diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 5857aecb01d00..8deac9fd56d26 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -78,6 +78,8 @@ import { ISharedTunnelsService } from '../../../platform/tunnel/common/tunnel.js import { SharedTunnelsService } from '../../../platform/tunnel/node/tunnelService.js'; import { ipcSharedProcessTunnelChannelName, ISharedProcessTunnelService } from '../../../platform/remote/common/sharedProcessTunnelService.js'; import { SharedProcessTunnelService } from '../../../platform/tunnel/node/sharedProcessTunnelService.js'; +import { ISharedProcessTunnelProxyService, ipcSharedProcessTunnelProxyChannelName } from '../../../platform/tunnel/common/sharedProcessTunnelProxyService.js'; +import { SharedProcessTunnelProxyService } from '../../../platform/tunnel/node/sharedProcessTunnelProxyService.js'; import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../../platform/uriIdentity/common/uriIdentityService.js'; import { isLinux } from '../../../base/common/platform.js'; @@ -404,6 +406,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { remoteSocketFactoryService.register(RemoteConnectionType.WebSocket, nodeSocketFactory); services.set(ISharedTunnelsService, new SyncDescriptor(SharedTunnelsService)); services.set(ISharedProcessTunnelService, new SyncDescriptor(SharedProcessTunnelService)); + services.set(ISharedProcessTunnelProxyService, new SyncDescriptor(SharedProcessTunnelProxyService)); // Remote Tunnel services.set(IRemoteTunnelService, new SyncDescriptor(RemoteTunnelService)); @@ -482,6 +485,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const sharedProcessTunnelChannel = ProxyChannel.fromService(accessor.get(ISharedProcessTunnelService), this._store); this.server.registerChannel(ipcSharedProcessTunnelChannelName, sharedProcessTunnelChannel); + // Tunnel Proxy + const sharedProcessTunnelProxyChannel = ProxyChannel.fromService(accessor.get(ISharedProcessTunnelProxyService), this._store); + this.server.registerChannel(ipcSharedProcessTunnelProxyChannelName, sharedProcessTunnelProxyChannel); + // Remote Tunnel const remoteTunnelChannel = ProxyChannel.fromService(accessor.get(IRemoteTunnelService), this._store); this.server.registerChannel('remoteTunnel', remoteTunnelChannel); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index ecb40a9e856c0..0f1f35d817658 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -96,6 +96,7 @@ export interface IBrowserViewState { certificateError: IBrowserViewCertificateError | undefined; storageScope: BrowserViewStorageScope; browserZoomIndex: number; + isRemoteSession: boolean; } export interface IBrowserViewNavigationEvent { @@ -193,6 +194,15 @@ export enum BrowserViewStorageScope { Ephemeral = 'ephemeral' } +export interface IBrowserSessionOptions { + /** Storage / data-isolation scope for the session. */ + scope: BrowserViewStorageScope; + /** Workspace identifier — required when `scope` is `'workspace'`. */ + workspaceId?: string; + /** Tunnel proxy URL for the session (only used for workspace and ephemeral scopes). */ + proxyUrl?: string; +} + export const ipcBrowserViewChannelName = 'browserView'; /** @@ -232,10 +242,9 @@ export interface IBrowserViewService { /** * Get or create a browser view instance * @param id The browser view identifier - * @param scope The storage scope for the browser view. Ignored if the view already exists. - * @param workspaceId Workspace identifier for session isolation. Only used if scope is 'workspace'. + * @param sessionOptions Options for creating and configuring the session. Ignored if the view already exists. */ - getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise; + getOrCreateBrowserView(id: string, sessionOptions: IBrowserSessionOptions): Promise; /** * Destroy a browser view instance diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index dc23d2ee0c0a9..aa26bf48c2f42 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -7,7 +7,7 @@ import { session } from 'electron'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { BrowserViewStorageScope } from '../common/browserView.js'; +import { BrowserViewStorageScope, IBrowserSessionOptions } from '../common/browserView.js'; import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; import { FileAccess } from '../../../base/common/network.js'; @@ -122,17 +122,17 @@ export class BrowserSession { /** * Get or create a workspace-scope session for the given workspace. */ - static getOrCreateWorkspace(workspaceId: string, workspaceStorageHome: URI): BrowserSession { + static getOrCreateWorkspace(workspaceId: string, workspaceStorageHome: URI, proxyUrl?: string): BrowserSession { const storage = joinPath(workspaceStorageHome, workspaceId, 'browserStorage'); const electronSession = session.fromPath(storage.fsPath); return BrowserSession._bySession.get(electronSession) - ?? new BrowserSession(`workspace:${workspaceId}`, electronSession, BrowserViewStorageScope.Workspace); + ?? new BrowserSession(`workspace:${workspaceId}`, electronSession, BrowserViewStorageScope.Workspace, proxyUrl); } /** * Get or create an ephemeral session for the given view / target id. */ - static getOrCreateEphemeral(viewId: string, type?: string): BrowserSession { + static getOrCreateEphemeral(viewId: string, type?: string, proxyUrl?: string): BrowserSession { if (type === 'workspace' || type === 'ephemeral') { throw new Error(`Cannot create session with reserved type '${type}'`); } @@ -140,7 +140,7 @@ export class BrowserSession { const sessionId = `${type ?? 'ephemeral'}:${viewId}`; const electronSession = session.fromPartition(`vscode-browser-${type}${viewId}`); return BrowserSession._bySession.get(electronSession) - ?? new BrowserSession(sessionId, electronSession, BrowserViewStorageScope.Ephemeral); + ?? new BrowserSession(sessionId, electronSession, BrowserViewStorageScope.Ephemeral, proxyUrl); } /** @@ -151,7 +151,10 @@ export class BrowserSession { * * @param viewId Used only for ephemeral sessions where every view * needs its own Electron session. - * @param scope Desired storage scope. + * @param sessionOptions Determines the storage scope and proxy configuration + * for the session. The `scope` determines how the + * session `id` is derived and thus which views share + * the session. * @param workspaceStorageHome Root folder under which per-workspace * browser storage is created * (`IEnvironmentMainService.workspaceStorageHome`). @@ -159,21 +162,20 @@ export class BrowserSession { */ static getOrCreate( viewId: string, - scope: BrowserViewStorageScope, + sessionOptions: IBrowserSessionOptions, workspaceStorageHome: URI, - workspaceId?: string, ): BrowserSession { - switch (scope) { + switch (sessionOptions.scope) { case BrowserViewStorageScope.Global: return BrowserSession.getOrCreateGlobal(); case BrowserViewStorageScope.Workspace: - if (workspaceId) { - return BrowserSession.getOrCreateWorkspace(workspaceId, workspaceStorageHome); + if (sessionOptions.workspaceId) { + return BrowserSession.getOrCreateWorkspace(sessionOptions.workspaceId, workspaceStorageHome, sessionOptions.proxyUrl); } // fallthrough -- no workspace context -> ephemeral case BrowserViewStorageScope.Ephemeral: default: - return BrowserSession.getOrCreateEphemeral(viewId); + return BrowserSession.getOrCreateEphemeral(viewId, undefined, sessionOptions.proxyUrl); } } @@ -183,6 +185,9 @@ export class BrowserSession { private readonly _trust: BrowserSessionTrust; + /** Whether this session routes traffic through a remote proxy. */ + readonly isRemoteSession: boolean; + private constructor( /** * Unique identifier for this session. Derived from what makes the @@ -194,9 +199,12 @@ export class BrowserSession { readonly electronSession: Electron.Session, /** Resolved storage scope. */ readonly storageScope: BrowserViewStorageScope, + /** Proxy URL, if remote. */ + proxyUrl?: string, ) { + this.isRemoteSession = !!proxyUrl; this._trust = new BrowserSessionTrust(this); - this.configure(); + this.configure(proxyUrl); BrowserSession.knownSessions.add(electronSession); BrowserSession._bySession.set(electronSession, this); BrowserSession._byId.set(id, new WeakRef(this)); @@ -221,7 +229,7 @@ export class BrowserSession { /** * Apply the permission policy and preload scripts to the session. */ - private configure(): void { + private configure(proxyUrl?: string): void { this.electronSession.setPermissionRequestHandler((_webContents, permission, callback) => { return callback(allowedPermissions.has(permission)); }); @@ -232,6 +240,12 @@ export class BrowserSession { type: 'frame', filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath }); + if (proxyUrl) { + this.electronSession.setProxy({ + proxyRules: proxyUrl, + proxyBypassRules: '<-loopback>' + }); + } } /** diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 2472e8bba1573..6fe4c7b48db12 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -427,7 +427,8 @@ export class BrowserView extends Disposable { lastError: this._lastError, certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, - browserZoomIndex: this._browserZoomIndex + browserZoomIndex: this._browserZoomIndex, + isRemoteSession: this.session.isRemoteSession }; } diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index e644063ae95c6..c7e459ef03ce0 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -7,7 +7,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserSessionOptions } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -61,9 +61,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa super(); } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { + async getOrCreateBrowserView(id: string, sessionOptions: IBrowserSessionOptions): Promise { if (this.browserViews.has(id)) { - // Note: scope will be ignored if the view already exists. + // Note: session options will be ignored if the view already exists. // Browser views cannot be moved between sessions after creation. const view = this.browserViews.get(id)!; return view.getState(); @@ -71,9 +71,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const browserSession = BrowserSession.getOrCreate( id, - scope, + sessionOptions, this.environmentMainService.workspaceStorageHome, - workspaceId ); const view = this.createBrowserView(id, browserSession); diff --git a/src/vs/platform/tunnel/common/sharedProcessTunnelProxyService.ts b/src/vs/platform/tunnel/common/sharedProcessTunnelProxyService.ts new file mode 100644 index 0000000000000..caed2700ce774 --- /dev/null +++ b/src/vs/platform/tunnel/common/sharedProcessTunnelProxyService.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import { IAddress } from '../../remote/common/remoteAgentConnection.js'; + +export const ISharedProcessTunnelProxyService = createDecorator('sharedProcessTunnelProxyService'); + +export const ipcSharedProcessTunnelProxyChannelName = 'sharedProcessTunnelProxy'; + +/** + * A service running in the shared process that manages a SOCKS5 proxy + * server. The proxy routes TCP connections through the remote agent + * tunnel, making the remote network transparently accessible to consumers + * that support SOCKS proxies (e.g. Electron sessions via `session.setProxy()`). + */ +export interface ISharedProcessTunnelProxyService { + readonly _serviceBrand: undefined; + + /** + * Start the tunnel proxy for the given remote authority. Returns the proxy URL. + */ + start(authority: string): Promise; + + /** + * Set the remote address info for the proxy for the given authority. + * Should be called whenever the resolver resolves. + */ + setAddress(authority: string, address: IAddress): Promise; + + /** + * Release one reference to the proxy for the given authority. + * The proxy is stopped when the last reference is released. + */ + stop(authority: string): Promise; +} diff --git a/src/vs/platform/tunnel/electron-browser/sharedProcessTunnelProxyService.ts b/src/vs/platform/tunnel/electron-browser/sharedProcessTunnelProxyService.ts new file mode 100644 index 0000000000000..60ca53f9e2b7a --- /dev/null +++ b/src/vs/platform/tunnel/electron-browser/sharedProcessTunnelProxyService.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSharedProcessRemoteService } from '../../ipc/electron-browser/services.js'; +import { ISharedProcessTunnelProxyService, ipcSharedProcessTunnelProxyChannelName } from '../common/sharedProcessTunnelProxyService.js'; + +registerSharedProcessRemoteService(ISharedProcessTunnelProxyService, ipcSharedProcessTunnelProxyChannelName); diff --git a/src/vs/platform/tunnel/node/sharedProcessTunnelProxyService.ts b/src/vs/platform/tunnel/node/sharedProcessTunnelProxyService.ts new file mode 100644 index 0000000000000..ffd50c0122d40 --- /dev/null +++ b/src/vs/platform/tunnel/node/sharedProcessTunnelProxyService.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { DeferredPromise } from '../../../base/common/async.js'; +import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; +import { IAddress, IAddressProvider, IConnectionOptions } from '../../remote/common/remoteAgentConnection.js'; +import { IRemoteSocketFactoryService } from '../../remote/common/remoteSocketFactoryService.js'; +import { ISignService } from '../../sign/common/sign.js'; +import { ISharedProcessTunnelProxyService } from '../common/sharedProcessTunnelProxyService.js'; +import { TunnelProxy } from './tunnelProxy.js'; + +class AddressProvider implements IAddressProvider { + private _address: IAddress | null = null; + private _pending: DeferredPromise | null = null; + + async getAddress(): Promise { + if (this._address) { + return this._address; + } + if (!this._pending) { + this._pending = new DeferredPromise(); + } + return this._pending.p; + } + + setAddress(address: IAddress): void { + this._address = address; + if (this._pending) { + this._pending.complete(address); + this._pending = null; + } + } +} + +class ProxyEntry { + readonly addressProvider = new AddressProvider(); + proxy: TunnelProxy | undefined; + startPromise: Promise | undefined; + refCount = 0; +} + +export class SharedProcessTunnelProxyService extends Disposable implements ISharedProcessTunnelProxyService { + declare readonly _serviceBrand: undefined; + + private readonly _entries = new Map(); + + constructor( + @IRemoteSocketFactoryService private readonly _remoteSocketFactoryService: IRemoteSocketFactoryService, + @ILogService private readonly _logService: ILogService, + @ISignService private readonly _signService: ISignService, + @IProductService private readonly _productService: IProductService, + ) { + super(); + } + + override dispose(): void { + for (const entry of this._entries.values()) { + entry.proxy?.dispose(); + } + this._entries.clear(); + super.dispose(); + } + + async start(authority: string): Promise { + let entry = this._entries.get(authority); + if (!entry) { + entry = new ProxyEntry(); + this._entries.set(authority, entry); + } + + entry.refCount++; + + if (entry.startPromise) { + return entry.startPromise; + } + + entry.startPromise = this._doStart(entry); + try { + return await entry.startPromise; + } catch (err) { + // Roll back on failure so the next caller can retry + entry.refCount--; + if (entry.refCount === 0) { + this._entries.delete(authority); + } + throw err; + } + } + + private async _doStart(entry: ProxyEntry): Promise { + const options: IConnectionOptions = { + commit: this._productService.commit, + quality: this._productService.quality, + addressProvider: entry.addressProvider, + remoteSocketFactoryService: this._remoteSocketFactoryService, + signService: this._signService, + logService: this._logService, + ipcLogger: null, + }; + + const proxy = new TunnelProxy(options, this._logService); + await proxy.start(); + entry.proxy = proxy; + + return proxy.proxyUrl; + } + + async setAddress(authority: string, address: IAddress): Promise { + this._entries.get(authority)?.addressProvider.setAddress(address); + } + + async stop(authority: string): Promise { + const entry = this._entries.get(authority); + if (!entry) { + return; + } + if (entry.refCount > 0) { + entry.refCount--; + } + if (entry.refCount === 0) { + entry.proxy?.dispose(); + this._entries.delete(authority); + } + } +} diff --git a/src/vs/platform/tunnel/node/tunnelProxy.ts b/src/vs/platform/tunnel/node/tunnelProxy.ts new file mode 100644 index 0000000000000..b8c7a8abbde83 --- /dev/null +++ b/src/vs/platform/tunnel/node/tunnelProxy.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as net from 'net'; +import { findFreePortFaster } from '../../../base/node/ports.js'; +import { NodeSocket } from '../../../base/parts/ipc/node/ipc.net.js'; +import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { IConnectionOptions, connectRemoteAgentTunnel } from '../../remote/common/remoteAgentConnection.js'; + +/** + * SOCKS5 authentication methods (RFC 1928). + */ +const enum Socks5Auth { + NoAuth = 0x00, + NoAcceptable = 0xFF, +} + +/** + * SOCKS5 address types (RFC 1928). + */ +const enum Socks5Atyp { + IPv4 = 0x01, + DomainName = 0x03, + IPv6 = 0x04, +} + +/** + * SOCKS5 reply codes (RFC 1928). + */ +const enum Socks5Reply { + Succeeded = 0x00, + GeneralFailure = 0x01, + HostUnreachable = 0x04, + CommandNotSupported = 0x07, + AddressTypeNotSupported = 0x08, +} + +/** + * A SOCKS5 proxy server that routes TCP connections through the remote + * agent tunnel. + * + * SOCKS5 operates at the TCP level — Chromium sends a SOCKS5 CONNECT + * for **every** TCP connection (both HTTP and HTTPS), avoiding the + * HTTP-vs-CONNECT split that HTTP proxies have. + * + * No authentication is required — security is provided by binding to + * `127.0.0.1` with an ephemeral port (same posture as NodeRemoteTunnel). + * + * Binds to `127.0.0.1` only (not exposed to the network). + */ +export class TunnelProxy extends Disposable { + + private readonly _server: net.Server; + private _localPort: number = 0; + + get localPort(): number { + return this._localPort; + } + + /** Proxy URL for `session.setProxy()`. */ + get proxyUrl(): string { + return `socks5://127.0.0.1:${this._localPort}`; + } + + constructor( + private readonly _connectionOptions: IConnectionOptions, + private readonly _logService: ILogService, + ) { + super(); + this._server = net.createServer(socket => this._onConnection(socket)); + this._server.on('error', (err) => { + this._logService.error('[TunnelProxy] Server error:', err); + }); + } + + async start(): Promise { + const port = await findFreePortFaster(0, 2, 1000, '127.0.0.1'); + this._server.listen(port, '127.0.0.1'); + await new Promise((resolve, reject) => { + this._server.once('listening', resolve); + this._server.once('error', reject); + }); + const address = this._server.address() as net.AddressInfo; + this._localPort = address.port; + this._logService.info(`[TunnelProxy] Listening on 127.0.0.1:${this._localPort}`); + } + + override dispose(): void { + this._server.close(); + super.dispose(); + } + + private _onConnection(socket: net.Socket): void { + socket.once('data', (data) => this._handleGreeting(socket, data)); + } + + /** + * Handle the SOCKS5 greeting (version + auth methods). + * We accept no-auth (0x00) — security is provided by binding to + * 127.0.0.1 with an ephemeral port. + */ + private _handleGreeting(socket: net.Socket, data: Buffer): void { + if (data.length < 2 || data[0] !== 0x05) { + socket.end(); + return; + } + + const nMethods = data[1]; + const methods = data.subarray(2, 2 + nMethods); + + if (!methods.includes(Socks5Auth.NoAuth)) { + socket.write(Buffer.from([0x05, Socks5Auth.NoAcceptable])); + socket.end(); + return; + } + + socket.write(Buffer.from([0x05, Socks5Auth.NoAuth])); + socket.once('data', (reqData) => this._handleRequest(socket, reqData)); + } + + /** + * Handle the SOCKS5 CONNECT request (RFC 1928). + * Parses the target address and port, then tunnels through the remote agent. + */ + private _handleRequest(socket: net.Socket, data: Buffer): void { + if (data.length < 4 || data[0] !== 0x05) { + socket.end(); + return; + } + + const cmd = data[1]; + if (cmd !== 0x01) { + this._sendReply(socket, Socks5Reply.CommandNotSupported); + socket.end(); + return; + } + + const atyp = data[3]; + let host: string; + let offset: number; + + switch (atyp) { + case Socks5Atyp.IPv4: { + if (data.length < 10) { socket.end(); return; } + host = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`; + offset = 8; + break; + } + case Socks5Atyp.DomainName: { + const domainLen = data[4]; + if (data.length < 5 + domainLen + 2) { socket.end(); return; } + host = data.subarray(5, 5 + domainLen).toString('utf8'); + offset = 5 + domainLen; + break; + } + case Socks5Atyp.IPv6: { + if (data.length < 22) { socket.end(); return; } + const parts: string[] = []; + for (let i = 0; i < 8; i++) { + parts.push(data.readUInt16BE(4 + i * 2).toString(16)); + } + host = parts.join(':'); + offset = 20; + break; + } + default: { + this._sendReply(socket, Socks5Reply.AddressTypeNotSupported); + socket.end(); + return; + } + } + + const port = data.readUInt16BE(offset); + this._logService.trace(`[TunnelProxy] CONNECT ${host}:${port}`); + this._connectTunnel(socket, host, port); + } + + private _sendReply(socket: net.Socket, reply: number): void { + const buf = Buffer.alloc(10); + buf[0] = 0x05; + buf[1] = reply; + buf[2] = 0x00; + buf[3] = Socks5Atyp.IPv4; + socket.write(buf); + } + + private async _connectTunnel(socket: net.Socket, host: string, port: number): Promise { + try { + socket.pause(); + + const protocol = await connectRemoteAgentTunnel(this._connectionOptions, host, port); + const remoteSocket = protocol.getSocket(); + const dataChunk = protocol.readEntireBuffer(); + protocol.dispose(); + + this._sendReply(socket, Socks5Reply.Succeeded); + + if (dataChunk.byteLength > 0) { + socket.write(dataChunk.buffer); + } + + if (remoteSocket instanceof NodeSocket) { + this._mirrorNodeSocket(socket, remoteSocket); + } else { + this._mirrorGenericSocket(socket, remoteSocket); + } + } catch (err) { + this._logService.error(`[TunnelProxy] Failed to tunnel to ${host}:${port}:`, err); + this._sendReply(socket, Socks5Reply.HostUnreachable); + socket.end(); + } + } + + private _mirrorNodeSocket(localSocket: net.Socket, remoteNodeSocket: NodeSocket): void { + const remoteSocket = remoteNodeSocket.socket; + remoteSocket.on('end', () => localSocket.end()); + remoteSocket.on('close', () => localSocket.end()); + remoteSocket.on('error', () => localSocket.destroy()); + localSocket.on('end', () => remoteSocket.end()); + localSocket.on('close', () => remoteSocket.end()); + localSocket.on('error', () => remoteSocket.destroy()); + + remoteSocket.pipe(localSocket); + localSocket.pipe(remoteSocket); + } + + private _mirrorGenericSocket(localSocket: net.Socket, remoteSocket: ISocket): void { + remoteSocket.onClose(() => localSocket.destroy()); + remoteSocket.onEnd(() => localSocket.end()); + remoteSocket.onData(d => localSocket.write(d.buffer)); + localSocket.on('data', d => remoteSocket.write(VSBuffer.wrap(d))); + localSocket.on('end', () => remoteSocket.end()); + localSocket.on('close', () => remoteSocket.end()); + localSocket.on('error', () => remoteSocket.end()); + localSocket.resume(); + } +} diff --git a/src/vs/platform/tunnel/test/node/tunnelProxy.test.ts b/src/vs/platform/tunnel/test/node/tunnelProxy.test.ts new file mode 100644 index 0000000000000..7d239290c744c --- /dev/null +++ b/src/vs/platform/tunnel/test/node/tunnelProxy.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as net from 'net'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TunnelProxy } from '../../node/tunnelProxy.js'; +import { IConnectionOptions } from '../../../remote/common/remoteAgentConnection.js'; +import { NullLogService } from '../../../log/common/log.js'; + +function buildConnectIPv4(host: string, port: number): Buffer { + const parts = host.split('.').map(Number); + const buf = Buffer.alloc(10); + buf[0] = 0x05; buf[1] = 0x01; buf[2] = 0x00; buf[3] = 0x01; + buf[4] = parts[0]; buf[5] = parts[1]; buf[6] = parts[2]; buf[7] = parts[3]; + buf.writeUInt16BE(port, 8); + return buf; +} + +function buildConnectDomain(domain: string, port: number): Buffer { + const d = Buffer.from(domain, 'utf8'); + const buf = Buffer.alloc(4 + 1 + d.length + 2); + buf[0] = 0x05; buf[1] = 0x01; buf[2] = 0x00; buf[3] = 0x03; + buf[4] = d.length; + d.copy(buf, 5); + buf.writeUInt16BE(port, 5 + d.length); + return buf; +} + +function buildConnectIPv6(parts: number[], port: number): Buffer { + const buf = Buffer.alloc(4 + 16 + 2); + buf[0] = 0x05; buf[1] = 0x01; buf[2] = 0x00; buf[3] = 0x04; + for (let i = 0; i < 8; i++) { buf.writeUInt16BE(parts[i], 4 + i * 2); } + buf.writeUInt16BE(port, 20); + return buf; +} + +function readBytes(socket: net.Socket, n: number, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let received = 0; + const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out (got ${received}/${n})`)); }, timeoutMs); + function onData(data: Buffer) { chunks.push(data); received += data.length; if (received >= n) { cleanup(); resolve(Buffer.concat(chunks).subarray(0, n)); } } + function onClose() { cleanup(); reject(new Error(`Closed after ${received}/${n}`)); } + function cleanup() { clearTimeout(timeout); socket.removeListener('data', onData); socket.removeListener('close', onClose); } + socket.on('data', onData); socket.on('close', onClose); + }); +} + +function connectToProxy(port: number): Promise { + return new Promise((resolve, reject) => { + const s = net.createConnection({ host: '127.0.0.1', port }, () => { s.removeListener('error', reject); resolve(s); }); + s.once('error', reject); + }); +} + +async function doGreeting(socket: net.Socket): Promise { + socket.write(Buffer.from([0x05, 0x01, 0x00])); + return readBytes(socket, 2); +} + +const dummyOpts: IConnectionOptions = { + commit: undefined, + quality: undefined, + addressProvider: { getAddress: () => Promise.reject(new Error('no remote')) }, + remoteSocketFactoryService: { _serviceBrand: undefined, register: () => ({ dispose: () => { } }), connect: () => Promise.reject(new Error('no factory')) }, + signService: { _serviceBrand: undefined, createNewMessage: () => Promise.resolve({ id: '', data: '' }), validate: () => Promise.resolve(true), sign: () => Promise.resolve('') }, + logService: new NullLogService(), + ipcLogger: null, +}; + +suite('TunnelProxy', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let proxy: TunnelProxy; + + setup(async () => { + proxy = new TunnelProxy(dummyOpts, new NullLogService()); + store.add(proxy); + await proxy.start(); + }); + + // --- Greeting --- + + test('rejects non-SOCKS5', async () => { + const s = await connectToProxy(proxy.localPort); + s.write(Buffer.from([0x04, 0x01, 0x00])); + await new Promise(r => s.on('close', r)); + s.destroy(); + }); + + test('rejects greeting with only auth methods', async () => { + const s = await connectToProxy(proxy.localPort); + s.write(Buffer.from([0x05, 0x01, 0x02])); + const resp = await readBytes(s, 2); + assert.strictEqual(resp[1], 0xFF); + s.destroy(); + }); + + test('selects no-auth', async () => { + const s = await connectToProxy(proxy.localPort); + const resp = await doGreeting(s); + assert.strictEqual(resp[0], 0x05); + assert.strictEqual(resp[1], 0x00); + s.destroy(); + }); + + // --- CONNECT (tunnel fails → 0x04 HostUnreachable) --- + + test('CONNECT IPv4', async () => { + const s = await connectToProxy(proxy.localPort); + await doGreeting(s); + s.write(buildConnectIPv4('192.168.1.1', 8080)); + const resp = await readBytes(s, 10); + assert.strictEqual(resp[0], 0x05); + assert.strictEqual(resp[1], 0x04); + s.destroy(); + }); + + test('CONNECT domain', async () => { + const s = await connectToProxy(proxy.localPort); + await doGreeting(s); + s.write(buildConnectDomain('example.com', 443)); + const resp = await readBytes(s, 10); + assert.strictEqual(resp[1], 0x04); + s.destroy(); + }); + + test('CONNECT IPv6', async () => { + const s = await connectToProxy(proxy.localPort); + await doGreeting(s); + s.write(buildConnectIPv6([0x2001, 0x0db8, 0, 0, 0, 0, 0, 1], 80)); + const resp = await readBytes(s, 10); + assert.strictEqual(resp[1], 0x04); + s.destroy(); + }); + + test('rejects BIND command', async () => { + const s = await connectToProxy(proxy.localPort); + await doGreeting(s); + const req = buildConnectIPv4('127.0.0.1', 80); + req[1] = 0x02; + s.write(req); + const resp = await readBytes(s, 10); + assert.strictEqual(resp[1], 0x07); + s.destroy(); + }); + + test('rejects unsupported address type', async () => { + const s = await connectToProxy(proxy.localPort); + await doGreeting(s); + s.write(Buffer.from([0x05, 0x01, 0x00, 0x05, 0x00, 0x00])); + const resp = await readBytes(s, 10); + assert.strictEqual(resp[1], 0x08); + s.destroy(); + }); + + // --- Lifecycle --- + + test('proxyUrl is socks5 on loopback', () => { + assert.ok(proxy.localPort > 0); + assert.strictEqual(proxy.proxyUrl, `socks5://127.0.0.1:${proxy.localPort}`); + }); + + test('concurrent connections', async () => { + const sockets = await Promise.all([connectToProxy(proxy.localPort), connectToProxy(proxy.localPort), connectToProxy(proxy.localPort)]); + for (const s of sockets) { + const resp = await doGreeting(s); + assert.strictEqual(resp[1], 0x00); + } + for (const s of sockets) { s.destroy(); } + }); +}); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index f2764417d6ead..f249406afb9d8 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -75,6 +75,7 @@ import '../workbench/services/integrity/electron-browser/integrityService.js'; import '../workbench/services/workingCopy/electron-browser/workingCopyBackupService.js'; import '../workbench/services/checksum/electron-browser/checksumService.js'; import '../platform/remote/electron-browser/sharedProcessTunnelService.js'; +import '../platform/tunnel/electron-browser/sharedProcessTunnelProxyService.js'; import '../workbench/services/tunnel/electron-browser/tunnelService.js'; import '../platform/diagnostics/electron-browser/diagnosticsService.js'; import '../platform/profiling/electron-browser/profilingService.js'; diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index a16c153f57eda..5676d5b1e2208 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -11,6 +11,10 @@ import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserV class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; + willUseRemoteProxy(): boolean { + return false; + } + async getOrCreateBrowserViewModel(_id: string): Promise { throw new Error('Integrated Browser is not available in web.'); } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 849efe3775c19..ed1d0dec9c3e2 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -33,6 +33,7 @@ import { IBrowserViewVisibilityEvent, IBrowserViewCertificateError, IElementData, + IBrowserSessionOptions, browserZoomDefaultIndex, browserZoomFactors } from '../../../../platform/browserView/common/browserView.js'; @@ -96,6 +97,9 @@ export const IBrowserViewWorkbenchService = createDecorator; readonly onWillDispose: Event; - initialize(create: boolean): Promise; + initialize(create: boolean, proxyUrl?: string): Promise; setInitialURL(url: string, title?: string, favicon?: string): void; layout(bounds: IBrowserViewBounds): Promise; @@ -233,6 +238,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _error: IBrowserViewLoadError | undefined = undefined; private _certificateError: IBrowserViewCertificateError | undefined = undefined; private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; + private _isRemoteSession: boolean = false; private _isEphemeral: boolean = false; private _zoomHost: string | undefined = undefined; private _sharedWithAgent: boolean = false; @@ -276,6 +282,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get error(): IBrowserViewLoadError | undefined { return this._error; } get certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get isRemoteSession(): boolean { return this._isRemoteSession; } get sharedWithAgent(): boolean { return this._sharedWithAgent; } get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } @@ -330,10 +337,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { * @param create Whether to create the browser view if it doesn't already exist. * @throws If the browser view doesn't exist and `create` is false, or if initialization fails */ - async initialize(create: boolean): Promise { - const dataStorageSetting = this.configurationService.getValue( + async initialize(create: boolean, proxyUrl?: string): Promise { + let dataStorage = this.configurationService.getValue( 'workbench.browser.dataStorage' - ) ?? BrowserViewStorageScope.Global; + ) ?? 'default'; // Wait for trust initialization before determining storage scope await this.workspaceTrustManagementService.workspaceTrustInitialized; @@ -341,12 +348,25 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && !this.workspaceTrustManagementService.isWorkspaceTrusted(); - // Always use ephemeral sessions for untrusted workspaces - const dataStorage = isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; + if (isWorkspaceUntrusted) { + // Always use ephemeral sessions for untrusted workspaces + dataStorage = BrowserViewStorageScope.Ephemeral; + } else if (dataStorage === 'default') { + // When proxying, default to workspace-scoped sessions + // to avoid polluting the global session with remote site data. + dataStorage = proxyUrl + ? BrowserViewStorageScope.Workspace + : BrowserViewStorageScope.Global; + } + + const sessionOptions: IBrowserSessionOptions = { + scope: dataStorage, + workspaceId: this.workspaceContextService.getWorkspace().id, + proxyUrl, + }; - const workspaceId = this.workspaceContextService.getWorkspace().id; const state = create - ? await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId) + ? await this.browserViewService.getOrCreateBrowserView(this.id, sessionOptions) : await this.browserViewService.getState(this.id); this._url = state.url; @@ -362,6 +382,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._error = state.lastError; this._certificateError = state.certificateError; this._storageScope = state.storageScope; + this._isRemoteSession = state.isRemoteSession; this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); this._browserZoomIndex = state.browserZoomIndex; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 4d4af6bf91e40..80881fcae94f8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -96,6 +96,14 @@ export abstract class BrowserEditorContribution extends Disposable { */ get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + /** + * Optional widgets to display before the URL input (between the + * navigation toolbar and the URL container). These are rendered + * inside the URL container on the left, before the site-info slot. + * Contributions can override this getter to provide widgets. + */ + get preUrlWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + /** * Optional toolbar-like elements to insert into the editor root between the navbar and the * browser container. Contributions can override this getter to provide elements. @@ -121,6 +129,7 @@ class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; private readonly _urlDisplay: HTMLElement; private readonly _siteInfoWidget: SiteInfoWidget; + private readonly _preUrlWidgetsContainer: HTMLElement; private readonly _urlBarWidgetsContainer: HTMLElement; constructor( @@ -185,6 +194,9 @@ class BrowserNavigationBar extends Disposable { this._urlBarWidgetsContainer = $('.browser-url-bar-widgets'); + this._preUrlWidgetsContainer = $('.browser-pre-url-widgets'); + + urlContainer.appendChild(this._preUrlWidgetsContainer); urlContainer.appendChild(siteInfoContainer); urlContainer.appendChild(urlInputWrapper); urlContainer.appendChild(this._urlBarWidgetsContainer); @@ -269,6 +281,16 @@ class BrowserNavigationBar extends Disposable { this._urlInput.focus(); } + /** + * Add widget elements before the URL input, sorted by order. + */ + addPreUrlWidgets(widgets: readonly IBrowserEditorWidgetContribution[]): void { + const sorted = widgets.slice().sort((a, b) => a.order - b.order); + for (const widget of sorted) { + this._preUrlWidgetsContainer.appendChild(widget.element); + } + } + /** * Add widget elements inside the URL bar, sorted by order. */ @@ -421,9 +443,12 @@ export class BrowserEditor extends EditorPane { // Inject URL bar widgets from contributions const allWidgets: IBrowserEditorWidgetContribution[] = []; + const allPreUrlWidgets: IBrowserEditorWidgetContribution[] = []; for (const contribution of this._contributionInstances.values()) { allWidgets.push(...contribution.urlBarWidgets); + allPreUrlWidgets.push(...contribution.preUrlWidgets); } + this._navigationBar.addPreUrlWidgets(allPreUrlWidgets); this._navigationBar.addUrlBarWidgets(allWidgets); root.appendChild(navbar); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index be1e6c7d1cb58..88ab09dd8a167 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -28,6 +28,7 @@ import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; import './features/browserEditorFindFeature.js'; import './features/browserTabManagementFeatures.js'; +import './features/browserRemoteFeatures.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index d189013b3adb7..ea5fd66a5ddc4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -11,7 +11,12 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { ISharedProcessTunnelProxyService } from '../../../../platform/tunnel/common/sharedProcessTunnelProxyService.js'; +import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; /** Command IDs whose accelerators are shown in browser view context menus. */ const browserViewContextMenuCommands = [ @@ -25,12 +30,18 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV private readonly _browserViewService: IBrowserViewService; private readonly _models = new Map(); + private _remoteProxyPromise: Promise | undefined; constructor( @IMainProcessService mainProcessService: IMainProcessService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @ISharedProcessTunnelProxyService private readonly tunnelProxyService: ISharedProcessTunnelProxyService, + @IRemoteAuthorityResolverService private readonly remoteAuthorityResolverService: IRemoteAuthorityResolverService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); @@ -38,6 +49,64 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV this.sendKeybindings(); this._register(this.keybindingService.onDidUpdateKeybindings(() => this.sendKeybindings())); + this._register(toDisposable(() => { + const authority = this.environmentService.remoteAuthority; + if (this._remoteProxyPromise && authority) { + void this.tunnelProxyService.stop(authority).catch(err => this.logService.error('[BrowserViewWorkbenchService] Failed to stop tunnel proxy:', err)); + } + })); + } + + willUseRemoteProxy(): boolean { + if (!this.environmentService.remoteAuthority) { + return false; + } + if (!this.configurationService.getValue('workbench.browser.enableRemoteProxy')) { + return false; + } + return true; + } + + private async _getRemoteProxy(): Promise { + if (!this.willUseRemoteProxy()) { + return undefined; + } + if (!this._remoteProxyPromise) { + this._remoteProxyPromise = this._startRemoteProxy(this.environmentService.remoteAuthority!); + } + return this._remoteProxyPromise; + } + + private async _startRemoteProxy(remoteAuthority: string): Promise { + try { + const proxyUrl = await this.tunnelProxyService.start(remoteAuthority); + this.logService.info(`[BrowserViewWorkbenchService] Tunnel proxy started for remote authority '${remoteAuthority}'`); + + // Push the resolved address to the proxy + const connectionData = this.remoteAuthorityResolverService.getConnectionData(remoteAuthority); + if (connectionData) { + await this.tunnelProxyService.setAddress(remoteAuthority, { + connectTo: connectionData.connectTo, + connectionToken: connectionData.connectionToken + }); + } + + // Keep address up to date on reconnections + this._register(this.remoteAuthorityResolverService.onDidChangeConnectionData(() => { + const data = this.remoteAuthorityResolverService.getConnectionData(remoteAuthority); + if (data) { + void this.tunnelProxyService.setAddress(remoteAuthority, { + connectTo: data.connectTo, + connectionToken: data.connectionToken + }).catch(err => this.logService.error('[BrowserViewWorkbenchService] Failed to update tunnel proxy address:', err)); + } + })); + + return proxyUrl; + } catch (err) { + this.logService.error('[BrowserViewWorkbenchService] Failed to start tunnel proxy:', err); + return undefined; + } } async getOrCreateBrowserViewModel(id: string): Promise { @@ -68,7 +137,8 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // Initialize the model with current state try { - await model.initialize(create); + const proxyUrl = create ? await this._getRemoteProxy() : undefined; + await model.initialize(create, proxyUrl); } catch (e) { this._models.delete(id); throw e; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts index d3ecac788fca5..4176b910aafdb 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -129,17 +129,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'workbench.browser.dataStorage': { type: 'string', enum: [ + 'default', BrowserViewStorageScope.Global, BrowserViewStorageScope.Workspace, BrowserViewStorageScope.Ephemeral ], markdownEnumDescriptions: [ - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.default' }, '`global` for local workspaces, `workspace` for remote workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces. Incompatible with remote sessions.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') ], restricted: true, - default: BrowserViewStorageScope.Global, + default: 'default', markdownDescription: localize( { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts new file mode 100644 index 0000000000000..ea00d6590accf --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; + +class BrowserRemoteIndicatorContribution extends BrowserEditorContribution { + private readonly _container: HTMLElement; + + private _isRemoteConnected = false; + + constructor( + editor: BrowserEditor, + @IHoverService hoverService: IHoverService, + @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, + ) { + super(editor); + + this._container = $('.browser-remote-indicator'); + + const icon = renderIcon(Codicon.remote); + this._container.appendChild(icon); + + this._register(hoverService.setupDelayedHover( + this._container, + () => ({ + content: this._isRemoteConnected + ? localize('browser.remoteSession', "Connected via remote") + : localize('browser.remoteSessionDisconnected', "Connected locally"), + }) + )); + + this.setRemoteConnected(false); + } + + override get preUrlWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._container, order: 0 }]; + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this.setRemoteConnected(model.isRemoteSession && !model.url.startsWith('file://')); + if (model.isRemoteSession) { + this._register(model.onDidNavigate((event) => { + this.setRemoteConnected(!event.url.startsWith('file://')); + })); + } + } + + override clear(): void { + this.setRemoteConnected(false); + } + + private setRemoteConnected(isConnected: boolean): void { + this._isRemoteConnected = isConnected; + this._container.classList.toggle('connected', isConnected); + + // Always display the icon in remote workspaces -- just update the state based on whether we're actually serving via remote. + this._container.style.display = isConnected || this.browserViewWorkbenchService.willUseRemoteProxy() ? '' : 'none'; + } +} + +BrowserEditor.registerContribution(BrowserRemoteIndicatorContribution); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.enableRemoteProxy': { + type: 'boolean', + default: false, + tags: ['advanced', 'experimental'], + scope: ConfigurationScope.WINDOW, + markdownDescription: localize('browser.enableRemoteProxy', "When enabled, browser requests in remote workspaces are proxied through the remote connection. This allows web pages to access resources available on the remote host."), + } + } +}); + diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 2f0c1db0f6c0c..5a283181a61fb 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -35,6 +35,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { match } from '../../../../../base/common/glob.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); @@ -511,18 +512,24 @@ class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchCo @IOpenerService openerService: IOpenerService, @IConfigurationService private readonly configurationService: IConfigurationService, @IEditorService private readonly editorService: IEditorService, - @ITelemetryService private readonly telemetryService: ITelemetryService + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, ) { super(); this._register(openerService.registerExternalOpener(this)); } - async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { + async openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { return false; } + // If we are in a remote session, always use the original source URI (and not the href which may be the forwarded address) + if (this.browserViewWorkbenchService.willUseRemoteProxy() && ctx.sourceUri) { + href = ctx.sourceUri.toString(); + } + try { const parsed = new URL(href); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index ba7f6e9d8ecb1..77a8703f928f1 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -57,7 +57,7 @@ .browser-url-container { flex: 1; display: flex; - align-items: center; + align-items: stretch; min-width: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); @@ -68,6 +68,32 @@ } } + .browser-pre-url-widgets { + display: flex; + flex-shrink: 0; + } + + .browser-remote-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + font-size: 12px; + white-space: nowrap; + background-color: var(--vscode-editorWidget-background); + color: var(--vscode-disabledForeground); + border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); + + &.connected { + background-color: var(--vscode-statusBarItem-remoteBackground); + color: var(--vscode-statusBarItem-remoteForeground); + } + + .codicon { + color: inherit; + } + } + .browser-url-input { flex: 1; min-width: 0; diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 8ecb4d298e49c..e907f4490bfee 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -76,6 +76,7 @@ import './services/integrity/electron-browser/integrityService.js'; import './services/workingCopy/electron-browser/workingCopyBackupService.js'; import './services/checksum/electron-browser/checksumService.js'; import '../platform/remote/electron-browser/sharedProcessTunnelService.js'; +import '../platform/tunnel/electron-browser/sharedProcessTunnelProxyService.js'; import './services/tunnel/electron-browser/tunnelService.js'; import '../platform/diagnostics/electron-browser/diagnosticsService.js'; import '../platform/profiling/electron-browser/profilingService.js';