add security hardening features (#543)
* add security hardening features - Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007) - Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011) - Add `--allowed-domains` flag to restrict browser navigation to trusted domains - Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation - Add `--max-output` flag to truncate large page outputs, preventing context flooding - New docs page at /security, updated README, SKILL.md, CLI help text, and templates * fixes * fixes * fixes * fixes * fixes * fixes * fixes * docs
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
getActionCategory,
|
||||
checkPolicy,
|
||||
loadPolicyFile,
|
||||
describeAction,
|
||||
KNOWN_CATEGORIES,
|
||||
type ActionPolicy,
|
||||
} from './action-policy.js';
|
||||
|
||||
describe('action-policy', () => {
|
||||
describe('getActionCategory', () => {
|
||||
it('should return correct category for known actions', () => {
|
||||
expect(getActionCategory('navigate')).toBe('navigate');
|
||||
expect(getActionCategory('click')).toBe('click');
|
||||
expect(getActionCategory('fill')).toBe('fill');
|
||||
expect(getActionCategory('evaluate')).toBe('eval');
|
||||
expect(getActionCategory('download')).toBe('download');
|
||||
expect(getActionCategory('upload')).toBe('upload');
|
||||
expect(getActionCategory('snapshot')).toBe('snapshot');
|
||||
expect(getActionCategory('scroll')).toBe('scroll');
|
||||
expect(getActionCategory('wait')).toBe('wait');
|
||||
expect(getActionCategory('gettext')).toBe('get');
|
||||
expect(getActionCategory('route')).toBe('network');
|
||||
expect(getActionCategory('state_save')).toBe('state');
|
||||
expect(getActionCategory('hover')).toBe('interact');
|
||||
});
|
||||
|
||||
it('should return _internal for internal actions', () => {
|
||||
expect(getActionCategory('launch')).toBe('_internal');
|
||||
expect(getActionCategory('close')).toBe('_internal');
|
||||
expect(getActionCategory('session')).toBe('_internal');
|
||||
expect(getActionCategory('auth_save')).toBe('_internal');
|
||||
expect(getActionCategory('confirm')).toBe('_internal');
|
||||
});
|
||||
|
||||
it('should return eval for security-sensitive actions', () => {
|
||||
expect(getActionCategory('setcontent')).toBe('eval');
|
||||
expect(getActionCategory('expose')).toBe('eval');
|
||||
expect(getActionCategory('addstyle')).toBe('eval');
|
||||
});
|
||||
|
||||
it('should return unknown for unrecognized actions', () => {
|
||||
expect(getActionCategory('nonexistent')).toBe('unknown');
|
||||
expect(getActionCategory('')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should return get for semantic locator actions', () => {
|
||||
expect(getActionCategory('getbyrole')).toBe('get');
|
||||
expect(getActionCategory('getbytext')).toBe('get');
|
||||
expect(getActionCategory('getbylabel')).toBe('get');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPolicy', () => {
|
||||
it('should always allow internal actions regardless of policy', () => {
|
||||
const denyAll: ActionPolicy = { default: 'deny' };
|
||||
expect(checkPolicy('launch', denyAll, new Set())).toBe('allow');
|
||||
expect(checkPolicy('close', denyAll, new Set())).toBe('allow');
|
||||
expect(checkPolicy('session', denyAll, new Set())).toBe('allow');
|
||||
});
|
||||
|
||||
it('should allow all when no policy and no confirm categories', () => {
|
||||
expect(checkPolicy('navigate', null, new Set())).toBe('allow');
|
||||
expect(checkPolicy('click', null, new Set())).toBe('allow');
|
||||
expect(checkPolicy('evaluate', null, new Set())).toBe('allow');
|
||||
});
|
||||
|
||||
it('should deny actions in explicit deny list', () => {
|
||||
const policy: ActionPolicy = { default: 'allow', deny: ['eval', 'download'] };
|
||||
expect(checkPolicy('evaluate', policy, new Set())).toBe('deny');
|
||||
expect(checkPolicy('download', policy, new Set())).toBe('deny');
|
||||
expect(checkPolicy('click', policy, new Set())).toBe('allow');
|
||||
});
|
||||
|
||||
it('should allow actions in explicit allow list with deny default', () => {
|
||||
const policy: ActionPolicy = { default: 'deny', allow: ['navigate', 'snapshot'] };
|
||||
expect(checkPolicy('navigate', policy, new Set())).toBe('allow');
|
||||
expect(checkPolicy('snapshot', policy, new Set())).toBe('allow');
|
||||
expect(checkPolicy('click', policy, new Set())).toBe('deny');
|
||||
});
|
||||
|
||||
it('should return confirm for actions in confirm categories', () => {
|
||||
expect(checkPolicy('evaluate', null, new Set(['eval']))).toBe('confirm');
|
||||
expect(checkPolicy('download', null, new Set(['download']))).toBe('confirm');
|
||||
});
|
||||
|
||||
it('should deny over confirm when action is in deny list', () => {
|
||||
const policy: ActionPolicy = { default: 'allow', deny: ['eval'] };
|
||||
expect(checkPolicy('evaluate', policy, new Set(['eval']))).toBe('deny');
|
||||
});
|
||||
|
||||
it('should use default policy for unknown categories', () => {
|
||||
const denyPolicy: ActionPolicy = { default: 'deny' };
|
||||
const allowPolicy: ActionPolicy = { default: 'allow' };
|
||||
expect(checkPolicy('nonexistent', denyPolicy, new Set())).toBe('deny');
|
||||
expect(checkPolicy('nonexistent', allowPolicy, new Set())).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPolicyFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'action-policy-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load a valid allow-default policy', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(policyPath, JSON.stringify({ default: 'allow', deny: ['eval'] }));
|
||||
const policy = loadPolicyFile(policyPath);
|
||||
expect(policy.default).toBe('allow');
|
||||
expect(policy.deny).toEqual(['eval']);
|
||||
});
|
||||
|
||||
it('should load a valid deny-default policy', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(
|
||||
policyPath,
|
||||
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot'] })
|
||||
);
|
||||
const policy = loadPolicyFile(policyPath);
|
||||
expect(policy.default).toBe('deny');
|
||||
expect(policy.allow).toEqual(['navigate', 'snapshot']);
|
||||
});
|
||||
|
||||
it('should throw on invalid default value', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(policyPath, JSON.stringify({ default: 'maybe' }));
|
||||
expect(() => loadPolicyFile(policyPath)).toThrow('must be "allow" or "deny"');
|
||||
});
|
||||
|
||||
it('should throw on missing file', () => {
|
||||
expect(() => loadPolicyFile(path.join(tempDir, 'missing.json'))).toThrow();
|
||||
});
|
||||
|
||||
it('should throw on invalid JSON', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(policyPath, 'not json');
|
||||
expect(() => loadPolicyFile(policyPath)).toThrow();
|
||||
});
|
||||
|
||||
it('should warn on unrecognized category names', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(
|
||||
policyPath,
|
||||
JSON.stringify({ default: 'allow', deny: ['eval', 'typo_category'] })
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const policy = loadPolicyFile(policyPath);
|
||||
expect(policy.default).toBe('allow');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unrecognized action category "typo_category"')
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not warn on valid category names', () => {
|
||||
const policyPath = path.join(tempDir, 'policy.json');
|
||||
fs.writeFileSync(
|
||||
policyPath,
|
||||
JSON.stringify({ default: 'deny', allow: ['navigate', 'snapshot', 'get'] })
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
loadPolicyFile(policyPath);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeAction', () => {
|
||||
it('should describe navigate actions', () => {
|
||||
expect(describeAction('navigate', { url: 'https://example.com' })).toBe(
|
||||
'Navigate to https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe eval actions with truncation', () => {
|
||||
const longScript = 'a'.repeat(200);
|
||||
const desc = describeAction('evaluate', { script: longScript });
|
||||
expect(desc).toContain('Evaluate JavaScript:');
|
||||
expect(desc.length).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should describe click actions', () => {
|
||||
expect(describeAction('click', { selector: '#btn' })).toBe('Click #btn');
|
||||
});
|
||||
|
||||
it('should describe dblclick actions', () => {
|
||||
expect(describeAction('dblclick', { selector: '#btn' })).toBe('Double-click #btn');
|
||||
});
|
||||
|
||||
it('should describe tap actions', () => {
|
||||
expect(describeAction('tap', { selector: '#btn' })).toBe('Tap #btn');
|
||||
});
|
||||
|
||||
it('should describe fill actions', () => {
|
||||
expect(describeAction('fill', { selector: '#input' })).toBe('Fill #input');
|
||||
});
|
||||
|
||||
it('should use fallback for unknown actions', () => {
|
||||
const desc = describeAction('scroll', {});
|
||||
expect(desc).toContain('scroll');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export interface ActionPolicy {
|
||||
default: 'allow' | 'deny';
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
}
|
||||
|
||||
export type PolicyDecision = 'allow' | 'deny' | 'confirm';
|
||||
|
||||
const ACTION_CATEGORIES: Record<string, string> = {
|
||||
navigate: 'navigate',
|
||||
back: 'navigate',
|
||||
forward: 'navigate',
|
||||
reload: 'navigate',
|
||||
tab_new: 'navigate',
|
||||
|
||||
click: 'click',
|
||||
dblclick: 'click',
|
||||
tap: 'click',
|
||||
|
||||
fill: 'fill',
|
||||
type: 'fill',
|
||||
// The `keyboard` action is a compound command that dispatches to sub-actions
|
||||
// (type, inserttext, press, down, up). Its primary use is text input, so it
|
||||
// maps to 'fill'. The interact-like sub-actions (press, down, up) are less
|
||||
// common and don't have separate top-level action names in the protocol.
|
||||
keyboard: 'fill',
|
||||
inserttext: 'fill',
|
||||
select: 'fill',
|
||||
multiselect: 'fill',
|
||||
check: 'fill',
|
||||
uncheck: 'fill',
|
||||
clear: 'fill',
|
||||
selectall: 'fill',
|
||||
setvalue: 'fill',
|
||||
|
||||
download: 'download',
|
||||
waitfordownload: 'download',
|
||||
|
||||
upload: 'upload',
|
||||
|
||||
evaluate: 'eval',
|
||||
evalhandle: 'eval',
|
||||
addscript: 'eval',
|
||||
addinitscript: 'eval',
|
||||
|
||||
snapshot: 'snapshot',
|
||||
screenshot: 'snapshot',
|
||||
pdf: 'snapshot',
|
||||
diff_snapshot: 'snapshot',
|
||||
diff_screenshot: 'snapshot',
|
||||
diff_url: 'snapshot',
|
||||
|
||||
scroll: 'scroll',
|
||||
scrollintoview: 'scroll',
|
||||
|
||||
wait: 'wait',
|
||||
waitforurl: 'wait',
|
||||
waitforloadstate: 'wait',
|
||||
waitforfunction: 'wait',
|
||||
|
||||
gettext: 'get',
|
||||
content: 'get',
|
||||
innerhtml: 'get',
|
||||
innertext: 'get',
|
||||
inputvalue: 'get',
|
||||
url: 'get',
|
||||
title: 'get',
|
||||
getattribute: 'get',
|
||||
count: 'get',
|
||||
boundingbox: 'get',
|
||||
styles: 'get',
|
||||
isvisible: 'get',
|
||||
isenabled: 'get',
|
||||
ischecked: 'get',
|
||||
responsebody: 'get',
|
||||
|
||||
route: 'network',
|
||||
unroute: 'network',
|
||||
requests: 'network',
|
||||
|
||||
state_save: 'state',
|
||||
state_load: 'state',
|
||||
cookies_set: 'state',
|
||||
storage_set: 'state',
|
||||
credentials: 'state',
|
||||
|
||||
hover: 'interact',
|
||||
focus: 'interact',
|
||||
drag: 'interact',
|
||||
press: 'interact',
|
||||
keydown: 'interact',
|
||||
keyup: 'interact',
|
||||
mousemove: 'interact',
|
||||
mousedown: 'interact',
|
||||
mouseup: 'interact',
|
||||
wheel: 'interact',
|
||||
dispatch: 'interact',
|
||||
|
||||
// These are always allowed (internal/meta operations)
|
||||
launch: '_internal',
|
||||
close: '_internal',
|
||||
tab_list: '_internal',
|
||||
tab_switch: '_internal',
|
||||
tab_close: '_internal',
|
||||
window_new: '_internal',
|
||||
frame: '_internal',
|
||||
mainframe: '_internal',
|
||||
dialog: '_internal',
|
||||
session: '_internal',
|
||||
console: '_internal',
|
||||
errors: '_internal',
|
||||
cookies_get: '_internal',
|
||||
cookies_clear: '_internal',
|
||||
storage_get: '_internal',
|
||||
storage_clear: '_internal',
|
||||
state_list: '_internal',
|
||||
state_show: '_internal',
|
||||
state_clear: '_internal',
|
||||
state_clean: '_internal',
|
||||
state_rename: '_internal',
|
||||
highlight: '_internal',
|
||||
bringtofront: '_internal',
|
||||
trace_start: '_internal',
|
||||
trace_stop: '_internal',
|
||||
har_start: '_internal',
|
||||
har_stop: '_internal',
|
||||
video_start: '_internal',
|
||||
video_stop: '_internal',
|
||||
recording_start: '_internal',
|
||||
recording_stop: '_internal',
|
||||
recording_restart: '_internal',
|
||||
profiler_start: '_internal',
|
||||
profiler_stop: '_internal',
|
||||
clipboard: '_internal',
|
||||
viewport: '_internal',
|
||||
useragent: '_internal',
|
||||
device: '_internal',
|
||||
geolocation: '_internal',
|
||||
permissions: '_internal',
|
||||
emulatemedia: '_internal',
|
||||
offline: '_internal',
|
||||
headers: '_internal',
|
||||
addstyle: 'eval',
|
||||
expose: 'eval',
|
||||
timezone: '_internal',
|
||||
locale: '_internal',
|
||||
pause: '_internal',
|
||||
setcontent: 'eval',
|
||||
screencast_start: '_internal',
|
||||
screencast_stop: '_internal',
|
||||
input_mouse: '_internal',
|
||||
input_keyboard: '_internal',
|
||||
input_touch: '_internal',
|
||||
|
||||
auth_save: '_internal',
|
||||
auth_login: '_internal',
|
||||
auth_list: '_internal',
|
||||
auth_delete: '_internal',
|
||||
auth_show: '_internal',
|
||||
confirm: '_internal',
|
||||
deny: '_internal',
|
||||
|
||||
// Find/semantic locator actions (read-only element resolution)
|
||||
getbyrole: 'get',
|
||||
getbytext: 'get',
|
||||
getbylabel: 'get',
|
||||
getbyplaceholder: 'get',
|
||||
getbyalttext: 'get',
|
||||
getbytitle: 'get',
|
||||
getbytestid: 'get',
|
||||
nth: 'get',
|
||||
};
|
||||
|
||||
// User-facing categories used in policy files. '_internal' is excluded because
|
||||
// internal actions always bypass policy. 'unknown' is intentionally not a value
|
||||
// in ACTION_CATEGORIES -- it is only the fallback return of getActionCategory()
|
||||
// for unrecognized actions. If a user puts "unknown" in a policy file,
|
||||
// loadPolicyFile will warn about it as unrecognized, which is correct.
|
||||
export const KNOWN_CATEGORIES = new Set(
|
||||
Object.values(ACTION_CATEGORIES).filter((c) => c !== '_internal')
|
||||
);
|
||||
|
||||
export function getActionCategory(action: string): string {
|
||||
return ACTION_CATEGORIES[action] ?? 'unknown';
|
||||
}
|
||||
|
||||
export function loadPolicyFile(policyPath: string): ActionPolicy {
|
||||
const resolved = resolve(policyPath);
|
||||
const content = readFileSync(resolved, 'utf-8');
|
||||
const policy = JSON.parse(content) as ActionPolicy;
|
||||
|
||||
if (policy.default !== 'allow' && policy.default !== 'deny') {
|
||||
throw new Error(
|
||||
`Invalid action policy: "default" must be "allow" or "deny", got "${policy.default}"`
|
||||
);
|
||||
}
|
||||
|
||||
for (const list of [policy.allow, policy.deny]) {
|
||||
if (!list) continue;
|
||||
for (const category of list) {
|
||||
if (!KNOWN_CATEGORIES.has(category)) {
|
||||
console.warn(
|
||||
`[agent-browser] Warning: unrecognized action category "${category}" in policy file. ` +
|
||||
`Known categories: ${[...KNOWN_CATEGORIES].sort().join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
let cachedPolicyPath: string | null = null;
|
||||
let cachedPolicyMtimeMs = 0;
|
||||
let cachedPolicy: ActionPolicy | null = null;
|
||||
const RELOAD_CHECK_INTERVAL_MS = 5_000;
|
||||
let lastCheckMs = 0;
|
||||
|
||||
export function initPolicyReloader(policyPath: string, policy: ActionPolicy): void {
|
||||
cachedPolicyPath = resolve(policyPath);
|
||||
cachedPolicyMtimeMs = statSync(cachedPolicyPath).mtimeMs;
|
||||
cachedPolicy = policy;
|
||||
}
|
||||
|
||||
export function reloadPolicyIfChanged(): ActionPolicy | null {
|
||||
if (!cachedPolicyPath) return cachedPolicy;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastCheckMs < RELOAD_CHECK_INTERVAL_MS) return cachedPolicy;
|
||||
lastCheckMs = now;
|
||||
|
||||
try {
|
||||
const currentMtime = statSync(cachedPolicyPath).mtimeMs;
|
||||
if (currentMtime !== cachedPolicyMtimeMs) {
|
||||
cachedPolicy = loadPolicyFile(cachedPolicyPath);
|
||||
cachedPolicyMtimeMs = currentMtime;
|
||||
}
|
||||
} catch {
|
||||
// File may have been removed; keep using cached policy
|
||||
}
|
||||
|
||||
return cachedPolicy;
|
||||
}
|
||||
|
||||
export function checkPolicy(
|
||||
action: string,
|
||||
policy: ActionPolicy | null,
|
||||
confirmCategories: Set<string>
|
||||
): PolicyDecision {
|
||||
const category = getActionCategory(action);
|
||||
|
||||
// Internal actions are always allowed
|
||||
if (category === '_internal') return 'allow';
|
||||
|
||||
// Explicit deny takes precedence over confirmation
|
||||
if (policy?.deny?.includes(category)) return 'deny';
|
||||
|
||||
// Check if this category requires confirmation
|
||||
if (confirmCategories.has(category)) return 'confirm';
|
||||
|
||||
if (!policy) return 'allow';
|
||||
|
||||
// Explicit allow list
|
||||
if (policy.allow?.includes(category)) return 'allow';
|
||||
|
||||
return policy.default;
|
||||
}
|
||||
|
||||
export function describeAction(action: string, command: Record<string, unknown>): string {
|
||||
const category = getActionCategory(action);
|
||||
switch (action) {
|
||||
case 'navigate':
|
||||
return `Navigate to ${command.url}`;
|
||||
case 'evaluate':
|
||||
case 'evalhandle':
|
||||
return `Evaluate JavaScript: ${String(command.script ?? '').slice(0, 80)}`;
|
||||
case 'fill':
|
||||
return `Fill ${command.selector}`;
|
||||
case 'type':
|
||||
return `Type into ${command.selector}`;
|
||||
case 'click':
|
||||
return `Click ${command.selector}`;
|
||||
case 'dblclick':
|
||||
return `Double-click ${command.selector}`;
|
||||
case 'tap':
|
||||
return `Tap ${command.selector}`;
|
||||
case 'download':
|
||||
return `Download via ${command.selector} to ${command.path}`;
|
||||
case 'upload':
|
||||
return `Upload files to ${command.selector}`;
|
||||
default:
|
||||
return `${category}: ${action}`;
|
||||
}
|
||||
}
|
||||
+513
-280
@@ -4,6 +4,17 @@ import type { Page, Frame } from 'playwright-core';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||
import { getAppDir } from './daemon.js';
|
||||
import {
|
||||
type ActionPolicy,
|
||||
checkPolicy,
|
||||
describeAction,
|
||||
getActionCategory,
|
||||
loadPolicyFile,
|
||||
initPolicyReloader,
|
||||
reloadPolicyIfChanged,
|
||||
} from './action-policy.js';
|
||||
import { requestConfirmation, getAndRemovePending } from './confirmation.js';
|
||||
import { getAuthProfile, updateLastLogin } from './auth-vault.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
readStateFile,
|
||||
@@ -126,6 +137,9 @@ import type {
|
||||
DiffSnapshotCommand,
|
||||
DiffScreenshotCommand,
|
||||
DiffUrlCommand,
|
||||
AuthLoginCommand,
|
||||
ConfirmCommand,
|
||||
DenyCommand,
|
||||
Annotation,
|
||||
NavigateData,
|
||||
ScreenshotData,
|
||||
@@ -146,7 +160,7 @@ import type {
|
||||
InputEventData,
|
||||
StylesData,
|
||||
} from './types.js';
|
||||
import { successResponse, errorResponse } from './protocol.js';
|
||||
import { successResponse, errorResponse, parseCommand } from './protocol.js';
|
||||
import { diffSnapshots, diffScreenshots } from './diff.js';
|
||||
import { getEnhancedSnapshot } from './snapshot.js';
|
||||
|
||||
@@ -228,290 +242,365 @@ export function toAIFriendlyError(error: unknown, selector: string): Error {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
let actionPolicy: ActionPolicy | null = null;
|
||||
let confirmCategories = new Set<string>();
|
||||
|
||||
export function initActionPolicy(): void {
|
||||
const policyPath = process.env.AGENT_BROWSER_ACTION_POLICY;
|
||||
if (policyPath) {
|
||||
try {
|
||||
actionPolicy = loadPolicyFile(policyPath);
|
||||
initPolicyReloader(policyPath, actionPolicy);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[ERROR] Failed to load action policy from ${policyPath}: ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const confirmActionsEnv = process.env.AGENT_BROWSER_CONFIRM_ACTIONS;
|
||||
if (confirmActionsEnv) {
|
||||
confirmCategories = new Set(
|
||||
confirmActionsEnv
|
||||
.split(',')
|
||||
.map((c) => c.trim().toLowerCase())
|
||||
.filter((c) => c.length > 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return a response
|
||||
*/
|
||||
export async function executeCommand(command: Command, browser: BrowserManager): Promise<Response> {
|
||||
try {
|
||||
switch (command.action) {
|
||||
case 'launch':
|
||||
return await handleLaunch(command, browser);
|
||||
case 'navigate':
|
||||
return await handleNavigate(command, browser);
|
||||
case 'click':
|
||||
return await handleClick(command, browser);
|
||||
case 'type':
|
||||
return await handleType(command, browser);
|
||||
case 'fill':
|
||||
return await handleFill(command, browser);
|
||||
case 'check':
|
||||
return await handleCheck(command, browser);
|
||||
case 'uncheck':
|
||||
return await handleUncheck(command, browser);
|
||||
case 'upload':
|
||||
return await handleUpload(command, browser);
|
||||
case 'dblclick':
|
||||
return await handleDoubleClick(command, browser);
|
||||
case 'focus':
|
||||
return await handleFocus(command, browser);
|
||||
case 'drag':
|
||||
return await handleDrag(command, browser);
|
||||
case 'frame':
|
||||
return await handleFrame(command, browser);
|
||||
case 'mainframe':
|
||||
return await handleMainFrame(command, browser);
|
||||
case 'getbyrole':
|
||||
return await handleGetByRole(command, browser);
|
||||
case 'getbytext':
|
||||
return await handleGetByText(command, browser);
|
||||
case 'getbylabel':
|
||||
return await handleGetByLabel(command, browser);
|
||||
case 'getbyplaceholder':
|
||||
return await handleGetByPlaceholder(command, browser);
|
||||
case 'press':
|
||||
return await handlePress(command, browser);
|
||||
case 'screenshot':
|
||||
return await handleScreenshot(command, browser);
|
||||
case 'snapshot':
|
||||
return await handleSnapshot(command, browser);
|
||||
case 'evaluate':
|
||||
return await handleEvaluate(command, browser);
|
||||
case 'wait':
|
||||
return await handleWait(command, browser);
|
||||
case 'scroll':
|
||||
return await handleScroll(command, browser);
|
||||
case 'select':
|
||||
return await handleSelect(command, browser);
|
||||
case 'hover':
|
||||
return await handleHover(command, browser);
|
||||
case 'content':
|
||||
return await handleContent(command, browser);
|
||||
case 'close':
|
||||
return await handleClose(command, browser);
|
||||
case 'tab_new':
|
||||
return await handleTabNew(command, browser);
|
||||
case 'tab_list':
|
||||
return await handleTabList(command, browser);
|
||||
case 'tab_switch':
|
||||
return await handleTabSwitch(command, browser);
|
||||
case 'tab_close':
|
||||
return await handleTabClose(command, browser);
|
||||
case 'window_new':
|
||||
return await handleWindowNew(command, browser);
|
||||
case 'cookies_get':
|
||||
return await handleCookiesGet(command, browser);
|
||||
case 'cookies_set':
|
||||
return await handleCookiesSet(command, browser);
|
||||
case 'cookies_clear':
|
||||
return await handleCookiesClear(command, browser);
|
||||
case 'storage_get':
|
||||
return await handleStorageGet(command, browser);
|
||||
case 'storage_set':
|
||||
return await handleStorageSet(command, browser);
|
||||
case 'storage_clear':
|
||||
return await handleStorageClear(command, browser);
|
||||
case 'dialog':
|
||||
return await handleDialog(command, browser);
|
||||
case 'pdf':
|
||||
return await handlePdf(command, browser);
|
||||
case 'route':
|
||||
return await handleRoute(command, browser);
|
||||
case 'unroute':
|
||||
return await handleUnroute(command, browser);
|
||||
case 'requests':
|
||||
return await handleRequests(command, browser);
|
||||
case 'download':
|
||||
return await handleDownload(command, browser);
|
||||
case 'geolocation':
|
||||
return await handleGeolocation(command, browser);
|
||||
case 'permissions':
|
||||
return await handlePermissions(command, browser);
|
||||
case 'viewport':
|
||||
return await handleViewport(command, browser);
|
||||
case 'useragent':
|
||||
return await handleUserAgent(command, browser);
|
||||
case 'device':
|
||||
return await handleDevice(command, browser);
|
||||
case 'back':
|
||||
return await handleBack(command, browser);
|
||||
case 'forward':
|
||||
return await handleForward(command, browser);
|
||||
case 'reload':
|
||||
return await handleReload(command, browser);
|
||||
case 'url':
|
||||
return await handleUrl(command, browser);
|
||||
case 'title':
|
||||
return await handleTitle(command, browser);
|
||||
case 'getattribute':
|
||||
return await handleGetAttribute(command, browser);
|
||||
case 'gettext':
|
||||
return await handleGetText(command, browser);
|
||||
case 'isvisible':
|
||||
return await handleIsVisible(command, browser);
|
||||
case 'isenabled':
|
||||
return await handleIsEnabled(command, browser);
|
||||
case 'ischecked':
|
||||
return await handleIsChecked(command, browser);
|
||||
case 'count':
|
||||
return await handleCount(command, browser);
|
||||
case 'boundingbox':
|
||||
return await handleBoundingBox(command, browser);
|
||||
case 'styles':
|
||||
return await handleStyles(command, browser);
|
||||
case 'video_start':
|
||||
return await handleVideoStart(command, browser);
|
||||
case 'video_stop':
|
||||
return await handleVideoStop(command, browser);
|
||||
case 'trace_start':
|
||||
return await handleTraceStart(command, browser);
|
||||
case 'trace_stop':
|
||||
return await handleTraceStop(command, browser);
|
||||
case 'profiler_start':
|
||||
return await handleProfilerStart(command, browser);
|
||||
case 'profiler_stop':
|
||||
return await handleProfilerStop(command, browser);
|
||||
case 'har_start':
|
||||
return await handleHarStart(command, browser);
|
||||
case 'har_stop':
|
||||
return await handleHarStop(command, browser);
|
||||
case 'state_save':
|
||||
return await handleStateSave(command, browser);
|
||||
case 'state_load':
|
||||
return await handleStateLoad(command, browser);
|
||||
case 'state_list':
|
||||
return await handleStateList(command);
|
||||
case 'state_clear':
|
||||
return await handleStateClear(command);
|
||||
case 'state_show':
|
||||
return await handleStateShow(command);
|
||||
case 'state_clean':
|
||||
return await handleStateClean(command);
|
||||
case 'state_rename':
|
||||
return await handleStateRename(command);
|
||||
case 'console':
|
||||
return await handleConsole(command, browser);
|
||||
case 'errors':
|
||||
return await handleErrors(command, browser);
|
||||
case 'keyboard':
|
||||
return await handleKeyboard(command, browser);
|
||||
case 'wheel':
|
||||
return await handleWheel(command, browser);
|
||||
case 'tap':
|
||||
return await handleTap(command, browser);
|
||||
case 'clipboard':
|
||||
return await handleClipboard(command, browser);
|
||||
case 'highlight':
|
||||
return await handleHighlight(command, browser);
|
||||
case 'clear':
|
||||
return await handleClear(command, browser);
|
||||
case 'selectall':
|
||||
return await handleSelectAll(command, browser);
|
||||
case 'innertext':
|
||||
return await handleInnerText(command, browser);
|
||||
case 'innerhtml':
|
||||
return await handleInnerHtml(command, browser);
|
||||
case 'inputvalue':
|
||||
return await handleInputValue(command, browser);
|
||||
case 'setvalue':
|
||||
return await handleSetValue(command, browser);
|
||||
case 'dispatch':
|
||||
return await handleDispatch(command, browser);
|
||||
case 'evalhandle':
|
||||
return await handleEvalHandle(command, browser);
|
||||
case 'expose':
|
||||
return await handleExpose(command, browser);
|
||||
case 'addscript':
|
||||
return await handleAddScript(command, browser);
|
||||
case 'addstyle':
|
||||
return await handleAddStyle(command, browser);
|
||||
case 'emulatemedia':
|
||||
return await handleEmulateMedia(command, browser);
|
||||
case 'offline':
|
||||
return await handleOffline(command, browser);
|
||||
case 'headers':
|
||||
return await handleHeaders(command, browser);
|
||||
case 'pause':
|
||||
return await handlePause(command, browser);
|
||||
case 'getbyalttext':
|
||||
return await handleGetByAltText(command, browser);
|
||||
case 'getbytitle':
|
||||
return await handleGetByTitle(command, browser);
|
||||
case 'getbytestid':
|
||||
return await handleGetByTestId(command, browser);
|
||||
case 'nth':
|
||||
return await handleNth(command, browser);
|
||||
case 'waitforurl':
|
||||
return await handleWaitForUrl(command, browser);
|
||||
case 'waitforloadstate':
|
||||
return await handleWaitForLoadState(command, browser);
|
||||
case 'setcontent':
|
||||
return await handleSetContent(command, browser);
|
||||
case 'timezone':
|
||||
return await handleTimezone(command, browser);
|
||||
case 'locale':
|
||||
return await handleLocale(command, browser);
|
||||
case 'credentials':
|
||||
return await handleCredentials(command, browser);
|
||||
case 'mousemove':
|
||||
return await handleMouseMove(command, browser);
|
||||
case 'mousedown':
|
||||
return await handleMouseDown(command, browser);
|
||||
case 'mouseup':
|
||||
return await handleMouseUp(command, browser);
|
||||
case 'bringtofront':
|
||||
return await handleBringToFront(command, browser);
|
||||
case 'waitforfunction':
|
||||
return await handleWaitForFunction(command, browser);
|
||||
case 'scrollintoview':
|
||||
return await handleScrollIntoView(command, browser);
|
||||
case 'addinitscript':
|
||||
return await handleAddInitScript(command, browser);
|
||||
case 'keydown':
|
||||
return await handleKeyDown(command, browser);
|
||||
case 'keyup':
|
||||
return await handleKeyUp(command, browser);
|
||||
case 'inserttext':
|
||||
return await handleInsertText(command, browser);
|
||||
case 'multiselect':
|
||||
return await handleMultiSelect(command, browser);
|
||||
case 'waitfordownload':
|
||||
return await handleWaitForDownload(command, browser);
|
||||
case 'responsebody':
|
||||
return await handleResponseBody(command, browser);
|
||||
case 'screencast_start':
|
||||
return await handleScreencastStart(command, browser);
|
||||
case 'screencast_stop':
|
||||
return await handleScreencastStop(command, browser);
|
||||
case 'input_mouse':
|
||||
return await handleInputMouse(command, browser);
|
||||
case 'input_keyboard':
|
||||
return await handleInputKeyboard(command, browser);
|
||||
case 'input_touch':
|
||||
return await handleInputTouch(command, browser);
|
||||
case 'recording_start':
|
||||
return await handleRecordingStart(command, browser);
|
||||
case 'recording_stop':
|
||||
return await handleRecordingStop(command, browser);
|
||||
case 'recording_restart':
|
||||
return await handleRecordingRestart(command, browser);
|
||||
case 'diff_snapshot':
|
||||
return await handleDiffSnapshot(command, browser);
|
||||
case 'diff_screenshot':
|
||||
return await handleDiffScreenshot(command, browser);
|
||||
case 'diff_url':
|
||||
return await handleDiffUrl(command, browser);
|
||||
default: {
|
||||
// TypeScript narrows to never here, but we handle it for safety
|
||||
const unknownCommand = command as { id: string; action: string };
|
||||
return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`);
|
||||
}
|
||||
// Handle confirm/deny actions (bypass policy check)
|
||||
if (command.action === 'confirm') {
|
||||
return await handleConfirm(command, browser);
|
||||
}
|
||||
if (command.action === 'deny') {
|
||||
return handleDeny(command);
|
||||
}
|
||||
|
||||
// Hot-reload policy file if it changed on disk
|
||||
actionPolicy = reloadPolicyIfChanged();
|
||||
|
||||
// Policy enforcement
|
||||
const decision = checkPolicy(command.action, actionPolicy, confirmCategories);
|
||||
if (decision === 'deny') {
|
||||
const category = getActionCategory(command.action);
|
||||
return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`);
|
||||
}
|
||||
if (decision === 'confirm') {
|
||||
const category = getActionCategory(command.action);
|
||||
const description = describeAction(
|
||||
command.action,
|
||||
command as unknown as Record<string, unknown>
|
||||
);
|
||||
const { confirmationId } = requestConfirmation(
|
||||
command.action,
|
||||
category,
|
||||
description,
|
||||
command as unknown as Record<string, unknown>
|
||||
);
|
||||
return successResponse(command.id, {
|
||||
confirmation_required: true,
|
||||
action: command.action,
|
||||
category,
|
||||
description,
|
||||
confirmation_id: confirmationId,
|
||||
});
|
||||
}
|
||||
|
||||
return await dispatchAction(command, browser);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse(command.id, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a command to its handler after policy checks have passed.
|
||||
*/
|
||||
async function dispatchAction(command: Command, browser: BrowserManager): Promise<Response> {
|
||||
switch (command.action) {
|
||||
case 'launch':
|
||||
return await handleLaunch(command, browser);
|
||||
case 'navigate':
|
||||
return await handleNavigate(command, browser);
|
||||
case 'click':
|
||||
return await handleClick(command, browser);
|
||||
case 'type':
|
||||
return await handleType(command, browser);
|
||||
case 'fill':
|
||||
return await handleFill(command, browser);
|
||||
case 'check':
|
||||
return await handleCheck(command, browser);
|
||||
case 'uncheck':
|
||||
return await handleUncheck(command, browser);
|
||||
case 'upload':
|
||||
return await handleUpload(command, browser);
|
||||
case 'dblclick':
|
||||
return await handleDoubleClick(command, browser);
|
||||
case 'focus':
|
||||
return await handleFocus(command, browser);
|
||||
case 'drag':
|
||||
return await handleDrag(command, browser);
|
||||
case 'frame':
|
||||
return await handleFrame(command, browser);
|
||||
case 'mainframe':
|
||||
return await handleMainFrame(command, browser);
|
||||
case 'getbyrole':
|
||||
return await handleGetByRole(command, browser);
|
||||
case 'getbytext':
|
||||
return await handleGetByText(command, browser);
|
||||
case 'getbylabel':
|
||||
return await handleGetByLabel(command, browser);
|
||||
case 'getbyplaceholder':
|
||||
return await handleGetByPlaceholder(command, browser);
|
||||
case 'press':
|
||||
return await handlePress(command, browser);
|
||||
case 'screenshot':
|
||||
return await handleScreenshot(command, browser);
|
||||
case 'snapshot':
|
||||
return await handleSnapshot(command, browser);
|
||||
case 'evaluate':
|
||||
return await handleEvaluate(command, browser);
|
||||
case 'wait':
|
||||
return await handleWait(command, browser);
|
||||
case 'scroll':
|
||||
return await handleScroll(command, browser);
|
||||
case 'select':
|
||||
return await handleSelect(command, browser);
|
||||
case 'hover':
|
||||
return await handleHover(command, browser);
|
||||
case 'content':
|
||||
return await handleContent(command, browser);
|
||||
case 'close':
|
||||
return await handleClose(command, browser);
|
||||
case 'tab_new':
|
||||
return await handleTabNew(command, browser);
|
||||
case 'tab_list':
|
||||
return await handleTabList(command, browser);
|
||||
case 'tab_switch':
|
||||
return await handleTabSwitch(command, browser);
|
||||
case 'tab_close':
|
||||
return await handleTabClose(command, browser);
|
||||
case 'window_new':
|
||||
return await handleWindowNew(command, browser);
|
||||
case 'cookies_get':
|
||||
return await handleCookiesGet(command, browser);
|
||||
case 'cookies_set':
|
||||
return await handleCookiesSet(command, browser);
|
||||
case 'cookies_clear':
|
||||
return await handleCookiesClear(command, browser);
|
||||
case 'storage_get':
|
||||
return await handleStorageGet(command, browser);
|
||||
case 'storage_set':
|
||||
return await handleStorageSet(command, browser);
|
||||
case 'storage_clear':
|
||||
return await handleStorageClear(command, browser);
|
||||
case 'dialog':
|
||||
return await handleDialog(command, browser);
|
||||
case 'pdf':
|
||||
return await handlePdf(command, browser);
|
||||
case 'route':
|
||||
return await handleRoute(command, browser);
|
||||
case 'unroute':
|
||||
return await handleUnroute(command, browser);
|
||||
case 'requests':
|
||||
return await handleRequests(command, browser);
|
||||
case 'download':
|
||||
return await handleDownload(command, browser);
|
||||
case 'geolocation':
|
||||
return await handleGeolocation(command, browser);
|
||||
case 'permissions':
|
||||
return await handlePermissions(command, browser);
|
||||
case 'viewport':
|
||||
return await handleViewport(command, browser);
|
||||
case 'useragent':
|
||||
return await handleUserAgent(command, browser);
|
||||
case 'device':
|
||||
return await handleDevice(command, browser);
|
||||
case 'back':
|
||||
return await handleBack(command, browser);
|
||||
case 'forward':
|
||||
return await handleForward(command, browser);
|
||||
case 'reload':
|
||||
return await handleReload(command, browser);
|
||||
case 'url':
|
||||
return await handleUrl(command, browser);
|
||||
case 'title':
|
||||
return await handleTitle(command, browser);
|
||||
case 'getattribute':
|
||||
return await handleGetAttribute(command, browser);
|
||||
case 'gettext':
|
||||
return await handleGetText(command, browser);
|
||||
case 'isvisible':
|
||||
return await handleIsVisible(command, browser);
|
||||
case 'isenabled':
|
||||
return await handleIsEnabled(command, browser);
|
||||
case 'ischecked':
|
||||
return await handleIsChecked(command, browser);
|
||||
case 'count':
|
||||
return await handleCount(command, browser);
|
||||
case 'boundingbox':
|
||||
return await handleBoundingBox(command, browser);
|
||||
case 'styles':
|
||||
return await handleStyles(command, browser);
|
||||
case 'video_start':
|
||||
return await handleVideoStart(command, browser);
|
||||
case 'video_stop':
|
||||
return await handleVideoStop(command, browser);
|
||||
case 'trace_start':
|
||||
return await handleTraceStart(command, browser);
|
||||
case 'trace_stop':
|
||||
return await handleTraceStop(command, browser);
|
||||
case 'profiler_start':
|
||||
return await handleProfilerStart(command, browser);
|
||||
case 'profiler_stop':
|
||||
return await handleProfilerStop(command, browser);
|
||||
case 'har_start':
|
||||
return await handleHarStart(command, browser);
|
||||
case 'har_stop':
|
||||
return await handleHarStop(command, browser);
|
||||
case 'state_save':
|
||||
return await handleStateSave(command, browser);
|
||||
case 'state_load':
|
||||
return await handleStateLoad(command, browser);
|
||||
case 'state_list':
|
||||
return await handleStateList(command);
|
||||
case 'state_clear':
|
||||
return await handleStateClear(command);
|
||||
case 'state_show':
|
||||
return await handleStateShow(command);
|
||||
case 'state_clean':
|
||||
return await handleStateClean(command);
|
||||
case 'state_rename':
|
||||
return await handleStateRename(command);
|
||||
case 'console':
|
||||
return await handleConsole(command, browser);
|
||||
case 'errors':
|
||||
return await handleErrors(command, browser);
|
||||
case 'keyboard':
|
||||
return await handleKeyboard(command, browser);
|
||||
case 'wheel':
|
||||
return await handleWheel(command, browser);
|
||||
case 'tap':
|
||||
return await handleTap(command, browser);
|
||||
case 'clipboard':
|
||||
return await handleClipboard(command, browser);
|
||||
case 'highlight':
|
||||
return await handleHighlight(command, browser);
|
||||
case 'clear':
|
||||
return await handleClear(command, browser);
|
||||
case 'selectall':
|
||||
return await handleSelectAll(command, browser);
|
||||
case 'innertext':
|
||||
return await handleInnerText(command, browser);
|
||||
case 'innerhtml':
|
||||
return await handleInnerHtml(command, browser);
|
||||
case 'inputvalue':
|
||||
return await handleInputValue(command, browser);
|
||||
case 'setvalue':
|
||||
return await handleSetValue(command, browser);
|
||||
case 'dispatch':
|
||||
return await handleDispatch(command, browser);
|
||||
case 'evalhandle':
|
||||
return await handleEvalHandle(command, browser);
|
||||
case 'expose':
|
||||
return await handleExpose(command, browser);
|
||||
case 'addscript':
|
||||
return await handleAddScript(command, browser);
|
||||
case 'addstyle':
|
||||
return await handleAddStyle(command, browser);
|
||||
case 'emulatemedia':
|
||||
return await handleEmulateMedia(command, browser);
|
||||
case 'offline':
|
||||
return await handleOffline(command, browser);
|
||||
case 'headers':
|
||||
return await handleHeaders(command, browser);
|
||||
case 'pause':
|
||||
return await handlePause(command, browser);
|
||||
case 'getbyalttext':
|
||||
return await handleGetByAltText(command, browser);
|
||||
case 'getbytitle':
|
||||
return await handleGetByTitle(command, browser);
|
||||
case 'getbytestid':
|
||||
return await handleGetByTestId(command, browser);
|
||||
case 'nth':
|
||||
return await handleNth(command, browser);
|
||||
case 'waitforurl':
|
||||
return await handleWaitForUrl(command, browser);
|
||||
case 'waitforloadstate':
|
||||
return await handleWaitForLoadState(command, browser);
|
||||
case 'setcontent':
|
||||
return await handleSetContent(command, browser);
|
||||
case 'timezone':
|
||||
return await handleTimezone(command, browser);
|
||||
case 'locale':
|
||||
return await handleLocale(command, browser);
|
||||
case 'credentials':
|
||||
return await handleCredentials(command, browser);
|
||||
case 'mousemove':
|
||||
return await handleMouseMove(command, browser);
|
||||
case 'mousedown':
|
||||
return await handleMouseDown(command, browser);
|
||||
case 'mouseup':
|
||||
return await handleMouseUp(command, browser);
|
||||
case 'bringtofront':
|
||||
return await handleBringToFront(command, browser);
|
||||
case 'waitforfunction':
|
||||
return await handleWaitForFunction(command, browser);
|
||||
case 'scrollintoview':
|
||||
return await handleScrollIntoView(command, browser);
|
||||
case 'addinitscript':
|
||||
return await handleAddInitScript(command, browser);
|
||||
case 'keydown':
|
||||
return await handleKeyDown(command, browser);
|
||||
case 'keyup':
|
||||
return await handleKeyUp(command, browser);
|
||||
case 'inserttext':
|
||||
return await handleInsertText(command, browser);
|
||||
case 'multiselect':
|
||||
return await handleMultiSelect(command, browser);
|
||||
case 'waitfordownload':
|
||||
return await handleWaitForDownload(command, browser);
|
||||
case 'responsebody':
|
||||
return await handleResponseBody(command, browser);
|
||||
case 'screencast_start':
|
||||
return await handleScreencastStart(command, browser);
|
||||
case 'screencast_stop':
|
||||
return await handleScreencastStop(command, browser);
|
||||
case 'input_mouse':
|
||||
return await handleInputMouse(command, browser);
|
||||
case 'input_keyboard':
|
||||
return await handleInputKeyboard(command, browser);
|
||||
case 'input_touch':
|
||||
return await handleInputTouch(command, browser);
|
||||
case 'recording_start':
|
||||
return await handleRecordingStart(command, browser);
|
||||
case 'recording_stop':
|
||||
return await handleRecordingStop(command, browser);
|
||||
case 'recording_restart':
|
||||
return await handleRecordingRestart(command, browser);
|
||||
case 'diff_snapshot':
|
||||
return await handleDiffSnapshot(command, browser);
|
||||
case 'diff_screenshot':
|
||||
return await handleDiffScreenshot(command, browser);
|
||||
case 'diff_url':
|
||||
return await handleDiffUrl(command, browser);
|
||||
case 'auth_login':
|
||||
return await handleAuthLogin(command, browser);
|
||||
default: {
|
||||
// TypeScript narrows to never here, but we handle it for safety
|
||||
const unknownCommand = command as { id: string; action: string };
|
||||
return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLaunch(
|
||||
command: Command & { action: 'launch' },
|
||||
browser: BrowserManager
|
||||
@@ -524,6 +613,8 @@ async function handleNavigate(
|
||||
command: NavigateCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<NavigateData>> {
|
||||
browser.checkDomainAllowed(command.url);
|
||||
|
||||
const page = browser.getPage();
|
||||
|
||||
// If headers are provided, set up scoped headers for this origin
|
||||
@@ -843,9 +934,11 @@ async function handleSnapshot(
|
||||
simpleRefs[ref] = { role: data.role, name: data.name };
|
||||
}
|
||||
|
||||
const page = browser.getPage();
|
||||
return successResponse(command.id, {
|
||||
snapshot: tree || 'Empty page',
|
||||
refs: Object.keys(simpleRefs).length > 0 ? simpleRefs : undefined,
|
||||
origin: page.url(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -858,7 +951,7 @@ async function handleEvaluate(
|
||||
// Evaluate the script directly as a string expression
|
||||
const result = await page.evaluate(command.script);
|
||||
|
||||
return successResponse(command.id, { result });
|
||||
return successResponse(command.id, { result, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleWait(command: WaitCommand, browser: BrowserManager): Promise<Response> {
|
||||
@@ -960,7 +1053,7 @@ async function handleContent(
|
||||
html = await page.content();
|
||||
}
|
||||
|
||||
return successResponse(command.id, { html });
|
||||
return successResponse(command.id, { html, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleClose(
|
||||
@@ -1472,15 +1565,17 @@ async function handleGetAttribute(
|
||||
command: GetAttributeCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
const locator = browser.getLocator(command.selector);
|
||||
const value = await locator.getAttribute(command.attribute);
|
||||
return successResponse(command.id, { attribute: command.attribute, value });
|
||||
return successResponse(command.id, { attribute: command.attribute, value, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleGetText(command: GetTextCommand, browser: BrowserManager): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
const locator = browser.getLocator(command.selector);
|
||||
const text = await locator.textContent();
|
||||
return successResponse(command.id, { text });
|
||||
return successResponse(command.id, { text, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleIsVisible(
|
||||
@@ -1875,8 +1970,9 @@ async function handleConsole(command: ConsoleCommand, browser: BrowserManager):
|
||||
return successResponse(command.id, { cleared: true });
|
||||
}
|
||||
|
||||
const page = browser.getPage();
|
||||
const messages = browser.getConsoleMessages();
|
||||
return successResponse(command.id, { messages });
|
||||
return successResponse(command.id, { messages, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleErrors(command: ErrorsCommand, browser: BrowserManager): Promise<Response> {
|
||||
@@ -1989,16 +2085,17 @@ async function handleInnerHtml(
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
const html = await page.locator(command.selector).innerHTML();
|
||||
return successResponse(command.id, { html });
|
||||
return successResponse(command.id, { html, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleInputValue(
|
||||
command: InputValueCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
const locator = browser.getLocator(command.selector);
|
||||
const value = await locator.inputValue();
|
||||
return successResponse(command.id, { value });
|
||||
return successResponse(command.id, { value, origin: page.url() });
|
||||
}
|
||||
|
||||
async function handleSetValue(
|
||||
@@ -2597,3 +2694,139 @@ async function handleDiffUrl(command: DiffUrlCommand, browser: BrowserManager):
|
||||
|
||||
return successResponse(command.id, result);
|
||||
}
|
||||
|
||||
async function handleAuthLogin(
|
||||
command: AuthLoginCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const profile = getAuthProfile(command.name);
|
||||
if (!profile) {
|
||||
return errorResponse(command.id, `Auth profile '${command.name}' not found`);
|
||||
}
|
||||
|
||||
browser.checkDomainAllowed(profile.url);
|
||||
|
||||
const page = browser.getPage();
|
||||
await page.goto(profile.url, { waitUntil: 'load' });
|
||||
|
||||
const usingAutoDetect =
|
||||
!profile.usernameSelector && !profile.passwordSelector && !profile.submitSelector;
|
||||
if (usingAutoDetect) {
|
||||
console.error(
|
||||
`[agent-browser] Auth login '${command.name}': using auto-detected form selectors. ` +
|
||||
`If login fails, specify --username-selector/--password-selector/--submit-selector with auth save.`
|
||||
);
|
||||
}
|
||||
|
||||
const passSel = profile.passwordSelector || 'input[type="password"]:visible';
|
||||
|
||||
// Auto-detect selectors ordered from most specific to broadest.
|
||||
// Locale-dependent text matchers (e.g. "Sign in") are intentionally
|
||||
// excluded -- they break on non-English pages.
|
||||
const AUTO_USER_SELECTORS = [
|
||||
'input[autocomplete="username"]:visible',
|
||||
'input[type="email"]:visible',
|
||||
'input[name="username"]:visible',
|
||||
'input[name="email"]:visible',
|
||||
];
|
||||
const AUTO_SUBMIT_SELECTORS = ['button[type="submit"]:visible', 'input[type="submit"]:visible'];
|
||||
|
||||
try {
|
||||
// Resolve username field: custom selector or sequential auto-detect
|
||||
let userLocator;
|
||||
if (profile.usernameSelector) {
|
||||
userLocator = page.locator(profile.usernameSelector).first();
|
||||
} else {
|
||||
userLocator = null;
|
||||
for (const sel of AUTO_USER_SELECTORS) {
|
||||
const loc = page.locator(sel).first();
|
||||
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
userLocator = loc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userLocator) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
`Auth login failed for '${command.name}': could not find username field. ` +
|
||||
`Specify --username-selector with auth save.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve submit button: custom selector or sequential auto-detect
|
||||
let submitLocator;
|
||||
if (profile.submitSelector) {
|
||||
submitLocator = page.locator(profile.submitSelector).first();
|
||||
} else {
|
||||
submitLocator = null;
|
||||
for (const sel of AUTO_SUBMIT_SELECTORS) {
|
||||
const loc = page.locator(sel).first();
|
||||
if (await loc.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
submitLocator = loc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!submitLocator) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
`Auth login failed for '${command.name}': could not find submit button. ` +
|
||||
`Specify --submit-selector with auth save.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await userLocator.fill(profile.username);
|
||||
await page.locator(passSel).first().fill(profile.password);
|
||||
await submitLocator.click();
|
||||
await page.waitForLoadState('load');
|
||||
} catch (err) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
`Auth login failed for '${command.name}': ${err instanceof Error ? err.message : err}. ` +
|
||||
`Try specifying custom selectors with auth save --username-selector/--password-selector/--submit-selector`
|
||||
);
|
||||
}
|
||||
|
||||
updateLastLogin(command.name);
|
||||
|
||||
return successResponse(command.id, {
|
||||
loggedIn: true,
|
||||
name: command.name,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleConfirm(command: ConfirmCommand, browser: BrowserManager): Promise<Response> {
|
||||
const entry = getAndRemovePending(command.confirmationId);
|
||||
if (!entry) {
|
||||
return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`);
|
||||
}
|
||||
|
||||
// Re-validate the stored command through the schema to guard against
|
||||
// shape drift between when the confirmation was issued and now.
|
||||
const parseResult = parseCommand(JSON.stringify(entry.command));
|
||||
if (!parseResult.success) {
|
||||
return errorResponse(command.id, `Stored command is no longer valid: ${parseResult.error}`);
|
||||
}
|
||||
const originalCommand = parseResult.command;
|
||||
|
||||
// Re-check deny list in case policy was updated since the confirmation was issued
|
||||
actionPolicy = reloadPolicyIfChanged();
|
||||
const decision = checkPolicy(originalCommand.action, actionPolicy, new Set());
|
||||
if (decision === 'deny') {
|
||||
const category = getActionCategory(originalCommand.action);
|
||||
return errorResponse(command.id, `Action denied by policy: '${category}' is not allowed`);
|
||||
}
|
||||
|
||||
return await dispatchAction(originalCommand, browser);
|
||||
}
|
||||
|
||||
function handleDeny(command: DenyCommand): Response {
|
||||
const entry = getAndRemovePending(command.confirmationId);
|
||||
if (!entry) {
|
||||
return errorResponse(command.id, `No pending confirmation with id '${command.confirmationId}'`);
|
||||
}
|
||||
return successResponse(command.id, { denied: true });
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Standalone CLI entry point for auth vault operations that don't need a browser.
|
||||
* Invoked directly by the Rust CLI to avoid sending passwords through the daemon channel.
|
||||
*
|
||||
* Usage: node auth-cli.js <json-command>
|
||||
* Prints a JSON response to stdout and exits.
|
||||
*/
|
||||
import {
|
||||
saveAuthProfile,
|
||||
getAuthProfileMeta,
|
||||
listAuthProfiles,
|
||||
deleteAuthProfile,
|
||||
} from './auth-vault.js';
|
||||
|
||||
interface AuthCommand {
|
||||
id: string;
|
||||
action: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}
|
||||
|
||||
function success(id: string, data: Record<string, unknown>): string {
|
||||
return JSON.stringify({ success: true, id, data });
|
||||
}
|
||||
|
||||
function error(id: string, message: string): string {
|
||||
return JSON.stringify({ success: false, id, error: message });
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const input = process.argv[2];
|
||||
if (!input) {
|
||||
process.stderr.write('Usage: node auth-cli.js <json-command>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let cmd: AuthCommand;
|
||||
try {
|
||||
cmd = JSON.parse(input);
|
||||
} catch {
|
||||
console.log(error('', 'Invalid JSON input'));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = cmd.id || '';
|
||||
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case 'auth_save': {
|
||||
if (!cmd.name || !cmd.url || !cmd.username || !cmd.password) {
|
||||
console.log(error(id, 'Missing required fields: name, url, username, password'));
|
||||
return;
|
||||
}
|
||||
const meta = saveAuthProfile({
|
||||
name: cmd.name,
|
||||
url: cmd.url,
|
||||
username: cmd.username,
|
||||
password: cmd.password,
|
||||
usernameSelector: cmd.usernameSelector,
|
||||
passwordSelector: cmd.passwordSelector,
|
||||
submitSelector: cmd.submitSelector,
|
||||
});
|
||||
console.log(
|
||||
success(id, {
|
||||
saved: !meta.updated,
|
||||
updated: meta.updated,
|
||||
name: meta.name,
|
||||
url: meta.url,
|
||||
username: meta.username,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'auth_list': {
|
||||
const profiles = listAuthProfiles();
|
||||
console.log(success(id, { profiles }));
|
||||
return;
|
||||
}
|
||||
case 'auth_show': {
|
||||
if (!cmd.name) {
|
||||
console.log(error(id, 'Missing required field: name'));
|
||||
return;
|
||||
}
|
||||
const meta = getAuthProfileMeta(cmd.name);
|
||||
if (!meta) {
|
||||
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||
return;
|
||||
}
|
||||
console.log(success(id, { profile: meta }));
|
||||
return;
|
||||
}
|
||||
case 'auth_delete': {
|
||||
if (!cmd.name) {
|
||||
console.log(error(id, 'Missing required field: name'));
|
||||
return;
|
||||
}
|
||||
const deleted = deleteAuthProfile(cmd.name);
|
||||
if (!deleted) {
|
||||
console.log(error(id, `Auth profile '${cmd.name}' not found`));
|
||||
return;
|
||||
}
|
||||
console.log(success(id, { deleted: true, name: cmd.name }));
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.log(error(id, `Unknown auth action: ${cmd.action}`));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Operation failed';
|
||||
console.log(error(id, msg));
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
homedir: () => tempHome,
|
||||
},
|
||||
homedir: () => tempHome,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
saveAuthProfile,
|
||||
getAuthProfile,
|
||||
getAuthProfileMeta,
|
||||
listAuthProfiles,
|
||||
deleteAuthProfile,
|
||||
updateLastLogin,
|
||||
} from './auth-vault.js';
|
||||
|
||||
describe('auth-vault', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-auth-test-'));
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
function cleanAuthDir() {
|
||||
const authDir = path.join(tempHome, '.agent-browser', 'auth');
|
||||
if (fs.existsSync(authDir)) {
|
||||
for (const f of fs.readdirSync(authDir)) {
|
||||
fs.unlinkSync(path.join(authDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('saveAuthProfile', () => {
|
||||
it('should save a new profile', () => {
|
||||
const result = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
expect(result.name).toBe('github');
|
||||
expect(result.url).toBe('https://github.com/login');
|
||||
expect(result.username).toBe('user');
|
||||
expect(result.updated).toBe(false);
|
||||
expect(result.createdAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should mark as updated when overwriting', () => {
|
||||
saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
|
||||
const result = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
expect(result.updated).toBe(true);
|
||||
expect(result.username).toBe('user2');
|
||||
});
|
||||
|
||||
it('should preserve createdAt on update', () => {
|
||||
const first = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const second = saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
expect(second.createdAt).toBe(first.createdAt);
|
||||
});
|
||||
|
||||
it('should save with custom selectors', () => {
|
||||
saveAuthProfile({
|
||||
name: 'myapp',
|
||||
url: 'https://example.com/login',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
usernameSelector: '#email',
|
||||
passwordSelector: '#password',
|
||||
submitSelector: 'button.login',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('myapp');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.usernameSelector).toBe('#email');
|
||||
expect(profile!.passwordSelector).toBe('#password');
|
||||
expect(profile!.submitSelector).toBe('button.login');
|
||||
});
|
||||
|
||||
it('should reject invalid profile names', () => {
|
||||
expect(() =>
|
||||
saveAuthProfile({
|
||||
name: '../escape',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
})
|
||||
).toThrow('only alphanumeric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthProfile', () => {
|
||||
it('should return null for non-existent profile', () => {
|
||||
expect(getAuthProfile('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return full profile with password', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('test');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.password).toBe('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthProfileMeta', () => {
|
||||
it('should return metadata without password', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const meta = getAuthProfileMeta('test');
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.name).toBe('test');
|
||||
expect(meta!.username).toBe('user');
|
||||
expect((meta as Record<string, unknown>).password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null for non-existent profile', () => {
|
||||
expect(getAuthProfileMeta('nonexistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAuthProfiles', () => {
|
||||
it('should return empty array when no profiles', () => {
|
||||
cleanAuthDir();
|
||||
expect(listAuthProfiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should list all saved profiles', () => {
|
||||
cleanAuthDir();
|
||||
saveAuthProfile({
|
||||
name: 'github',
|
||||
url: 'https://github.com/login',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
saveAuthProfile({
|
||||
name: 'gitlab',
|
||||
url: 'https://gitlab.com/login',
|
||||
username: 'user2',
|
||||
password: 'pass2',
|
||||
});
|
||||
|
||||
const profiles = listAuthProfiles();
|
||||
expect(profiles).toHaveLength(2);
|
||||
const names = profiles.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['github', 'gitlab']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAuthProfile', () => {
|
||||
it('should delete an existing profile', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
expect(deleteAuthProfile('test')).toBe(true);
|
||||
expect(getAuthProfile('test')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent profile', () => {
|
||||
expect(deleteAuthProfile('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLastLogin', () => {
|
||||
it('should update lastLoginAt timestamp', () => {
|
||||
saveAuthProfile({
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
const metaBefore = getAuthProfileMeta('test');
|
||||
expect(metaBefore!.lastLoginAt).toBeUndefined();
|
||||
|
||||
updateLastLogin('test');
|
||||
|
||||
const metaAfter = getAuthProfileMeta('test');
|
||||
expect(metaAfter!.lastLoginAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-generated encryption key', () => {
|
||||
it('should auto-create key file and encrypt profile when no env var is set', () => {
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
|
||||
saveAuthProfile({
|
||||
name: 'autokey',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
const keyFilePath = path.join(tempHome, '.agent-browser', '.encryption-key');
|
||||
expect(fs.existsSync(keyFilePath)).toBe(true);
|
||||
|
||||
const keyHex = fs.readFileSync(keyFilePath, 'utf-8').trim();
|
||||
expect(keyHex).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const profilePath = path.join(tempHome, '.agent-browser', 'auth', 'autokey.json');
|
||||
const raw = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
|
||||
expect(raw.encrypted).toBe(true);
|
||||
expect(raw.iv).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should read back profile using auto-generated key', () => {
|
||||
delete process.env.AGENT_BROWSER_ENCRYPTION_KEY;
|
||||
|
||||
saveAuthProfile({
|
||||
name: 'readback',
|
||||
url: 'https://example.com',
|
||||
username: 'user',
|
||||
password: 'secret123',
|
||||
});
|
||||
|
||||
const profile = getAuthProfile('readback');
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile!.password).toBe('secret123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
ensureEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
isEncryptedPayload,
|
||||
getKeyFilePath,
|
||||
restrictFilePermissions,
|
||||
restrictDirPermissions,
|
||||
type EncryptedPayload,
|
||||
} from './encryption.js';
|
||||
|
||||
const AUTH_DIR = 'auth';
|
||||
|
||||
interface AuthProfile {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthProfileMeta {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
function getAuthDir(): string {
|
||||
const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
restrictDirPermissions(dir);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function validateProfileName(name: string): void {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function profilePath(name: string): string {
|
||||
validateProfileName(name);
|
||||
return path.join(getAuthDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readProfile(name: string): AuthProfile | null {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return null;
|
||||
|
||||
const raw = readFileSync(p, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (isEncryptedPayload(parsed)) {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Encryption key required to read encrypted auth profiles. ` +
|
||||
`Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
|
||||
);
|
||||
}
|
||||
const decrypted = decryptData(parsed as EncryptedPayload, key);
|
||||
return JSON.parse(decrypted) as AuthProfile;
|
||||
}
|
||||
|
||||
return parsed as AuthProfile;
|
||||
}
|
||||
|
||||
function writeProfile(profile: AuthProfile): void {
|
||||
const key = ensureEncryptionKey();
|
||||
const serialized = JSON.stringify(profile, null, 2);
|
||||
const encrypted = encryptData(serialized, key);
|
||||
const filePath = profilePath(profile.name);
|
||||
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
export function saveAuthProfile(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}): AuthProfileMeta & { updated: boolean } {
|
||||
const existing = readProfile(opts.name);
|
||||
|
||||
const profile: AuthProfile = {
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
usernameSelector: opts.usernameSelector,
|
||||
passwordSelector: opts.passwordSelector,
|
||||
submitSelector: opts.submitSelector,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
lastLoginAt: existing?.lastLoginAt,
|
||||
};
|
||||
|
||||
writeProfile(profile);
|
||||
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
updated: existing !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthProfile(name: string): AuthProfile | null {
|
||||
return readProfile(name);
|
||||
}
|
||||
|
||||
export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
|
||||
const profile = readProfile(name);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAuthProfiles(): AuthProfileMeta[] {
|
||||
const dir = getAuthDir();
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
const profiles: AuthProfileMeta[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.replace(/\.json$/, '');
|
||||
try {
|
||||
const meta = getAuthProfileMeta(name);
|
||||
if (meta) profiles.push(meta);
|
||||
} catch {
|
||||
profiles.push({
|
||||
name,
|
||||
url: '(encrypted)',
|
||||
username: '(encrypted)',
|
||||
createdAt: '(unknown)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export function deleteAuthProfile(name: string): boolean {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return false;
|
||||
unlinkSync(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateLastLogin(name: string): void {
|
||||
const profile = readProfile(name);
|
||||
if (profile) {
|
||||
profile.lastLoginAt = new Date().toISOString();
|
||||
writeProfile(profile);
|
||||
}
|
||||
}
|
||||
+81
-2
@@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
isEncryptedPayload,
|
||||
@@ -117,6 +118,7 @@ export class BrowserManager {
|
||||
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -246,6 +248,61 @@ export class BrowserManager {
|
||||
return parseRef(selector) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the domain filter on a context if an allowlist is configured.
|
||||
* Should be called before any pages navigate on the context.
|
||||
*/
|
||||
private async ensureDomainFilter(context: BrowserContext): Promise<void> {
|
||||
if (this.allowedDomains.length > 0) {
|
||||
await installDomainFilter(context, this.allowedDomains);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After installing the domain filter, verify existing pages are on allowed
|
||||
* domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
|
||||
* already navigated to disallowed domains. Navigate them to about:blank.
|
||||
*/
|
||||
private async sanitizeExistingPages(pages: Page[]): Promise<void> {
|
||||
if (this.allowedDomains.length === 0) return;
|
||||
for (const page of pages) {
|
||||
const url = page.url();
|
||||
if (!url || url === 'about:blank') continue;
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||
await page.goto('about:blank');
|
||||
}
|
||||
} catch {
|
||||
await page.goto('about:blank').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is allowed by the domain allowlist.
|
||||
* Throws if the URL's domain is blocked. No-op if no allowlist is set.
|
||||
* Blocks non-http(s) schemes and unparseable URLs by default.
|
||||
*/
|
||||
checkDomainAllowed(url: string): void {
|
||||
if (this.allowedDomains.length === 0) return;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||
throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator - supports both refs and regular selectors
|
||||
*/
|
||||
@@ -286,6 +343,7 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -899,6 +957,8 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(10000);
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
@@ -1039,10 +1099,12 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
this.setupContextTracking(context);
|
||||
} catch (error) {
|
||||
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
|
||||
console.error('Failed to close Kernel session during cleanup:', sessionError);
|
||||
@@ -1112,10 +1174,12 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
this.setupContextTracking(context);
|
||||
} catch (error) {
|
||||
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
||||
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
||||
@@ -1178,6 +1242,15 @@ export class BrowserManager {
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
|
||||
if (options.allowedDomains && options.allowedDomains.length > 0) {
|
||||
this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase());
|
||||
} else {
|
||||
const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
|
||||
if (envDomains) {
|
||||
this.allowedDomains = parseDomainList(envDomains);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||
const warning =
|
||||
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
||||
@@ -1405,8 +1478,10 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
await this.sanitizeExistingPages([page]);
|
||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||
if (!this.pages.includes(page)) {
|
||||
this.pages.push(page);
|
||||
@@ -1480,8 +1555,11 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(10000);
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
}
|
||||
|
||||
await this.sanitizeExistingPages(allPages);
|
||||
|
||||
for (const page of allPages) {
|
||||
this.pages.push(page);
|
||||
this.setupPageTracking(page);
|
||||
@@ -1737,6 +1815,7 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
|
||||
const page = await context.newPage();
|
||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestConfirmation, getAndRemovePending } from './confirmation.js';
|
||||
|
||||
describe('confirmation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('requestConfirmation', () => {
|
||||
it('should return a confirmation ID', () => {
|
||||
const result = requestConfirmation('evaluate', 'eval', 'Evaluate JS', { script: 'test' });
|
||||
expect(result.confirmationId).toBeTruthy();
|
||||
expect(result.confirmationId).toMatch(/^c_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const r1 = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
const r2 = requestConfirmation('click', 'click', 'desc', {});
|
||||
expect(r1.confirmationId).not.toBe(r2.confirmationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndRemovePending', () => {
|
||||
it('should retrieve and remove a pending confirmation', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {
|
||||
action: 'evaluate',
|
||||
script: 'test',
|
||||
});
|
||||
|
||||
const entry = getAndRemovePending(confirmationId);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.action).toBe('evaluate');
|
||||
expect(entry!.command).toEqual({ action: 'evaluate', script: 'test' });
|
||||
});
|
||||
|
||||
it('should return null on second retrieval (already removed)', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
getAndRemovePending(confirmationId);
|
||||
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', () => {
|
||||
expect(getAndRemovePending('c_nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-deny after 60 seconds', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(getAndRemovePending(confirmationId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should still be retrievable before 60 second timeout', () => {
|
||||
const { confirmationId } = requestConfirmation('evaluate', 'eval', 'desc', {});
|
||||
|
||||
vi.advanceTimersByTime(59_999);
|
||||
|
||||
const entry = getAndRemovePending(confirmationId);
|
||||
expect(entry).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
interface PendingConfirmation {
|
||||
id: string;
|
||||
action: string;
|
||||
category: string;
|
||||
description: string;
|
||||
command: Record<string, unknown>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const AUTO_DENY_TIMEOUT_MS = 60_000;
|
||||
|
||||
const pending = new Map<string, PendingConfirmation>();
|
||||
|
||||
function generateId(): string {
|
||||
return `c_${randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
export function requestConfirmation(
|
||||
action: string,
|
||||
category: string,
|
||||
description: string,
|
||||
command: Record<string, unknown>
|
||||
): { confirmationId: string } {
|
||||
const id = generateId();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
}, AUTO_DENY_TIMEOUT_MS);
|
||||
|
||||
pending.set(id, {
|
||||
id,
|
||||
action,
|
||||
category,
|
||||
description,
|
||||
command,
|
||||
timer,
|
||||
});
|
||||
|
||||
return { confirmationId: id };
|
||||
}
|
||||
|
||||
export function getAndRemovePending(
|
||||
id: string
|
||||
): { command: Record<string, unknown>; action: string } | null {
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return null;
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
pending.delete(id);
|
||||
return { command: entry.command, action: entry.action };
|
||||
}
|
||||
+10
-3
@@ -5,7 +5,7 @@ import * as os from 'os';
|
||||
import { BrowserManager } from './browser.js';
|
||||
import { IOSManager } from './ios-manager.js';
|
||||
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand } from './actions.js';
|
||||
import { executeCommand, initActionPolicy } from './actions.js';
|
||||
import { executeIOSCommand } from './ios-actions.js';
|
||||
import { StreamServer } from './stream-server.js';
|
||||
import {
|
||||
@@ -333,6 +333,9 @@ export async function startDaemon(options?: {
|
||||
// Clean up expired state files on startup
|
||||
runCleanupExpiredStates();
|
||||
|
||||
// Initialize action policy enforcement
|
||||
initActionPolicy();
|
||||
|
||||
// Determine provider from options or environment
|
||||
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||
const isIOS = provider === 'ios';
|
||||
@@ -595,9 +598,13 @@ export async function startDaemon(options?: {
|
||||
processQueue().catch((err) => {
|
||||
// Socket write failures during queue processing are non-fatal;
|
||||
// the client has likely disconnected.
|
||||
console.warn('[warn] processQueue error:', err?.message ?? err);
|
||||
// Only log err.message to avoid leaking sensitive fields (e.g. passwords) from command objects.
|
||||
console.warn('[warn] processQueue error:', err?.message ?? String(err));
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] processQueue error (full):', err);
|
||||
console.error(
|
||||
'[DEBUG] processQueue error stack:',
|
||||
err?.stack ?? err?.message ?? String(err)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isDomainAllowed, parseDomainList, buildWebSocketFilterScript } from './domain-filter.js';
|
||||
|
||||
describe('domain-filter', () => {
|
||||
describe('isDomainAllowed', () => {
|
||||
it('should match exact domains', () => {
|
||||
expect(isDomainAllowed('example.com', ['example.com'])).toBe(true);
|
||||
expect(isDomainAllowed('github.com', ['github.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-matching domains', () => {
|
||||
expect(isDomainAllowed('evil.com', ['example.com'])).toBe(false);
|
||||
expect(isDomainAllowed('notexample.com', ['example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should match wildcard patterns', () => {
|
||||
expect(isDomainAllowed('sub.example.com', ['*.example.com'])).toBe(true);
|
||||
expect(isDomainAllowed('deep.sub.example.com', ['*.example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should match bare domain against wildcard pattern', () => {
|
||||
expect(isDomainAllowed('example.com', ['*.example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-matching wildcard patterns', () => {
|
||||
expect(isDomainAllowed('example.org', ['*.example.com'])).toBe(false);
|
||||
expect(isDomainAllowed('evil.com', ['*.example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty allowlist', () => {
|
||||
expect(isDomainAllowed('example.com', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('should match against multiple patterns', () => {
|
||||
const patterns = ['example.com', '*.github.com', 'vercel.app'];
|
||||
expect(isDomainAllowed('example.com', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('api.github.com', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('vercel.app', patterns)).toBe(true);
|
||||
expect(isDomainAllowed('evil.com', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not partially match domain suffixes without wildcard', () => {
|
||||
expect(isDomainAllowed('sub.example.com', ['example.com'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDomainList', () => {
|
||||
it('should split comma-separated domains', () => {
|
||||
expect(parseDomainList('a.com,b.com')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(parseDomainList(' a.com , b.com ')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should lowercase domains', () => {
|
||||
expect(parseDomainList('Example.COM,GitHub.Com')).toEqual(['example.com', 'github.com']);
|
||||
});
|
||||
|
||||
it('should filter empty entries', () => {
|
||||
expect(parseDomainList('a.com,,b.com,')).toEqual(['a.com', 'b.com']);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(parseDomainList('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve wildcard prefixes', () => {
|
||||
expect(parseDomainList('*.example.com')).toEqual(['*.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebSocketFilterScript', () => {
|
||||
it('should produce a valid JavaScript IIFE', () => {
|
||||
const script = buildWebSocketFilterScript(['example.com', '*.github.com']);
|
||||
expect(script).toContain('_allowedDomains');
|
||||
expect(script).toContain('"example.com"');
|
||||
expect(script).toContain('"*.github.com"');
|
||||
});
|
||||
|
||||
it('should embed the domain list as JSON', () => {
|
||||
const script = buildWebSocketFilterScript(['a.com']);
|
||||
expect(script).toContain('["a.com"]');
|
||||
});
|
||||
|
||||
it('should include WebSocket, EventSource, and sendBeacon patches', () => {
|
||||
const script = buildWebSocketFilterScript(['a.com']);
|
||||
expect(script).toContain('WebSocket');
|
||||
expect(script).toContain('EventSource');
|
||||
expect(script).toContain('SecurityError');
|
||||
expect(script).toContain('sendBeacon');
|
||||
});
|
||||
|
||||
it('should handle empty allowlist', () => {
|
||||
const script = buildWebSocketFilterScript([]);
|
||||
expect(script).toContain('[]');
|
||||
});
|
||||
|
||||
it('should include domain matching logic consistent with isDomainAllowed', () => {
|
||||
const script = buildWebSocketFilterScript(['*.example.com']);
|
||||
expect(script).toContain('_isDomainAllowed');
|
||||
expect(script).toContain('slice(1)');
|
||||
expect(script).toContain('slice(2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { BrowserContext, Route } from 'playwright-core';
|
||||
|
||||
/**
|
||||
* Checks whether a hostname matches one of the allowed domain patterns.
|
||||
* Patterns support exact match ("example.com") and wildcard prefix ("*.example.com").
|
||||
*/
|
||||
export function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean {
|
||||
for (const pattern of allowedDomains) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(1); // ".example.com"
|
||||
if (hostname === pattern.slice(2) || hostname.endsWith(suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if (hostname === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseDomainList(raw: string): string[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the init script source that monkey-patches WebSocket, EventSource,
|
||||
* and navigator.sendBeacon to block connections to non-allowed domains.
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function buildWebSocketFilterScript(allowedDomains: string[]): string {
|
||||
const serialized = JSON.stringify(allowedDomains);
|
||||
return `(function() {
|
||||
var _allowedDomains = ${serialized};
|
||||
function _isDomainAllowed(hostname) {
|
||||
hostname = hostname.toLowerCase();
|
||||
for (var i = 0; i < _allowedDomains.length; i++) {
|
||||
var pattern = _allowedDomains[i];
|
||||
if (pattern.indexOf('*.') === 0) {
|
||||
var suffix = pattern.slice(1);
|
||||
if (hostname === pattern.slice(2) || hostname.slice(-suffix.length) === suffix) {
|
||||
return true;
|
||||
}
|
||||
} else if (hostname === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _checkUrl(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return _isDomainAllowed(parsed.hostname);
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof WebSocket !== 'undefined') {
|
||||
var _OrigWS = WebSocket;
|
||||
WebSocket = function(url, protocols) {
|
||||
if (!_checkUrl(url)) {
|
||||
throw new DOMException(
|
||||
'WebSocket connection to ' + url + ' blocked by domain allowlist',
|
||||
'SecurityError'
|
||||
);
|
||||
}
|
||||
if (protocols !== undefined) {
|
||||
return new _OrigWS(url, protocols);
|
||||
}
|
||||
return new _OrigWS(url);
|
||||
};
|
||||
WebSocket.prototype = _OrigWS.prototype;
|
||||
WebSocket.CONNECTING = _OrigWS.CONNECTING;
|
||||
WebSocket.OPEN = _OrigWS.OPEN;
|
||||
WebSocket.CLOSING = _OrigWS.CLOSING;
|
||||
WebSocket.CLOSED = _OrigWS.CLOSED;
|
||||
}
|
||||
if (typeof EventSource !== 'undefined') {
|
||||
var _OrigES = EventSource;
|
||||
EventSource = function(url, opts) {
|
||||
if (!_checkUrl(url)) {
|
||||
throw new DOMException(
|
||||
'EventSource connection to ' + url + ' blocked by domain allowlist',
|
||||
'SecurityError'
|
||||
);
|
||||
}
|
||||
return new _OrigES(url, opts);
|
||||
};
|
||||
EventSource.prototype = _OrigES.prototype;
|
||||
EventSource.CONNECTING = _OrigES.CONNECTING;
|
||||
EventSource.OPEN = _OrigES.OPEN;
|
||||
EventSource.CLOSED = _OrigES.CLOSED;
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
var _origSendBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
if (!_checkUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
return _origSendBeacon(url, data);
|
||||
};
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a context-level route that enforces the domain allowlist.
|
||||
* Both document navigations and sub-resource requests (scripts, images, fetch, etc.)
|
||||
* to non-allowed domains are blocked, preventing data exfiltration.
|
||||
* Non-http(s) schemes (data:, blob:, etc.) are allowed for sub-resources
|
||||
* but blocked for document navigations.
|
||||
*
|
||||
* Also installs an init script that patches WebSocket, EventSource, and
|
||||
* navigator.sendBeacon to block connections to non-allowed domains. This is
|
||||
* a best-effort defense: if eval is permitted by action policy, page scripts
|
||||
* could theoretically restore the originals. Denying the eval action
|
||||
* category closes that loophole.
|
||||
*/
|
||||
export async function installDomainFilter(
|
||||
context: BrowserContext,
|
||||
allowedDomains: string[]
|
||||
): Promise<void> {
|
||||
if (allowedDomains.length === 0) return;
|
||||
|
||||
await context.addInitScript(buildWebSocketFilterScript(allowedDomains));
|
||||
|
||||
await context.route('**/*', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const urlStr = request.url();
|
||||
|
||||
if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) {
|
||||
if (request.resourceType() === 'document') {
|
||||
await route.abort('blockedbyclient');
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(urlStr).hostname.toLowerCase();
|
||||
} catch {
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDomainAllowed(hostname, allowedDomains)) {
|
||||
await route.continue();
|
||||
} else {
|
||||
await route.abort('blockedbyclient');
|
||||
}
|
||||
});
|
||||
}
|
||||
+104
-12
@@ -3,6 +3,10 @@
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
@@ -10,6 +14,7 @@ import * as crypto from 'crypto';
|
||||
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
||||
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
|
||||
export const IV_LENGTH = 12; // 96 bits for GCM
|
||||
const KEY_FILE_NAME = '.encryption-key';
|
||||
|
||||
/**
|
||||
* Encrypted payload structure.
|
||||
@@ -22,27 +27,114 @@ export interface EncryptedPayload {
|
||||
data: string; // Base64 encoded ciphertext
|
||||
}
|
||||
|
||||
export function getKeyFilePath(): string {
|
||||
return join(os.homedir(), '.agent-browser', KEY_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption key from environment variable.
|
||||
* Restrict file permissions to the current user only.
|
||||
* On Unix, the caller should use `mode: 0o600` when writing. This function
|
||||
* handles Windows where Node's mode parameter is ignored.
|
||||
*/
|
||||
export function restrictFilePermissions(filePath: string): void {
|
||||
if (os.platform() !== 'win32') return;
|
||||
try {
|
||||
execSync(`icacls "${filePath}" /inheritance:r /grant:r "%USERNAME%:F"`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort; may fail in some environments (containers, restricted shells)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict directory permissions to the current user only.
|
||||
* On Unix, the caller should use `mode: 0o700` when creating. This function
|
||||
* handles Windows where Node's mode parameter is ignored.
|
||||
*/
|
||||
export function restrictDirPermissions(dirPath: string): void {
|
||||
if (os.platform() !== 'win32') return;
|
||||
try {
|
||||
execSync(`icacls "${dirPath}" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyHex(keyHex: string): Buffer | null {
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(keyHex.trim())) return null;
|
||||
return Buffer.from(keyHex.trim(), 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encryption key from environment variable or key file.
|
||||
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
|
||||
* Generate with: openssl rand -hex 32
|
||||
*
|
||||
* @returns Buffer containing the key, or null if not set/invalid
|
||||
* Checks (in order):
|
||||
* 1. AGENT_BROWSER_ENCRYPTION_KEY env var
|
||||
* 2. ~/.agent-browser/.encryption-key file
|
||||
*
|
||||
* @returns Buffer containing the key, or null if not available
|
||||
*/
|
||||
export function getEncryptionKey(): Buffer | null {
|
||||
const keyHex = process.env[ENCRYPTION_KEY_ENV];
|
||||
if (!keyHex) return null;
|
||||
|
||||
// Key should be 64 hex chars = 32 bytes = 256 bits
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) {
|
||||
console.warn(
|
||||
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
||||
`Generate one with: openssl rand -hex 32`
|
||||
);
|
||||
return null;
|
||||
if (keyHex) {
|
||||
const key = parseKeyHex(keyHex);
|
||||
if (!key) {
|
||||
console.warn(
|
||||
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
||||
`Generate one with: openssl rand -hex 32`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
const keyFilePath = getKeyFilePath();
|
||||
if (existsSync(keyFilePath)) {
|
||||
try {
|
||||
const fileHex = readFileSync(keyFilePath, 'utf-8');
|
||||
return parseKeyHex(fileHex);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an encryption key is available, auto-generating one if needed.
|
||||
* On first call without an existing key, generates a random 256-bit key
|
||||
* and writes it to ~/.agent-browser/.encryption-key (mode 0600).
|
||||
*/
|
||||
export function ensureEncryptionKey(): Buffer {
|
||||
const existing = getEncryptionKey();
|
||||
if (existing) return existing;
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
const keyHex = key.toString('hex');
|
||||
|
||||
const dir = join(os.homedir(), '.agent-browser');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
restrictDirPermissions(dir);
|
||||
}
|
||||
|
||||
const keyFilePath = getKeyFilePath();
|
||||
writeFileSync(keyFilePath, keyHex + '\n', { mode: 0o600 });
|
||||
restrictFilePermissions(keyFilePath);
|
||||
|
||||
console.error(
|
||||
`[agent-browser] Auto-generated encryption key at ${keyFilePath} -- back up this file or set ${ENCRYPTION_KEY_ENV}`
|
||||
);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,9 @@ const launchSchema = baseCommandSchema.extend({
|
||||
downloadPath: z.string().optional(),
|
||||
profile: z.string().optional(),
|
||||
storageState: z.string().optional(),
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
actionPolicy: z.string().optional(),
|
||||
confirmActions: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const navigateSchema = baseCommandSchema.extend({
|
||||
@@ -873,6 +876,53 @@ const windowNewSchema = baseCommandSchema.extend({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const authProfileName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: 'Profile name must contain only alphanumeric characters, hyphens, and underscores',
|
||||
});
|
||||
|
||||
const authSaveSchema = baseCommandSchema.extend({
|
||||
action: z.literal('auth_save'),
|
||||
name: authProfileName,
|
||||
url: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
usernameSelector: z.string().optional(),
|
||||
passwordSelector: z.string().optional(),
|
||||
submitSelector: z.string().optional(),
|
||||
});
|
||||
|
||||
const authLoginSchema = baseCommandSchema.extend({
|
||||
action: z.literal('auth_login'),
|
||||
name: authProfileName,
|
||||
});
|
||||
|
||||
const authListSchema = baseCommandSchema.extend({
|
||||
action: z.literal('auth_list'),
|
||||
});
|
||||
|
||||
const authDeleteSchema = baseCommandSchema.extend({
|
||||
action: z.literal('auth_delete'),
|
||||
name: authProfileName,
|
||||
});
|
||||
|
||||
const authShowSchema = baseCommandSchema.extend({
|
||||
action: z.literal('auth_show'),
|
||||
name: authProfileName,
|
||||
});
|
||||
|
||||
const confirmSchema = baseCommandSchema.extend({
|
||||
action: z.literal('confirm'),
|
||||
confirmationId: z.string().min(1),
|
||||
});
|
||||
|
||||
const denySchema = baseCommandSchema.extend({
|
||||
action: z.literal('deny'),
|
||||
confirmationId: z.string().min(1),
|
||||
});
|
||||
|
||||
// Union schema for all commands
|
||||
const commandSchema = z.discriminatedUnion('action', [
|
||||
launchSchema,
|
||||
@@ -1010,6 +1060,13 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
diffSnapshotSchema,
|
||||
diffScreenshotSchema,
|
||||
diffUrlSchema,
|
||||
confirmSchema,
|
||||
denySchema,
|
||||
authSaveSchema,
|
||||
authLoginSchema,
|
||||
authListSchema,
|
||||
authDeleteSchema,
|
||||
authShowSchema,
|
||||
]);
|
||||
|
||||
// Parse result type
|
||||
|
||||
+76
-1
@@ -33,6 +33,9 @@ export interface LaunchCommand extends BaseCommand {
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
@@ -1022,7 +1025,54 @@ export type Command =
|
||||
| DeviceListCommand
|
||||
| DiffSnapshotCommand
|
||||
| DiffScreenshotCommand
|
||||
| DiffUrlCommand;
|
||||
| DiffUrlCommand
|
||||
| AuthSaveCommand
|
||||
| AuthLoginCommand
|
||||
| AuthListCommand
|
||||
| AuthDeleteCommand
|
||||
| AuthShowCommand
|
||||
| ConfirmCommand
|
||||
| DenyCommand;
|
||||
|
||||
export interface AuthSaveCommand extends BaseCommand {
|
||||
action: 'auth_save';
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}
|
||||
|
||||
export interface AuthLoginCommand extends BaseCommand {
|
||||
action: 'auth_login';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthListCommand extends BaseCommand {
|
||||
action: 'auth_list';
|
||||
}
|
||||
|
||||
export interface AuthDeleteCommand extends BaseCommand {
|
||||
action: 'auth_delete';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthShowCommand extends BaseCommand {
|
||||
action: 'auth_show';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConfirmCommand extends BaseCommand {
|
||||
action: 'confirm';
|
||||
confirmationId: string;
|
||||
}
|
||||
|
||||
export interface DenyCommand extends BaseCommand {
|
||||
action: 'deny';
|
||||
confirmationId: string;
|
||||
}
|
||||
|
||||
// Diff commands
|
||||
export interface DiffSnapshotCommand extends BaseCommand {
|
||||
@@ -1091,14 +1141,39 @@ export interface ScreenshotData {
|
||||
|
||||
export interface SnapshotData {
|
||||
snapshot: string;
|
||||
refs?: Record<string, { role: string; name?: string }>;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface EvaluateData {
|
||||
result: unknown;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
html: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface TextData {
|
||||
text: string | null;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface AttributeData {
|
||||
attribute: string;
|
||||
value: string | null;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface ValueData {
|
||||
value: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleData {
|
||||
messages: Array<{ type: string; text: string }>;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface TabInfo {
|
||||
|
||||
Reference in New Issue
Block a user