Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"native-keymap": "^3.3.5",
"node-pty": "^1.2.0-beta.10",
"open": "^10.1.2",
"playwright-core": "^1.58.2",
"tas-client": "0.3.1",
"undici": "^7.18.2",
"v8-inspect-profiler": "^0.1.1",
Expand Down
3 changes: 3 additions & 0 deletions src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { EncryptionMainService } from '../../platform/encryption/electron-main/e
import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js';
import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js';
import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js';
import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js';
import { NativeParsedArgs } from '../../platform/environment/common/argv.js';
import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js';
import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js';
Expand Down Expand Up @@ -1040,6 +1041,7 @@ export class CodeApplication extends Disposable {
services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */));

// Browser View
services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true));
services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */));

// Keyboard Layout
Expand Down Expand Up @@ -1202,6 +1204,7 @@ export class CodeApplication extends Disposable {
// Browser View
const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables);
mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel);
sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel));

// Signing
const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall
import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js';
import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js';
import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js';
import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js';
import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js';

class SharedProcessMain extends Disposable implements IClientConnectionFilter {

Expand Down Expand Up @@ -401,6 +403,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Web Content Extractor
services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService));

// Playwright
services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService));

return new InstantiationService(services);
}

Expand Down Expand Up @@ -467,6 +472,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// Web Content Extractor
const webContentExtractorChannel = ProxyChannel.fromService(accessor.get(ISharedWebContentExtractorService), this._store);
this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel);

// Playwright
const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store);
this.server.registerChannel('playwright', playwrightChannel);
}

