diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index def6d45fd1096f..e099cb8921d07d 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -4,11 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import * as http from 'http'; +import { AddressInfo } from 'net'; import * as path from 'path'; import 'mocha'; import * as vscode from 'vscode'; import { assertNoRpc, closeAllEditors } from '../utils'; +const allowedPageMarker = 'ALLOWED_BROWSER_PAGE_MARKER'; +const deniedFrameMarker = 'DENIED_BROWSER_FRAME_MARKER'; +const complexPageMarker = 'COMPLEX_PAGE_READY_MARKER'; + /** * Extracts all text content from a LanguageModelToolResult. */ @@ -22,8 +28,97 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { (vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('chat - browser tools', () => { let clearNotificationsInterval: ReturnType | undefined; + let allowedServer: http.Server; + let allowedPort: number; + let deniedServer: http.Server; + let deniedPort: number; + let deniedRequestCount: number; + + setup(async function () { + this.timeout(15000); + + deniedRequestCount = 0; + deniedServer = http.createServer((_request, response) => { + deniedRequestCount++; + response.setHeader('Content-Type', 'text/html'); + response.end(`${deniedFrameMarker}`); + }); + deniedPort = await listen(deniedServer, '127.0.0.1'); + + allowedServer = http.createServer((request, response) => { + switch (request.url) { + case '/hidden-iframe': + response.setHeader('Content-Type', 'text/html'); + response.end(`${allowedPageMarker}`); + break; + case '/redirect-to-denied': + response.writeHead(302, { Location: `http://127.0.0.1:${deniedPort}/redirected-private` }); + response.end(); + break; + case '/complex': + response.setHeader('Content-Type', 'text/html'); + response.end(` + + + +
loading
+ + + + + `); + break; + case '/style.css': + response.setHeader('Content-Type', 'text/css'); + response.end('body { --complex-page-style: loaded; }'); + break; + case '/complex.js': + response.setHeader('Content-Type', 'text/javascript'); + response.end(` + const worker = new Worker('/worker.js'); + const workerResult = new Promise(resolve => worker.onmessage = event => resolve(event.data)); + const imageResult = new Promise(resolve => { + const image = document.getElementById('test-image'); + if (image.complete) { + resolve('image-loaded'); + } else { + image.onload = () => resolve('image-loaded'); + } + }); + Promise.all([ + fetch('/data').then(response => response.text()), + workerResult, + imageResult, + ]).then(([fetchResult, workerMarker, imageMarker]) => { + const styleMarker = getComputedStyle(document.body).getPropertyValue('--complex-page-style').trim(); + document.getElementById('status').textContent = + '${complexPageMarker} ' + fetchResult + ' ' + workerMarker + ' ' + imageMarker + ' style-' + styleMarker; + }); + `); + break; + case '/worker.js': + response.setHeader('Content-Type', 'text/javascript'); + response.end(`postMessage('worker-loaded');`); + break; + case '/data': + response.setHeader('Content-Type', 'text/plain'); + response.end('fetch-loaded'); + break; + case '/image.png': + response.setHeader('Content-Type', 'image/png'); + response.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')); + break; + case '/allowed-frame': + response.setHeader('Content-Type', 'text/html'); + response.end('allowed-frame-loaded'); + break; + default: + response.writeHead(404); + response.end(); + } + }); + allowedPort = await listen(allowedServer, 'localhost'); - setup(async () => { // Periodically clear notifications to prevent them from interrupting the browser. clearNotificationsInterval = setInterval(() => { vscode.commands.executeCommand('notifications.clearAll'); @@ -54,13 +149,47 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const chatToolsConfig = vscode.workspace.getConfiguration('chat.tools.global'); await chatToolsConfig.update('autoApprove', undefined, vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand('setContext', 'vscode.chat.tools.global.autoApprove.testMode', undefined); + + await setNetworkPolicy(undefined); + await closeServer(allowedServer); + await closeServer(deniedServer); }); - async function invokeTool(toolName: string, input: Record): Promise { - const result = await vscode.lm.invokeTool(toolName, { + function listen(server: http.Server, host: string): Promise { + return new Promise((resolve, reject) => { + server.listen(0, host, () => resolve((server.address() as AddressInfo).port)); + server.on('error', reject); + }); + } + + function closeServer(server: http.Server): Promise { + server.closeAllConnections(); + return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + + async function setNetworkPolicy(enabled: true | undefined): Promise { + const configuration = vscode.workspace.getConfiguration(); + if (enabled) { + await configuration.update('chat.agent.allowedNetworkDomains', ['http://localhost'], vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.deniedNetworkDomains', ['http://127.0.0.1'], vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.networkFilter', true, vscode.ConfigurationTarget.Global); + } else { + await configuration.update('chat.agent.networkFilter', undefined, vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + await configuration.update('chat.agent.deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + async function invokeToolResult(toolName: string, input: Record): Promise { + return vscode.lm.invokeTool(toolName, { input, toolInvocationToken: undefined, }); + } + + async function invokeTool(toolName: string, input: Record): Promise { + const result = await invokeToolResult(toolName, input); return extractTextContent(result); } @@ -159,4 +288,119 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const readOutput = await invokeTool('read_page', { pageId }); assert.ok(readOutput.includes('test message'), `Expected page to contain worker response "test message", got: ${readOutput}`); }); + + test('browser tools network policy blocks denied hidden iframe content from read_page', async function () { + this.timeout(60000); + await setNetworkPolicy(true); + + const openOutput = await invokeTool('open_browser_page', { + url: `http://localhost:${allowedPort}/hidden-iframe`, + forceNew: true, + }); + const pageId = openOutput.match(/Page ID:\s*(\S+)/)?.[1]; + assert.ok(pageId, `Could not extract Page ID from: ${openOutput}`); + + const readOutput = await invokeTool('read_page', { pageId }); + + assert.deepStrictEqual({ + deniedRequestCount, + openContainsAllowedMarker: openOutput.includes(allowedPageMarker), + readContainsAllowedMarker: readOutput.includes(allowedPageMarker), + openContainsDeniedMarker: openOutput.includes(deniedFrameMarker), + readContainsDeniedMarker: readOutput.includes(deniedFrameMarker), + readContainsDeniedUrl: readOutput.includes('127.0.0.1'), + }, { + deniedRequestCount: 0, + openContainsAllowedMarker: true, + readContainsAllowedMarker: true, + openContainsDeniedMarker: false, + readContainsDeniedMarker: false, + readContainsDeniedUrl: false, + }); + }); + + test('browser tools network policy blocks screenshot_page after redirect to denied host', async function () { + this.timeout(60000); + await setNetworkPolicy(true); + + const openOutput = await invokeTool('open_browser_page', { + url: `http://localhost:${allowedPort}/redirect-to-denied`, + forceNew: true, + }); + const pageId = openOutput.match(/Page ID:\s*(\S+)/)?.[1]; + assert.ok(pageId, `Could not extract Page ID from: ${openOutput}`); + + const readOutput = await invokeTool('read_page', { pageId }); + const screenshotResult = await invokeToolResult('screenshot_page', { pageId }); + const screenshotText = extractTextContent(screenshotResult); + const dataPartCount = screenshotResult.content.filter(part => part instanceof vscode.LanguageModelDataPart).length; + + assert.deepStrictEqual({ + readWasBlocked: readOutput.includes('blocked by network domain policy'), + screenshotWasBlocked: screenshotText.includes('blocked by network domain policy'), + dataPartCount, + }, { + readWasBlocked: true, + screenshotWasBlocked: true, + dataPartCount: 0, + }); + }); + + test('browser tools network policy blocks screenshot_page when a denied frame is present', async function () { + this.timeout(60000); + await setNetworkPolicy(true); + + const openOutput = await invokeTool('open_browser_page', { + url: `http://localhost:${allowedPort}/hidden-iframe`, + forceNew: true, + }); + const pageId = openOutput.match(/Page ID:\s*(\S+)/)?.[1]; + assert.ok(pageId, `Could not extract Page ID from: ${openOutput}`); + + const screenshotResult = await invokeToolResult('screenshot_page', { pageId }); + const screenshotText = extractTextContent(screenshotResult); + const dataPartCount = screenshotResult.content.filter(part => part instanceof vscode.LanguageModelDataPart).length; + + assert.deepStrictEqual({ + screenshotWasBlocked: screenshotText.includes('blocked by network domain policy'), + dataPartCount, + }, { + screenshotWasBlocked: true, + dataPartCount: 0, + }); + }); + + test('browser tools network policy preserves complex allowed page loading', async function () { + this.timeout(60000); + await setNetworkPolicy(true); + + const openOutput = await invokeTool('open_browser_page', { + url: `http://localhost:${allowedPort}/complex`, + forceNew: true, + }); + const pageId = openOutput.match(/Page ID:\s*(\S+)/)?.[1]; + assert.ok(pageId, `Could not extract Page ID from: ${openOutput}`); + + await invokeTool('run_playwright_code', { + pageId, + code: `await page.waitForSelector('#status:text-is("${complexPageMarker} fetch-loaded worker-loaded image-loaded style-loaded")'); return "ready";`, + }); + const readOutput = await invokeTool('read_page', { pageId }); + + assert.deepStrictEqual({ + complexPageReady: readOutput.includes(complexPageMarker), + fetchLoaded: readOutput.includes('fetch-loaded'), + workerLoaded: readOutput.includes('worker-loaded'), + imageLoaded: readOutput.includes('image-loaded'), + styleLoaded: readOutput.includes('style-loaded'), + allowedFrameLoaded: readOutput.includes('allowed-frame-loaded'), + }, { + complexPageReady: true, + fetchLoaded: true, + workerLoaded: true, + imageLoaded: true, + styleLoaded: true, + allowedFrameLoaded: true, + }); + }); }); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 88f8969d37c231..077daf4ea90439 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -589,6 +589,8 @@ export interface IBrowserViewService { */ getSelectedText(id: string): Promise; + getNetworkPolicyError(id: string): Promise; + /** * Clear all storage data for the global browser session */ diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 7748280b41d32d..5dcfd828f78437 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -18,6 +18,8 @@ import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRem import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { localize } from '../../../nls.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; +import { BrowserSessionNetworkFilter } from './browserSessionNetworkFilter.js'; /** * Holds an Electron session along with its storage scope and unique browser @@ -64,7 +66,7 @@ export class BrowserSession { * they point to is garbage-collected. */ private static readonly _finalizer = new FinalizationRegistry((id) => { - BrowserSession._byId.delete(id); + this._byId.delete(id); }); /** @@ -207,6 +209,7 @@ export class BrowserSession { private readonly _history: BrowserSessionHistory; private readonly _remote: BrowserSessionRemote; private readonly _permissions: BrowserSessionPermissions; + private readonly _agentNetworkFilter: BrowserSessionNetworkFilter; /** * @deprecated Don't use this directly. Create sessions via the static factory methods. @@ -222,7 +225,9 @@ export class BrowserSession { readonly electronSession: Electron.Session, /** Resolved storage scope. */ readonly storageScope: BrowserViewStorageScope, + @IAgentNetworkFilterService agentNetworkFilterService: IAgentNetworkFilterService, ) { + this._agentNetworkFilter = new BrowserSessionNetworkFilter(agentNetworkFilterService); this._trust = new BrowserSessionTrust(this); this._history = new BrowserSessionHistory(this); this._remote = new BrowserSessionRemote(this); @@ -254,6 +259,14 @@ export class BrowserSession { return this._permissions; } + setAgentNetworkFiltering(webContentsId: number, enabled: boolean): void { + this._agentNetworkFilter.setFiltering(webContentsId, enabled); + } + + getAgentNetworkPolicyError(webContentsId: number): string | undefined { + return this._agentNetworkFilter.getPolicyError(webContentsId); + } + /** * Connect application storage to this session so that preferences * (trusted certificates, history, etc.) are persisted across restarts. @@ -271,6 +284,7 @@ export class BrowserSession { */ private configure(): void { this._permissions.configure(this.electronSession); + this.electronSession.webRequest.onBeforeRequest((details, callback) => this._agentNetworkFilter.onBeforeRequest(details, callback)); this.electronSession.registerPreloadScript({ type: 'frame', filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath diff --git a/src/vs/platform/browserView/electron-main/browserSessionNetworkFilter.ts b/src/vs/platform/browserView/electron-main/browserSessionNetworkFilter.ts new file mode 100644 index 00000000000000..136249d3d712dc --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserSessionNetworkFilter.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CallbackResponse, OnBeforeRequestListenerDetails } from 'electron'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; + +export class BrowserSessionNetworkFilter { + private readonly filteredWebContents = new Set(); + private readonly policyErrors = new Map(); + + constructor(private readonly agentNetworkFilterService: IAgentNetworkFilterService) { } + + setFiltering(webContentsId: number, enabled: boolean): void { + if (enabled) { + this.filteredWebContents.add(webContentsId); + } else { + this.filteredWebContents.delete(webContentsId); + this.policyErrors.delete(webContentsId); + } + } + + getPolicyError(webContentsId: number): string | undefined { + return this.policyErrors.get(webContentsId); + } + + onBeforeRequest(details: OnBeforeRequestListenerDetails, callback: (response: CallbackResponse) => void): void { + const webContentsId = details.webContentsId ?? details.webContents?.id; + if (details.resourceType === 'mainFrame') { + if (webContentsId !== undefined) { + this.policyErrors.delete(webContentsId); + } + callback({ cancel: false }); + return; + } + + if (webContentsId === undefined || !this.filteredWebContents.has(webContentsId)) { + callback({ cancel: false }); + return; + } + + let uri: URI; + try { + uri = URI.parse(details.url, true); + } catch { + this.policyErrors.set(webContentsId, localize('browserSession.invalidNetworkRequest', 'A browser request was blocked by network domain policy.')); + callback({ cancel: true }); + return; + } + + const allowed = this.agentNetworkFilterService.isUriAllowed(uri); + if (!allowed) { + this.policyErrors.set(webContentsId, this.agentNetworkFilterService.formatError(uri)); + } + callback({ cancel: !allowed }); + } +} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index aa403231eb45a6..7462eecac8b316 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -10,6 +10,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserViewPermissionRequestEvent } from '../common/browserView.js'; import { BrowserViewEmulator } from './browserViewEmulator.js'; import { BrowserViewInspector } from './browserViewInspector.js'; +import { BrowserViewAgentNetworkFilterSources } from './browserViewAgentNetworkFilter.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; @@ -58,6 +59,7 @@ export class BrowserView extends Disposable { private _ownerWindow: ICodeWindow; private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private readonly _agentNetworkFilterSources = new BrowserViewAgentNetworkFilterSources(); private _wantsVisibility = false; private _hasBeenLaidOut = false; @@ -148,6 +150,9 @@ export class BrowserView extends Disposable { // Passing an `undefined` webContents triggers an error in Electron. ...(options?.webContents ? { webContents: options.webContents } : {}) }); + if (owner.sessionId) { + this.setAgentNetworkFiltering(owner.sessionId, true); + } // Use a default size of 1024x768. // Important: The bounds here must be on-screen, otherwise some OSes (like macOS) may not actually start rendering. @@ -579,6 +584,14 @@ export class BrowserView extends Disposable { return this._view.webContents; } + setAgentNetworkFiltering(sourceId: string, enabled: boolean): void { + this.session.setAgentNetworkFiltering(this.webContents.id, this._agentNetworkFilterSources.set(sourceId, enabled)); + } + + getAgentNetworkPolicyError(): string | undefined { + return this.session.getAgentNetworkPolicyError(this.webContents.id); + } + /** * Get the current state of this browser view */ @@ -985,6 +998,8 @@ export class BrowserView extends Disposable { return; } this._isDisposed = true; + this._agentNetworkFilterSources.clear(); + this.session.setAgentNetworkFiltering(this.webContents.id, false); // Dispose debugger. This detaches debug sessions first. this.debugger.dispose(); diff --git a/src/vs/platform/browserView/electron-main/browserViewAgentNetworkFilter.ts b/src/vs/platform/browserView/electron-main/browserViewAgentNetworkFilter.ts new file mode 100644 index 00000000000000..7b925bdb30b807 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewAgentNetworkFilter.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IBrowserViewOwner } from '../common/browserView.js'; + +export class BrowserViewAgentNetworkFilterSources { + private readonly sources = new Set(); + + set(sourceId: string, enabled: boolean): boolean { + if (enabled) { + this.sources.add(sourceId); + } else { + this.sources.delete(sourceId); + } + return this.sources.size > 0; + } + + clear(): void { + this.sources.clear(); + } +} + +export interface IAgentNetworkFilterableBrowserView { + setAgentNetworkFiltering(sourceId: string, enabled: boolean): void; +} + +export function setBrowserViewGroupAgentNetworkFiltering(view: IAgentNetworkFilterableBrowserView, owner: IBrowserViewOwner, enabled: boolean): void { + if (owner.sessionId) { + view.setAgentNetworkFiltering(owner.sessionId, enabled); + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index b8a9a3d4d6d478..b686ddab738c14 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -16,6 +16,7 @@ import { BrowserSession } from './browserSession.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { BrowserViewCDPTarget } from './browserViewCDPTarget.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { setBrowserViewGroupAgentNetworkFiltering } from './browserViewAgentNetworkFilter.js'; /** * An isolated group of {@link BrowserView} instances exposed as CDP targets. @@ -83,6 +84,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (!view) { throw new Error(`Browser view ${viewId} not found`); } + setBrowserViewGroupAgentNetworkFiltering(view, this.owner, true); this.views.set(view.id, view); this.knownContextIds.add(view.session.id); this._onDidAddView.fire({ viewId: view.id }); @@ -134,6 +136,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I async removeView(viewId: string): Promise { const view = this.views.get(viewId); if (view && this.views.delete(viewId)) { + setBrowserViewGroupAgentNetworkFiltering(view, this.owner, false); // If no remaining views belong to the view's context, and we don't own the context, remove it from known contexts if (!this.ownedContextIds.has(view.session.id) && ![...this.views.values()].some(v => v.session.id === view.session.id)) { this.knownContextIds.delete(view.session.id); @@ -270,6 +273,9 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I // #endregion override dispose(): void { + for (const view of this.views.values()) { + setBrowserViewGroupAgentNetworkFiltering(view, this.owner, false); + } this._onDidDestroy.fire(); super.dispose(); } diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 3861f6ee8086f9..c46212c026c03f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -246,6 +246,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).getURL(); } + async getNetworkPolicyError(id: string): Promise { + return this._getBrowserView(id).getAgentNetworkPolicyError(); + } + async goBack(id: string): Promise { return this._getBrowserView(id).goBack(); } diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 851d51fdbd9e82..a132b79c8633b6 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -120,6 +120,9 @@ export class PlaywrightTab { } private _handleRequestFailed(request: playwright.Request) { + if (this._getBlockedURLErrorMessage(request.url())) { + return; + } const timing = request.timing(); this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` }); } @@ -135,12 +138,24 @@ export class PlaywrightTab { } /** - * Returns a blocked-by-policy error message if the current page URL is - * denied by the network filter, or `undefined` if the URL is allowed. + * Returns a blocked-by-policy error message if the current page or any frame + * is denied by the network filter, or `undefined` if all URLs are allowed. */ - private _getBlockedURLErrorMessage(): string | undefined { - const url = this.page.url(); - if (!url || url === 'about:blank') { + private _getBlockedURLErrorMessage(url?: string): string | undefined { + if (url !== undefined) { + return this._getBlockedURLMessage(url); + } + for (const frame of this.page.frames()) { + const error = this._getBlockedURLMessage(frame.url()); + if (error) { + return error; + } + } + return undefined; + } + + private _getBlockedURLMessage(url: string): string | undefined { + if (!url || url === 'about:blank' || url.startsWith('chrome-error://')) { return undefined; } let uri: URI | undefined; @@ -165,13 +180,14 @@ export class PlaywrightTab { throw new Error(`Cannot perform action while a dialog is open`); } - // Block agent actions when the current page URL is on the deny list. + // Block agent actions when the current page or any frame is on the deny list. const blockedError = this._getBlockedURLErrorMessage(); if (blockedError) { throw new Error(blockedError); } let actionDidComplete = false; + let postActionBlockedError: string | undefined; let result: T | void; const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); const actionCompleted = createCancelablePromise(async (token) => { @@ -186,6 +202,7 @@ export class PlaywrightTab { try { this.actionScope.activeCalls++; result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + postActionBlockedError = this._getBlockedURLErrorMessage(); actionDidComplete = true; } finally { this.page.off('filechooser', handleFileChooser); @@ -198,6 +215,9 @@ export class PlaywrightTab { // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. throw new DialogInterruptedError(); } + if (postActionBlockedError) { + throw new Error(postActionBlockedError); + } return result!; }); } @@ -205,7 +225,7 @@ export class PlaywrightTab { async getSummary(full = this._needsFullSnapshot): Promise { await this._initialized; - // When the current page URL is blocked by network policy, return only a + // When the current page or any frame is blocked by network policy, return only a // policy error — do not expose title, URL, console logs, or snapshot to // avoid prompt-injection via blocked content. const blockedError = this._getBlockedURLErrorMessage(); diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionNetworkFilter.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionNetworkFilter.test.ts new file mode 100644 index 00000000000000..13953dd3c9f15a --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserSessionNetworkFilter.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import type { CallbackResponse, OnBeforeRequestListenerDetails } from 'electron'; +import { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IAgentNetworkFilterService } from '../../../networkFilter/common/networkFilterService.js'; +import { BrowserSessionNetworkFilter } from '../../electron-main/browserSessionNetworkFilter.js'; + +suite('BrowserSession network filter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createFilter(): { filter: BrowserSessionNetworkFilter; setDeniedAuthority(authority: string | undefined): void } { + let deniedAuthority: string | undefined = 'denied.example'; + const networkFilter: IAgentNetworkFilterService = { + _serviceBrand: undefined, + onDidChange: Event.None, + isUriAllowed: uri => uri.authority !== deniedAuthority, + formatError: uri => `Access to ${uri.authority} is blocked by network domain policy.`, + }; + return { + filter: new BrowserSessionNetworkFilter(networkFilter), + setDeniedAuthority: authority => deniedAuthority = authority, + }; + } + + function invokeRequest(filter: BrowserSessionNetworkFilter, details: Partial & Pick): CallbackResponse { + let result: CallbackResponse | undefined; + filter.onBeforeRequest({ + id: 1, + method: 'GET', + referrer: '', + timestamp: 0, + uploadData: [], + ...details, + }, response => result = response); + assert.ok(result); + return result; + } + + test('filters only explicitly shared webContents in a shared Electron session', () => { + const { filter } = createFilter(); + filter.setFiltering(1, true); + + assert.deepStrictEqual({ + sharedDenied: invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }), + unsharedDenied: invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 2 }), + missingOwnerDenied: invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame' }), + sharedAllowed: invokeRequest(filter, { url: 'https://allowed.example/frame', resourceType: 'subFrame', webContentsId: 1 }), + sharedPolicyError: filter.getPolicyError(1), + unsharedPolicyError: filter.getPolicyError(2), + }, { + sharedDenied: { cancel: true }, + unsharedDenied: { cancel: false }, + missingOwnerDenied: { cancel: false }, + sharedAllowed: { cancel: false }, + sharedPolicyError: 'Access to denied.example is blocked by network domain policy.', + unsharedPolicyError: undefined, + }); + }); + + test('unsharing disables filtering and clears retained policy errors', () => { + const { filter } = createFilter(); + filter.setFiltering(1, true); + invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }); + + filter.setFiltering(1, false); + + assert.deepStrictEqual({ + request: invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }), + policyError: filter.getPolicyError(1), + }, { + request: { cancel: false }, + policyError: undefined, + }); + }); + + test('main-frame navigation remains loadable and resets retained subframe errors', () => { + const { filter } = createFilter(); + filter.setFiltering(1, true); + invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }); + + const result = invokeRequest(filter, { url: 'https://denied.example/page', resourceType: 'mainFrame', webContentsId: 1 }); + + assert.deepStrictEqual({ + result, + policyError: filter.getPolicyError(1), + }, { + result: { cancel: false }, + policyError: undefined, + }); + }); + + test('uses the webContents object when webContentsId is omitted', () => { + const { filter } = createFilter(); + const webContents = { id: 1 } as unknown as Electron.WebContents; + filter.setFiltering(1, true); + + const deniedSubframe = invokeRequest(filter, { + url: 'https://denied.example/frame', + resourceType: 'subFrame', + webContents, + }); + const retainedPolicyError = filter.getPolicyError(1); + const mainFrame = invokeRequest(filter, { + url: 'https://denied.example/page', + resourceType: 'mainFrame', + webContents, + }); + + assert.deepStrictEqual({ + deniedSubframe, + retainedPolicyError, + mainFrame, + policyErrorAfterNavigation: filter.getPolicyError(1), + }, { + deniedSubframe: { cancel: true }, + retainedPolicyError: 'Access to denied.example is blocked by network domain policy.', + mainFrame: { cancel: false }, + policyErrorAfterNavigation: undefined, + }); + }); + + test('uses current network policy for every request', () => { + const { filter, setDeniedAuthority } = createFilter(); + filter.setFiltering(1, true); + + const initiallyDenied = invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }); + setDeniedAuthority(undefined); + const allowedAfterPolicyChange = invokeRequest(filter, { url: 'https://denied.example/frame', resourceType: 'subFrame', webContentsId: 1 }); + + assert.deepStrictEqual({ initiallyDenied, allowedAfterPolicyChange }, { + initiallyDenied: { cancel: true }, + allowedAfterPolicyChange: { cancel: false }, + }); + }); + + test('fails closed for malformed requests owned by a shared view', () => { + const { filter } = createFilter(); + filter.setFiltering(1, true); + + assert.deepStrictEqual({ + request: invokeRequest(filter, { url: 'not a uri', resourceType: 'subFrame', webContentsId: 1 }), + policyError: filter.getPolicyError(1), + }, { + request: { cancel: true }, + policyError: 'A browser request was blocked by network domain policy.', + }); + }); +}); diff --git a/src/vs/platform/browserView/test/electron-main/browserViewNetworkFilter.test.ts b/src/vs/platform/browserView/test/electron-main/browserViewNetworkFilter.test.ts new file mode 100644 index 00000000000000..e3c65a7a41e5bf --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserViewNetworkFilter.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { BrowserViewAgentNetworkFilterSources, IAgentNetworkFilterableBrowserView, setBrowserViewGroupAgentNetworkFiltering } from '../../electron-main/browserViewAgentNetworkFilter.js'; + +suite('BrowserView network filter lifecycle', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + sinon.restore(); + }); + + test('keeps filtering enabled until every agent source releases the view', () => { + const sources = new BrowserViewAgentNetworkFilterSources(); + + assert.deepStrictEqual([ + sources.set('agent-one', true), + sources.set('agent-two', true), + sources.set('agent-one', false), + sources.set('agent-two', false), + ], [ + true, + true, + true, + false, + ]); + }); + + test('clearing sources disables filtering state', () => { + const sources = new BrowserViewAgentNetworkFilterSources(); + sources.set('agent-one', true); + sources.set('agent-two', true); + + sources.clear(); + + assert.strictEqual(sources.set('agent-three', false), false); + }); + + test('agent group add and remove transitions update filtering with the session source', () => { + const setAgentNetworkFiltering = sinon.stub(); + const view: IAgentNetworkFilterableBrowserView = { setAgentNetworkFiltering }; + const owner = { mainWindowId: 1, sessionId: 'agent-session' }; + + setBrowserViewGroupAgentNetworkFiltering(view, owner, true); + setBrowserViewGroupAgentNetworkFiltering(view, owner, false); + + assert.deepStrictEqual(setAgentNetworkFiltering.args, [ + ['agent-session', true], + ['agent-session', false], + ]); + }); + + test('non-agent groups do not change request filtering', () => { + const setAgentNetworkFiltering = sinon.stub(); + const view: IAgentNetworkFilterableBrowserView = { setAgentNetworkFiltering }; + const owner = { mainWindowId: 1 }; + + setBrowserViewGroupAgentNetworkFiltering(view, owner, true); + setBrowserViewGroupAgentNetworkFiltering(view, owner, false); + + assert.strictEqual(setAgentNetworkFiltering.callCount, 0); + }); +}); diff --git a/src/vs/platform/browserView/test/node/playwrightTab.test.ts b/src/vs/platform/browserView/test/node/playwrightTab.test.ts new file mode 100644 index 00000000000000..d42dd5b310f9c6 --- /dev/null +++ b/src/vs/platform/browserView/test/node/playwrightTab.test.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { EventEmitter } from 'events'; +// eslint-disable-next-line local/code-import-patterns +import type * as playwright from 'playwright-core'; +import { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IAgentNetworkFilterService } from '../../../networkFilter/common/networkFilterService.js'; +import { PlaywrightTab } from '../../node/playwrightTab.js'; + +class TestFrame { + constructor(private readonly value: string) { } + + url(): string { + return this.value; + } + + async waitForLoadState(): Promise { } +} + +class TestPage extends EventEmitter { + private currentFrames: TestFrame[]; + readonly ariaSnapshotCalls: boolean[] = []; + + constructor( + mainUrl: string, + childUrls: string[] = [], + private readonly snapshot = 'ALLOWED_PAGE_CONTENT', + ) { + super(); + this.currentFrames = [new TestFrame(mainUrl), ...childUrls.map(url => new TestFrame(url))]; + } + + setFrames(mainUrl: string, childUrls: string[] = []): void { + this.currentFrames = [new TestFrame(mainUrl), ...childUrls.map(url => new TestFrame(url))]; + } + + url(): string { + return this.currentFrames[0].url(); + } + + frames(): playwright.Frame[] { + return this.currentFrames as unknown as playwright.Frame[]; + } + + mainFrame(): playwright.Frame { + return this.currentFrames[0] as unknown as playwright.Frame; + } + + async consoleMessages(): Promise { + return []; + } + + async pageErrors(): Promise { + return []; + } + + async ariaSnapshot(options?: { _track?: string }): Promise { + this.ariaSnapshotCalls.push(options?._track === 'response'); + return this.snapshot; + } + + async title(): Promise { + return 'Allowed Page'; + } + + async waitForFunction(): Promise { + return true; + } + + asPage(): playwright.Page { + return this as unknown as playwright.Page; + } +} + +suite('PlaywrightTab network policy', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createTab(page: TestPage): PlaywrightTab { + const networkFilter: IAgentNetworkFilterService = { + _serviceBrand: undefined, + onDidChange: Event.None, + isUriAllowed: uri => uri.authority !== 'denied.example', + formatError: uri => `Access to ${uri.authority} is blocked by network domain policy.`, + }; + return new PlaywrightTab(page.asPage(), { activeCalls: 0 }, networkFilter); + } + + test('summary rejects a denied child frame without extracting page content', async () => { + const page = new TestPage('https://allowed.example', ['https://denied.example/frame']); + const tab = createTab(page); + + const summary = await tab.getSummary(true); + + assert.deepStrictEqual({ + summary, + ariaSnapshotCallCount: page.ariaSnapshotCalls.length, + }, { + summary: 'Access to denied.example is blocked by network domain policy.', + ariaSnapshotCallCount: 0, + }); + }); + + test('summary ignores Chromium error replacement frames', async () => { + const page = new TestPage('https://allowed.example', ['chrome-error://chromewebdata/']); + const tab = createTab(page); + + const summary = await tab.getSummary(true); + + assert.deepStrictEqual({ + includesAllowedContent: summary.includes('ALLOWED_PAGE_CONTENT'), + includesChromeError: summary.includes('chromewebdata'), + ariaSnapshotCallCount: page.ariaSnapshotCalls.length, + }, { + includesAllowedContent: true, + includesChromeError: false, + ariaSnapshotCallCount: 1, + }); + }); + + test('rejects an action result when the page navigates to a denied URL during execution', async () => { + const page = new TestPage('https://allowed.example'); + const tab = createTab(page); + + await assert.rejects( + tab.safeRunAgainstPage(async () => { + page.setFrames('https://denied.example/private'); + return 'DENIED_ACTION_RESULT'; + }), + /Access to denied\.example is blocked by network domain policy/ + ); + }); + + test('does not expose denied request URLs through recent-event logs', async () => { + const page = new TestPage('https://allowed.example'); + const tab = createTab(page); + const request = { + url: () => 'https://denied.example/private', + } as playwright.Request; + + page.emit('requestfailed', request); + const summary = await tab.getSummary(true); + + assert.deepStrictEqual({ + includesAllowedContent: summary.includes('ALLOWED_PAGE_CONTENT'), + includesDeniedUrl: summary.includes('denied.example'), + includesRequestFailure: summary.includes('requestFailed'), + }, { + includesAllowedContent: true, + includesDeniedUrl: false, + includesRequestFailure: false, + }); + }); + + test('uses current network policy after configuration changes', async () => { + let denied = true; + const page = new TestPage('https://allowed.example', ['https://dynamic.example/frame']); + const networkFilter: IAgentNetworkFilterService = { + _serviceBrand: undefined, + onDidChange: Event.None, + isUriAllowed: uri => uri.authority !== 'dynamic.example' || !denied, + formatError: uri => `Access to ${uri.authority} is blocked by network domain policy.`, + }; + const tab = new PlaywrightTab(page.asPage(), { activeCalls: 0 }, networkFilter); + + const blockedSummary = await tab.getSummary(true); + denied = false; + const allowedSummary = await tab.getSummary(true); + + assert.deepStrictEqual({ + blockedSummary, + allowedSummaryContainsContent: allowedSummary.includes('ALLOWED_PAGE_CONTENT'), + }, { + blockedSummary: 'Access to dynamic.example is blocked by network domain policy.', + allowedSummaryContainsContent: true, + }); + }); +}); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 3c1da670e5d9f1..51982a55c937a3 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -412,6 +412,7 @@ export interface IBrowserViewModel extends IDisposable { findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; + getNetworkPolicyError(): Promise; clearStorage(): Promise; setSharedWithAgent(shared: boolean): Promise; trustCertificate(host: string, fingerprint: string): Promise; @@ -656,6 +657,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get isAreaSelectionActive(): boolean { return this._isAreaSelectionActive; } get device(): IBrowserDeviceProfile | undefined { return this._device; } + getNetworkPolicyError(): Promise { + return this.browserViewService.getNetworkPolicyError(this.id); + } + get onDidNavigate(): Event { return this.browserViewService.onDynamicDidNavigate(this.id); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 41182cd1f9effb..473177fc942e6d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -178,6 +178,11 @@ export class ScreenshotBrowserTool implements IToolImpl { return errorResult(`No browser page found with ID ${params.pageId}`); } + let networkPolicyError = await this.getNetworkPolicyError(browserViewModel, sessionId, params.pageId); + if (networkPolicyError) { + return errorResult(networkPolicyError); + } + const bounds = selector && await playwrightInvokeRaw(this.playwrightService, sessionId, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => { const locator = page.locator(selector); if (scrollIntoViewIfNeeded) { @@ -185,7 +190,17 @@ export class ScreenshotBrowserTool implements IToolImpl { } return locator.boundingBox(); }, selector, params.scrollIntoViewIfNeeded) || undefined; + + networkPolicyError = await this.getNetworkPolicyError(browserViewModel, sessionId, params.pageId); + if (networkPolicyError) { + return errorResult(networkPolicyError); + } + const screenshot = await browserViewModel.captureScreenshot({ pageRect: bounds }); + networkPolicyError = await this.getNetworkPolicyError(browserViewModel, sessionId, params.pageId); + if (networkPolicyError) { + return errorResult(networkPolicyError); + } const dimensions = readImageDimensions(screenshot); const hostWindow = this.findBrowserViewHostWindow(browserViewModel); @@ -227,6 +242,15 @@ export class ScreenshotBrowserTool implements IToolImpl { }; } + private async getNetworkPolicyError(model: IBrowserViewModel, sessionId: string, pageId: string): Promise { + try { + await playwrightInvokeRaw(this.playwrightService, sessionId, pageId, async () => undefined); + return model.getNetworkPolicyError(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + private findBrowserViewHostWindow(model: IBrowserViewModel): CodeWindow | undefined { // Walk groups in most-recently-active order so we prefer the window the user most likely // associates with this browser view. Matching by editor input means we still find the host diff --git a/src/vs/workbench/contrib/browserView/test/common/browserViewModelSharing.test.ts b/src/vs/workbench/contrib/browserView/test/common/browserViewModelSharing.test.ts new file mode 100644 index 00000000000000..82dffb850d91bd --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/common/browserViewModelSharing.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { Event } from '../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { BrowserViewModel, BrowserViewSharingState } from '../../common/browserView.js'; + +suite('BrowserViewModel sharing', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + sinon.restore(); + }); + + function createModel(allowed = true): { + model: BrowserViewModel; + startTrackingPage: sinon.SinonStub; + stopTrackingPage: sinon.SinonStub; + sharingEvents: BrowserViewSharingState[]; + } { + const startTrackingPage = sinon.stub().resolves(); + const stopTrackingPage = sinon.stub().resolves(); + const sharingEvents: BrowserViewSharingState[] = []; + const networkFilter: IAgentNetworkFilterService = { + _serviceBrand: undefined, + onDidChange: Event.None, + isUriAllowed: (_uri: URI) => allowed, + formatError: uri => `Access to ${uri.authority} is blocked by network domain policy.`, + }; + const model = Object.create(BrowserViewModel.prototype) as BrowserViewModel; + Reflect.set(model, 'id', 'view'); + Reflect.set(model, '_url', 'https://example.com'); + Reflect.set(model, '_sharedWithAgent', false); + Reflect.set(model, 'agentNetworkFilterService', networkFilter); + Reflect.set(model, 'storageService', { getBoolean: () => true }); + Reflect.set(model, 'telemetryService', { publicLog2: () => { } }); + Reflect.set(model, 'playwrightService', { startTrackingPage, stopTrackingPage }); + Reflect.set(model, 'browserViewWorkbenchService', { isSharingAvailable: true }); + Reflect.set(model, '_onDidChangeSharingState', { fire: (state: BrowserViewSharingState) => sharingEvents.push(state) }); + return { model, startTrackingPage, stopTrackingPage, sharingEvents }; + } + + test('sharing starts agent tracking and unsharing stops it', async () => { + const { model, startTrackingPage, stopTrackingPage, sharingEvents } = createModel(); + + const shared = await model.setSharedWithAgent(true); + const sharedState = model.sharingState; + const unshared = await model.setSharedWithAgent(false); + + assert.deepStrictEqual({ + shared, + unshared, + startTrackingArgs: startTrackingPage.args, + stopTrackingArgs: stopTrackingPage.args, + sharedState, + finalState: model.sharingState, + sharingEvents, + }, { + shared: true, + unshared: true, + startTrackingArgs: [['view']], + stopTrackingArgs: [['view']], + sharedState: BrowserViewSharingState.Shared, + finalState: BrowserViewSharingState.NotShared, + sharingEvents: [BrowserViewSharingState.Shared, BrowserViewSharingState.NotShared], + }); + }); + + test('does not share a page whose current URL is denied', async () => { + const { model, startTrackingPage, sharingEvents } = createModel(false); + const info = sinon.stub().resolves(); + Reflect.set(model, 'dialogService', { info }); + + const shared = await model.setSharedWithAgent(true); + + assert.deepStrictEqual({ + shared, + startTrackingCallCount: startTrackingPage.callCount, + infoCallCount: info.callCount, + sharingState: model.sharingState, + sharingEvents, + }, { + shared: false, + startTrackingCallCount: 0, + infoCallCount: 1, + sharingState: BrowserViewSharingState.NotShared, + sharingEvents: [], + }); + }); +}); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts new file mode 100644 index 00000000000000..618a42e71d9df1 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/screenshotBrowserTool.test.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IPlaywrightService } from '../../../../../../platform/browserView/common/playwrightService.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; +import { BrowserEditorInput } from '../../../common/browserEditorInput.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../common/browserView.js'; +import { ScreenshotBrowserTool } from '../../../electron-browser/tools/screenshotBrowserTool.js'; + +suite('ScreenshotBrowserTool', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + const screenshot = VSBuffer.wrap(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + )); + + teardown(() => { + sinon.restore(); + }); + + function createTool(policyErrors: Array, rawResults: unknown[] = []): { + tool: ScreenshotBrowserTool; + captureScreenshot: sinon.SinonStub; + getNetworkPolicyError: sinon.SinonStub; + invokeFunctionRaw: sinon.SinonStub; + telemetryEvents: string[]; + } { + const pageId = 'page'; + const captureScreenshot = sinon.stub().resolves(screenshot); + const getNetworkPolicyError = sinon.stub(); + for (let i = 0; i < policyErrors.length; i++) { + getNetworkPolicyError.onCall(i).resolves(policyErrors[i]); + } + const model = new class extends mock() { + override readonly zoomFactor = 1; + override readonly visible = true; + override captureScreenshot = captureScreenshot; + override getNetworkPolicyError = getNetworkPolicyError; + }(); + const input = new class extends mock() { + override async resolve(): Promise { + return model; + } + }(); + const browserViewWorkbenchService = new class extends mock() { + override getKnownBrowserViews(): Map { + return new Map([[pageId, input]]); + } + }(); + const invokeFunctionRaw = sinon.stub(); + for (let i = 0; i < rawResults.length; i++) { + invokeFunctionRaw.onCall(i).resolves(rawResults[i]); + } + const playwrightService = new class extends mock() { + override invokeFunctionRaw = invokeFunctionRaw; + }(); + const telemetryEvents: string[] = []; + const telemetryService = new class extends NullTelemetryServiceShape { + override publicLog2(): void { + telemetryEvents.push(arguments[0]); + } + }(); + const editorGroupsService = new class extends mock() { + override getGroups() { + return []; + } + }(); + return { + tool: new ScreenshotBrowserTool(browserViewWorkbenchService, playwrightService, telemetryService, editorGroupsService), + captureScreenshot, + getNetworkPolicyError, + invokeFunctionRaw, + telemetryEvents, + }; + } + + async function invoke(tool: ScreenshotBrowserTool, parameters: Record) { + return tool.invoke( + { callId: 'call-id', toolId: 'screenshot_page', parameters: { pageId: 'page', ...parameters }, context: undefined }, + () => Promise.resolve(0), + { report: () => { } }, + CancellationToken.None, + ); + } + + test('network policy rejection happens before screenshot capture', async () => { + const pageId = 'blocked-page'; + const blockedError = 'Access to denied.example is blocked by network domain policy.'; + const captureScreenshot = sinon.stub().resolves(VSBuffer.wrap(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ))); + const model = new class extends mock() { + override readonly zoomFactor = 1; + override readonly visible = true; + override captureScreenshot = captureScreenshot; + }(); + const input = new class extends mock() { + override async resolve(): Promise { + return model; + } + }(); + const browserViewWorkbenchService = new class extends mock() { + override getKnownBrowserViews(): Map { + return new Map([[pageId, input]]); + } + }(); + let validatePageAccessCallCount = 0; + const playwrightService = new class extends mock() { + override async invokeFunctionRaw(): Promise { + validatePageAccessCallCount++; + throw new Error(blockedError); + } + }(); + const editorGroupsService = new class extends mock() { + override getGroups() { + return []; + } + }(); + const tool = new ScreenshotBrowserTool( + browserViewWorkbenchService, + playwrightService, + NullTelemetryService, + editorGroupsService, + ); + + const result = await tool.invoke( + { callId: 'call-id', toolId: 'screenshot_page', parameters: { pageId }, context: undefined }, + () => Promise.resolve(0), + { report: () => { } }, + CancellationToken.None, + ); + + assert.deepStrictEqual({ + validatePageAccessCallCount, + captureScreenshotCallCount: captureScreenshot.callCount, + content: result.content, + }, { + validatePageAccessCallCount: 1, + captureScreenshotCallCount: 0, + content: [{ + kind: 'text', + value: blockedError, + }], + }); + }); + + test('network policy rejection happens before selector lookup', async () => { + const blockedError = 'Access to denied.example is blocked by network domain policy.'; + const { tool, captureScreenshot, getNetworkPolicyError, invokeFunctionRaw, telemetryEvents } = createTool([blockedError]); + + const result = await invoke(tool, { selector: '#target' }); + + assert.deepStrictEqual({ + invokeFunctionRawCallCount: invokeFunctionRaw.callCount, + getNetworkPolicyErrorCallCount: getNetworkPolicyError.callCount, + captureScreenshotCallCount: captureScreenshot.callCount, + telemetryEvents, + content: result.content, + }, { + invokeFunctionRawCallCount: 1, + getNetworkPolicyErrorCallCount: 1, + captureScreenshotCallCount: 0, + telemetryEvents: [], + content: [{ kind: 'text', value: blockedError }], + }); + }); + + test('network policy rejection after selector lookup prevents capture', async () => { + const blockedError = 'Access to denied.example is blocked by network domain policy.'; + const { tool, captureScreenshot, getNetworkPolicyError, invokeFunctionRaw, telemetryEvents } = createTool( + [undefined, blockedError], + [undefined, { x: 1, y: 2, width: 3, height: 4 }, undefined], + ); + + const result = await invoke(tool, { selector: '#target', scrollIntoViewIfNeeded: true }); + + assert.deepStrictEqual({ + invokeFunctionRawCallCount: invokeFunctionRaw.callCount, + getNetworkPolicyErrorCallCount: getNetworkPolicyError.callCount, + captureScreenshotCallCount: captureScreenshot.callCount, + telemetryEvents, + content: result.content, + }, { + invokeFunctionRawCallCount: 3, + getNetworkPolicyErrorCallCount: 2, + captureScreenshotCallCount: 0, + telemetryEvents: [], + content: [{ kind: 'text', value: blockedError }], + }); + }); + + test('network policy rejection after capture discards image data and success telemetry', async () => { + const blockedError = 'Access to denied.example is blocked by network domain policy.'; + const { tool, captureScreenshot, getNetworkPolicyError, invokeFunctionRaw, telemetryEvents } = createTool( + [undefined, undefined, blockedError], + [undefined, undefined, undefined], + ); + + const result = await invoke(tool, {}); + + assert.deepStrictEqual({ + invokeFunctionRawCallCount: invokeFunctionRaw.callCount, + getNetworkPolicyErrorCallCount: getNetworkPolicyError.callCount, + captureScreenshotCallCount: captureScreenshot.callCount, + telemetryEvents, + content: result.content, + }, { + invokeFunctionRawCallCount: 3, + getNetworkPolicyErrorCallCount: 3, + captureScreenshotCallCount: 1, + telemetryEvents: [], + content: [{ kind: 'text', value: blockedError }], + }); + }); + + test('successful screenshot validates around capture and emits success telemetry once', async () => { + const { tool, captureScreenshot, getNetworkPolicyError, invokeFunctionRaw, telemetryEvents } = createTool( + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ); + + const result = await invoke(tool, {}); + + assert.deepStrictEqual({ + invokeFunctionRawCallCount: invokeFunctionRaw.callCount, + getNetworkPolicyErrorCallCount: getNetworkPolicyError.callCount, + captureScreenshotCallCount: captureScreenshot.callCount, + telemetryEvents, + contentKinds: result.content.map(part => part.kind), + }, { + invokeFunctionRawCallCount: 3, + getNetworkPolicyErrorCallCount: 3, + captureScreenshotCallCount: 1, + telemetryEvents: ['integratedBrowser.tools.screenshot.captured'], + contentKinds: ['data'], + }); + }); +});