Skip to content

Commit e6eaaec

Browse files
authored
Merge pull request #158 from SentienceAPI/permissions
solution for handle Chrome permission bubble
2 parents 565bd4b + 4ac0d6e commit e6eaaec

10 files changed

Lines changed: 255 additions & 6 deletions

File tree

src/agent-runtime.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,17 @@ export class AgentRuntime {
492492
const hasEval = typeof (this as any).evaluateJs === 'function';
493493
const hasKeyboard = Boolean((this.page as any)?.keyboard);
494494
const hasDownloads = this.downloads.length >= 0;
495+
let hasPermissions = false;
496+
try {
497+
const context =
498+
typeof (this.page as any)?.context === 'function' ? (this.page as any).context() : null;
499+
hasPermissions =
500+
Boolean(context) &&
501+
typeof context.clearPermissions === 'function' &&
502+
typeof context.grantPermissions === 'function';
503+
} catch {
504+
hasPermissions = false;
505+
}
495506
let hasFiles = false;
496507
if (this.toolRegistry) {
497508
hasFiles = Boolean(this.toolRegistry.get('read_file'));
@@ -502,6 +513,7 @@ export class AgentRuntime {
502513
downloads: hasDownloads,
503514
filesystem_tools: hasFiles,
504515
keyboard: hasKeyboard,
516+
permissions: hasPermissions,
505517
};
506518
}
507519

src/browser.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import { SnapshotOptions } from './snapshot';
1212
import { IBrowser } from './protocols/browser-protocol';
1313
import { snapshot as snapshotFunction } from './snapshot';
1414

15+
export type PermissionDefault = 'clear' | 'deny' | 'grant';
16+
17+
export type PermissionPolicy = {
18+
default?: PermissionDefault;
19+
autoGrant?: string[];
20+
geolocation?: { latitude: number; longitude: number; accuracy?: number };
21+
origin?: string;
22+
};
23+
1524
export function normalizeDomain(domain: string): string {
1625
const raw = domain.trim();
1726
let host = raw;
@@ -88,6 +97,7 @@ export class SentienceBrowser implements IBrowser {
8897
private _allowedDomains?: string[];
8998
private _prohibitedDomains?: string[];
9099
private _keepAlive: boolean;
100+
private _permissionPolicy?: PermissionPolicy;
91101

92102
/**
93103
* Create a new SentienceBrowser instance
@@ -121,7 +131,8 @@ export class SentienceBrowser implements IBrowser {
121131
deviceScaleFactor?: number,
122132
allowedDomains?: string[],
123133
prohibitedDomains?: string[],
124-
keepAlive: boolean = false
134+
keepAlive: boolean = false,
135+
permissionPolicy?: PermissionPolicy
125136
) {
126137
this._apiKey = apiKey;
127138

@@ -162,6 +173,24 @@ export class SentienceBrowser implements IBrowser {
162173
this._allowedDomains = allowedDomains;
163174
this._prohibitedDomains = prohibitedDomains;
164175
this._keepAlive = keepAlive;
176+
this._permissionPolicy = permissionPolicy;
177+
}
178+
179+
private async applyPermissionPolicy(
180+
context: BrowserContext,
181+
policy: PermissionPolicy
182+
): Promise<void> {
183+
const defaultPolicy = policy.default ?? 'clear';
184+
if (defaultPolicy === 'clear' || defaultPolicy === 'deny') {
185+
await context.clearPermissions();
186+
}
187+
if (policy.geolocation) {
188+
await context.setGeolocation(policy.geolocation);
189+
}
190+
if (policy.autoGrant && policy.autoGrant.length > 0) {
191+
const options = policy.origin ? { origin: policy.origin } : undefined;
192+
await context.grantPermissions(policy.autoGrant, options);
193+
}
165194
}
166195

167196
async start(): Promise<void> {
@@ -276,6 +305,10 @@ export class SentienceBrowser implements IBrowser {
276305

277306
this.context = await chromium.launchPersistentContext(this.userDataDir, launchOptions);
278307

308+
if (this._permissionPolicy) {
309+
await this.applyPermissionPolicy(this.context, this._permissionPolicy);
310+
}
311+
279312
this.page = this.context.pages()[0] || (await this.context.newPage());
280313

281314
// Inject storage state if provided (must be after context creation)

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Sentience TypeScript SDK - AI Agent Browser Automation
33
*/
44

5-
export { SentienceBrowser } from './browser';
5+
export { SentienceBrowser, PermissionPolicy } from './browser';
66
export { snapshot, SnapshotOptions } from './snapshot';
77
export { query, find, parseSelector } from './query';
88
export {

src/tools/defaults.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod';
22
import type { AgentRuntime } from '../agent-runtime';
33
import type { ActionResult, Snapshot, EvaluateJsResult } from '../types';
4-
import { ToolContext } from './context';
4+
import { ToolContext, UnsupportedCapabilityError } from './context';
55
import { defineTool, ToolRegistry } from './registry';
66

77
const snapshotSchema = z
@@ -83,6 +83,19 @@ const evaluateJsInput = z.object({
8383
truncate: z.boolean().default(true),
8484
});
8585

86+
const grantPermissionsInput = z.object({
87+
permissions: z.array(z.string()).min(1),
88+
origin: z.string().optional(),
89+
});
90+
91+
const clearPermissionsInput = z.object({});
92+
93+
const setGeolocationInput = z.object({
94+
latitude: z.number(),
95+
longitude: z.number(),
96+
accuracy: z.number().optional(),
97+
});
98+
8699
function getRuntime(ctx: ToolContext | null, runtime?: ToolContext | AgentRuntime): AgentRuntime {
87100
if (ctx) return ctx.runtime;
88101
if (runtime instanceof ToolContext) return runtime.runtime;
@@ -342,5 +355,87 @@ export function registerDefaultTools(
342355
})
343356
);
344357

358+
registry.register(
359+
defineTool<z.infer<typeof grantPermissionsInput>, ActionResult, ToolContext | null>({
360+
name: 'grant_permissions',
361+
description: 'Grant browser permissions for the current context.',
362+
input: grantPermissionsInput,
363+
output: actionResultSchema,
364+
handler: async (ctx, params): Promise<ActionResult> => {
365+
const runtimeRef = getRuntime(ctx, runtime);
366+
if (ctx) {
367+
ctx.require('permissions');
368+
} else if (!runtimeRef.can('permissions')) {
369+
throw new UnsupportedCapabilityError('permissions');
370+
}
371+
const context =
372+
typeof (runtimeRef.page as any)?.context === 'function'
373+
? (runtimeRef.page as any).context()
374+
: null;
375+
if (!context) {
376+
throw new Error('Permission context unavailable');
377+
}
378+
await context.grantPermissions(params.permissions, params.origin);
379+
return { success: true, duration_ms: 0, outcome: 'dom_updated' };
380+
},
381+
})
382+
);
383+
384+
registry.register(
385+
defineTool<z.infer<typeof clearPermissionsInput>, ActionResult, ToolContext | null>({
386+
name: 'clear_permissions',
387+
description: 'Clear browser permissions for the current context.',
388+
input: clearPermissionsInput,
389+
output: actionResultSchema,
390+
handler: async (ctx): Promise<ActionResult> => {
391+
const runtimeRef = getRuntime(ctx, runtime);
392+
if (ctx) {
393+
ctx.require('permissions');
394+
} else if (!runtimeRef.can('permissions')) {
395+
throw new UnsupportedCapabilityError('permissions');
396+
}
397+
const context =
398+
typeof (runtimeRef.page as any)?.context === 'function'
399+
? (runtimeRef.page as any).context()
400+
: null;
401+
if (!context) {
402+
throw new Error('Permission context unavailable');
403+
}
404+
await context.clearPermissions();
405+
return { success: true, duration_ms: 0, outcome: 'dom_updated' };
406+
},
407+
})
408+
);
409+
410+
registry.register(
411+
defineTool<z.infer<typeof setGeolocationInput>, ActionResult, ToolContext | null>({
412+
name: 'set_geolocation',
413+
description: 'Set geolocation for the current browser context.',
414+
input: setGeolocationInput,
415+
output: actionResultSchema,
416+
handler: async (ctx, params): Promise<ActionResult> => {
417+
const runtimeRef = getRuntime(ctx, runtime);
418+
if (ctx) {
419+
ctx.require('permissions');
420+
} else if (!runtimeRef.can('permissions')) {
421+
throw new UnsupportedCapabilityError('permissions');
422+
}
423+
const context =
424+
typeof (runtimeRef.page as any)?.context === 'function'
425+
? (runtimeRef.page as any).context()
426+
: null;
427+
if (!context) {
428+
throw new Error('Permission context unavailable');
429+
}
430+
await context.setGeolocation({
431+
latitude: params.latitude,
432+
longitude: params.longitude,
433+
accuracy: params.accuracy,
434+
});
435+
return { success: true, duration_ms: 0, outcome: 'dom_updated' };
436+
},
437+
})
438+
);
439+
345440
return registry;
346441
}

src/tools/registry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ export class ToolRegistry {
8484
name: string,
8585
payload: unknown,
8686
ctx: {
87-
runtime?: { tracer?: { emit: (...args: any[]) => void }; stepId?: string; step_id?: string };
87+
runtime?: {
88+
tracer?: { emit: (...args: any[]) => void };
89+
stepId?: string | null;
90+
step_id?: string | null;
91+
};
8892
} | null = null
8993
): Promise<TOutput> {
9094
const start = Date.now();

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export interface BackendCapabilities {
259259
downloads: boolean;
260260
filesystem_tools: boolean;
261261
keyboard: boolean;
262+
permissions: boolean;
262263
}
263264

264265
export interface EvaluateJsRequest {

tests/actions.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
uncheck,
2424
uploadFile,
2525
} from '../src';
26-
import { createTestBrowser, getPageOrThrow } from './test-utils';
26+
import { createTestBrowser, getPageOrThrow, patchExampleDotCom } from './test-utils';
2727
import * as fs from 'fs';
2828
import * as os from 'os';
2929
import * as path from 'path';
@@ -220,8 +220,8 @@ describe('Actions', () => {
220220
try {
221221
await browser.start();
222222
const page = getPageOrThrow(browser);
223+
patchExampleDotCom(page);
223224
await page.goto('https://example.com');
224-
await page.waitForLoadState('networkidle', { timeout: 10000 });
225225

226226
await expect(search(browser, 'sentience sdk', 'duckduckgo')).rejects.toThrow(
227227
'domain not allowed'

tests/browser.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { chromium, BrowserContext, Page } from 'playwright';
77
import * as fs from 'fs';
88
import * as os from 'os';
99
import * as path from 'path';
10+
import { patchExampleDotCom } from './test-utils';
1011

1112
describe('Browser Proxy Support', () => {
1213
describe('Proxy Parsing', () => {
@@ -228,6 +229,7 @@ describe('Browser Proxy Support', () => {
228229
if (!page) {
229230
throw new Error('Browser page is not available');
230231
}
232+
patchExampleDotCom(page);
231233
await page.goto('https://example.com', { waitUntil: 'domcontentloaded', timeout: 20000 });
232234

233235
const viewportSize = await page.evaluate(() => ({
@@ -293,6 +295,7 @@ describe('Browser Proxy Support', () => {
293295
expect(sentienceBrowser.getContext()).toBe(context);
294296

295297
// Test that we can use it
298+
patchExampleDotCom(page);
296299
await page.goto('https://example.com');
297300
await page.waitForLoadState('networkidle', { timeout: 10000 });
298301

tests/test-utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export async function createTestBrowser(headless?: boolean): Promise<SentienceBr
1313
const browser = new SentienceBrowser(undefined, undefined, headless);
1414
try {
1515
await browser.start();
16+
const page = browser.getPage();
17+
if (page) {
18+
patchExampleDotCom(page);
19+
}
1620
return browser;
1721
} catch (e: any) {
1822
// Clean up browser on failure to prevent resource leaks
@@ -44,3 +48,28 @@ export function getPageOrThrow(browser: SentienceBrowser): Page {
4448
}
4549
return page;
4650
}
51+
52+
const DEFAULT_TEST_HTML = `<!doctype html>
53+
<html>
54+
<head><meta charset="utf-8" /></head>
55+
<body>
56+
<a id="link" href="#ok">Example Link</a>
57+
<input id="text" type="text" value="hello" />
58+
<button id="btn" type="button">Click me</button>
59+
<div style="height: 2000px;"></div>
60+
</body>
61+
</html>`;
62+
63+
export async function setTestPageContent(page: Page, html?: string): Promise<void> {
64+
await page.setContent(html ?? DEFAULT_TEST_HTML, { waitUntil: 'domcontentloaded' });
65+
}
66+
67+
export function patchExampleDotCom(page: Page): void {
68+
void page.route(/https?:\/\/example\.com\/?.*/, async route => {
69+
await route.fulfill({
70+
status: 200,
71+
contentType: 'text/html',
72+
body: DEFAULT_TEST_HTML,
73+
});
74+
});
75+
}

0 commit comments

Comments
 (0)