private registerErrorHandler(logService: ILogService): void {
Expand Down
5 changes: 5 additions & 0 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,9 @@ export interface IBrowserViewService {
* @param id The browser view identifier
*/
clearStorage(id: string): Promise<void>;

/**
* Get a CDP WebSocket endpoint URL.
*/
getDebugWebSocketEndpoint(): Promise<string>;
}
18 changes: 18 additions & 0 deletions src/vs/platform/browserView/common/playwrightService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { createDecorator } from '../../instantiation/common/instantiation.js';

export const IPlaywrightService = createDecorator<IPlaywrightService>('playwrightService');

/**
* A service for using Playwright to connect to and automate the integrated browser.
*/
export interface IPlaywrightService {
readonly _serviceBrand: undefined;

// TODO@kycutler: define a more specific API.
initialize(): Promise<void>;
}
238 changes: 238 additions & 0 deletions src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import type * as http from 'http';
import { AddressInfo, Socket } from 'net';
import { upgradeToISocket } from '../../../base/parts/ipc/node/ipc.net.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { CDPBrowserProxy } from '../common/cdp/proxy.js';
import { CDPEvent, CDPRequest, CDPError, CDPErrorCode, ICDPBrowserTarget, ICDPConnection } from '../common/cdp/types.js';
import { disposableTimeout } from '../../../base/common/async.js';
import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';

export const IBrowserViewCDPProxyServer = createDecorator<IBrowserViewCDPProxyServer>('browserViewCDPProxyServer');

export interface IBrowserViewCDPProxyServer {
readonly _serviceBrand: undefined;

/**
* Returns a debug endpoint with a short-lived, single-use token.
*/
getWebSocketEndpoint(): Promise<string>;
}

/**
* WebSocket server that provides CDP debugging for browser views.
*/
export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer {
declare readonly _serviceBrand: undefined;

private server: http.Server | undefined;
private port: number | undefined;
private readonly tokens: TokenManager;

constructor(
private readonly browserTarget: ICDPBrowserTarget,
@ILogService private readonly logService: ILogService
) {
super();

this.tokens = this._register(new TokenManager());
}

/**
* Returns a debug endpoint with a short-lived, single-use token in the
* WebSocket URL. The token is revoked once a WebSocket connection is made
* or after 30 seconds, whichever comes first.
*/
async getWebSocketEndpoint(): Promise<string> {
await this.ensureServerStarted();

const token = await this.tokens.issueToken();
return this.getWebSocketUrl(token);
}

private getWebSocketUrl(token: string): string {
return `ws://localhost:${this.port}/devtools/browser?token=${token}`;
}

private async ensureServerStarted(): Promise<void> {
if (this.server) {
return;
}

const http = await import('http');
this.server = http.createServer();

await new Promise<void>((resolve, reject) => {
// Only listen on localhost to prevent external access
this.server!.listen(0, '127.0.0.1', () => resolve());
this.server!.once('error', reject);
});

const address = this.server.address() as AddressInfo;
this.port = address.port;

this.server.on('request', (req, res) => this.handleHttpRequest(req, res));
this.server.on('upgrade', (req: http.IncomingMessage, socket: Socket) => this.handleWebSocketUpgrade(req, socket));
}

private async handleHttpRequest(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
this.logService.debug(`[BrowserViewDebugProxy] HTTP request at ${_req.url}`);
// No support for HTTP endpoints for now.
res.writeHead(404);
res.end();
}

private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void {
const [pathname, params] = (req.url || '').split('?');

const token = new URLSearchParams(params).get('token');
if (!token || !this.tokens.consumeToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.end();
return;
}

const browserMatch = pathname.match(/^\/devtools\/browser(\/.*)?$/);

this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`);

if (!browserMatch) {
this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`);
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.end();
return;
}

this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`);

const upgraded = upgradeToISocket(req, socket, {
debugLabel: 'browser-view-cdp-' + generateUuid(),
enableMessageSplitting: false,
});

if (!upgraded) {
return;
}

const proxy = new CDPBrowserProxy(this.browserTarget);
const disposables = this.wireWebSocket(upgraded, proxy);
this._register(disposables);
this._register(upgraded);
}

/**
* Wire a WebSocket (ISocket) to an ICDPConnection bidirectionally.
* Returns a DisposableStore that cleans up all subscriptions.
*/
private wireWebSocket(upgraded: ISocket, connection: ICDPConnection): DisposableStore {
const disposables = new DisposableStore();

// Socket -> Connection: parse JSON, call sendMessage, write response/error
disposables.add(upgraded.onData((rawData: VSBuffer) => {
try {
const message = rawData.toString();
const { id, method, params, sessionId } = JSON.parse(message) as CDPRequest;
this.logService.debug(`[BrowserViewDebugProxy] <- ${message}`);
connection.sendMessage(method, params, sessionId)
.then((result: unknown) => {
const response = { id, result, sessionId };
const responseStr = JSON.stringify(response);
this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`);
upgraded.write(VSBuffer.fromString(responseStr));
})
.catch((error: Error) => {
const response = {
id,
error: {
code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError,
message: error.message || 'Unknown error'
},
sessionId
};
const responseStr = JSON.stringify(response);
this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`);
upgraded.write(VSBuffer.fromString(responseStr));
});
} catch (error) {
this.logService.error('[BrowserViewDebugProxy] Error parsing message:', error);
upgraded.end();
}
}));

// Connection -> Socket: serialize events and write
disposables.add(connection.onEvent((event: CDPEvent) => {
const eventStr = JSON.stringify(event);
this.logService.debug(`[BrowserViewDebugProxy] -> ${eventStr}`);
upgraded.write(VSBuffer.fromString(eventStr));
}));

// Connection close -> close socket
disposables.add(connection.onClose(() => {
this.logService.debug(`[BrowserViewDebugProxy] WebSocket closing`);
upgraded.end();
}));

// Socket closed -> cleanup
disposables.add(upgraded.onClose(() => {
this.logService.debug(`[BrowserViewDebugProxy] WebSocket closed`);
connection.dispose();
disposables.dispose();
}));

return disposables;
}

override dispose(): void {
if (this.server) {
this.server.close();
this.server = undefined;
}

super.dispose();
}
}

class TokenManager extends Disposable {
/** Map of currently valid single-use tokens. Each expires after 30 seconds. */
private readonly tokens = new Map<string, { expiresAt: number }>();

/**
* Creates a short-lived, single-use token.
* The token is revoked once consumed or after 30 seconds.
*/
async issueToken(): Promise<string> {
const token = this.makeToken();
this.tokens.set(token, { expiresAt: Date.now() + 30_000 });
this._register(disposableTimeout(() => this.tokens.delete(token), 30_000));
return token;
}

consumeToken(token: string): boolean {
if (!token) {
return false;
}
const info = this.tokens.get(token);
if (!info) {
return false;
}
this.tokens.delete(token);
return Date.now() <= info.expiresAt;
}

private makeToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('');
const base64 = btoa(binary);
const urlSafeToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

return urlSafeToken;
}
}
Loading
Loading