diff --git a/code/extensions/che-port/package.json b/code/extensions/che-port/package.json index 0f1cd72441b7..febb49d5c7ab 100644 --- a/code/extensions/che-port/package.json +++ b/code/extensions/che-port/package.json @@ -13,7 +13,13 @@ "Other" ], "activationEvents": [ - "*" + "*", + "onTunnel" + ], + "enabledApiProposals": [ + "tunnels", + "tunnelFactory", + "resolvers" ], "capabilities": { "virtualWorkspaces": true, diff --git a/code/extensions/che-port/src/extension.ts b/code/extensions/che-port/src/extension.ts index 5e6a410f48c6..a85194612828 100644 --- a/code/extensions/che-port/src/extension.ts +++ b/code/extensions/che-port/src/extension.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (c) 2022-2025 Red Hat, Inc. + * Copyright (c) 2022-2026 Red Hat, Inc. * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -13,8 +13,11 @@ import * as vscode from 'vscode'; import { PortsPlugin } from './ports-plugin'; +import { CheTunnelProvider } from './tunnel-provider'; let portsPlugin: PortsPlugin | undefined; +let tunnelProvider: CheTunnelProvider | undefined; +let tunnelProviderDisposable: vscode.Disposable | undefined; export async function activate(context: vscode.ExtensionContext): Promise { // if not in a che context, do nothing @@ -23,11 +26,40 @@ export async function activate(context: vscode.ExtensionContext): Promise } portsPlugin = new PortsPlugin(context); - return portsPlugin.start(); + await portsPlugin.start(); + + // Register tunnel provider for localhost port forwarding + // This enables extensions like GitLab Duo that use webviews with localhost servers + tunnelProvider = new CheTunnelProvider(portsPlugin); + + try { + tunnelProviderDisposable = await vscode.workspace.registerTunnelProvider(tunnelProvider, { + tunnelFeatures: { + elevation: false, + privacyOptions: [ + { id: 'public', label: 'Public', themeIcon: 'globe' } + ], + protocol: true + } + }); + context.subscriptions.push(tunnelProviderDisposable); + console.log('[che-port] Tunnel provider registered successfully'); + } catch (error) { + console.error('[che-port] Failed to register tunnel provider:', error); + } } export function deactivate(): void { + if (tunnelProvider) { + tunnelProvider.dispose(); + tunnelProvider = undefined; + } + if (tunnelProviderDisposable) { + tunnelProviderDisposable.dispose(); + tunnelProviderDisposable = undefined; + } if (portsPlugin) { portsPlugin.stop(); + portsPlugin = undefined; } } diff --git a/code/extensions/che-port/src/ports-plugin.ts b/code/extensions/che-port/src/ports-plugin.ts index c0531b2230ff..012d6a077ed1 100644 --- a/code/extensions/che-port/src/ports-plugin.ts +++ b/code/extensions/che-port/src/ports-plugin.ts @@ -21,6 +21,7 @@ import { EndpointsTreeDataProvider } from './endpoints-tree-data-provider'; import { ListeningPort } from './listening-port'; import { PortChangesDetector } from './port-changes-detector'; import { PortForwardServer } from './port-forward-server'; +import { RedirectPortManager } from './tunnel-provider'; /** * Plugin that is monitoring new port being opened and closed. @@ -35,7 +36,7 @@ export interface ForwardedPort { endpoint: Endpoint; } -export class PortsPlugin { +export class PortsPlugin implements RedirectPortManager { // constants public static readonly LISTEN_ALL_IPV4 = '0.0.0.0'; public static readonly LISTEN_ALL_IPV6 = '::'; @@ -299,4 +300,39 @@ export class PortsPlugin { } async stop(): Promise { } + + // RedirectPortManager interface implementation + + /** + * Acquire an available code-redirect endpoint for tunnel provider. + * This is used by CheTunnelProvider to get a redirect slot. + */ + acquireRedirectEndpoint(): Endpoint | undefined { + if (this.redirectPorts.length === 0) { + return undefined; + } + return this.redirectPorts.pop(); + } + + /** + * Release a previously acquired redirect endpoint back to the pool. + * Called when a tunnel is disposed. + */ + releaseRedirectEndpoint(endpoint: Endpoint): void { + // Only add back if it's a valid redirect endpoint and not already in the pool + if (endpoint.name.startsWith(PortsPlugin.SERVER_REDIRECT_PATTERN)) { + const alreadyExists = this.redirectPorts.some(e => e.targetPort === endpoint.targetPort); + if (!alreadyExists) { + this.redirectPorts.push(endpoint); + this.outputChannel.appendLine(`[PortsPlugin] Released redirect endpoint ${endpoint.name} back to pool`); + } + } + } + + /** + * Get the output channel for logging from tunnel provider. + */ + getOutputChannel(): vscode.OutputChannel { + return this.outputChannel; + } } diff --git a/code/extensions/che-port/src/tunnel-provider.ts b/code/extensions/che-port/src/tunnel-provider.ts new file mode 100644 index 000000000000..f6412e0fd2e6 --- /dev/null +++ b/code/extensions/che-port/src/tunnel-provider.ts @@ -0,0 +1,162 @@ +/********************************************************************** + * Copyright (c) 2026 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ***********************************************************************/ + +/* eslint-disable header/header */ + +import * as vscode from 'vscode'; +import { Endpoint } from './endpoint'; +import { PortForwardServer } from './port-forward-server'; + +/** + * Represents an active tunnel created by CheTunnelProvider. + * Implements vscode.Tunnel interface for VS Code's tunnel management. + */ +class CheTunnel implements vscode.Tunnel { + private readonly onDidDisposeEmitter = new vscode.EventEmitter(); + readonly onDidDispose = this.onDidDisposeEmitter.event; + + constructor( + public readonly remoteAddress: { port: number; host: string }, + public readonly localAddress: string, + public readonly privacy: string, + public readonly protocol: string, + private readonly portForwardServer: PortForwardServer, + private readonly onDispose: () => void + ) {} + + dispose(): void { + this.portForwardServer.stop(); + this.onDispose(); + this.onDidDisposeEmitter.fire(); + this.onDidDisposeEmitter.dispose(); + } +} + +/** + * Interface for accessing redirect ports from PortsPlugin. + */ +export interface RedirectPortManager { + /** + * Get an available code-redirect endpoint. + * Returns undefined if no redirect slots are available. + */ + acquireRedirectEndpoint(): Endpoint | undefined; + + /** + * Release a previously acquired redirect endpoint. + */ + releaseRedirectEndpoint(endpoint: Endpoint): void; + + /** + * Get the output channel for logging. + */ + getOutputChannel(): vscode.OutputChannel; +} + +/** + * TunnelProvider implementation for Eclipse Che that uses code-redirect endpoints + * to expose localhost ports to the browser. + * + * This enables extensions like GitLab Duo that use localhost servers for webviews + * to work in browser-based VS Code environments. + */ +export class CheTunnelProvider implements vscode.TunnelProvider { + private activeTunnels: Map = new Map(); + + constructor(private readonly redirectPortManager: RedirectPortManager) {} + + /** + * Provides a tunnel for the requested port by: + * 1. Acquiring an available code-redirect endpoint + * 2. Starting a TCP proxy from the redirect port to the target localhost port + * 3. Returning the public URL of the redirect endpoint + */ + async provideTunnel( + tunnelOptions: vscode.TunnelOptions, + _tunnelCreationOptions: vscode.TunnelCreationOptions, + _token: vscode.CancellationToken + ): Promise { + const targetPort = tunnelOptions.remoteAddress.port; + const targetHost = tunnelOptions.remoteAddress.host; + const outputChannel = this.redirectPortManager.getOutputChannel(); + + outputChannel.appendLine(`[TunnelProvider] Tunnel requested for ${targetHost}:${targetPort}`); + + // Check if we already have a tunnel for this port + if (this.activeTunnels.has(targetPort)) { + outputChannel.appendLine(`[TunnelProvider] Reusing existing tunnel for port ${targetPort}`); + return this.activeTunnels.get(targetPort); + } + + // Acquire a free code-redirect endpoint + const redirectEndpoint = this.redirectPortManager.acquireRedirectEndpoint(); + if (!redirectEndpoint) { + outputChannel.appendLine(`[TunnelProvider] No free code-redirect slots available for port ${targetPort}`); + return undefined; + } + + outputChannel.appendLine( + `[TunnelProvider] Using ${redirectEndpoint.name} (port ${redirectEndpoint.targetPort}) for tunnel to ${targetPort}` + ); + + // Start TCP proxy from redirect port to target port + const portForwardServer = new PortForwardServer( + redirectEndpoint.targetPort, + targetHost === '127.0.0.1' || targetHost === '0.0.0.0' ? 'localhost' : targetHost, + targetPort + ); + + try { + await portForwardServer.start(); + } catch (error) { + outputChannel.appendLine(`[TunnelProvider] Failed to start port forward: ${error}`); + this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint); + return undefined; + } + + // Get the public URL for the redirect endpoint + const publicUrl = redirectEndpoint.url; + if (!publicUrl) { + outputChannel.appendLine(`[TunnelProvider] Redirect endpoint ${redirectEndpoint.name} has no URL`); + portForwardServer.stop(); + this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint); + return undefined; + } + + outputChannel.appendLine(`[TunnelProvider] Tunnel created: localhost:${targetPort} -> ${publicUrl}`); + + // Create the tunnel object + const tunnel = new CheTunnel( + { port: targetPort, host: targetHost }, + publicUrl, + 'public', + tunnelOptions.protocol || 'http', + portForwardServer, + () => { + this.activeTunnels.delete(targetPort); + this.redirectPortManager.releaseRedirectEndpoint(redirectEndpoint); + outputChannel.appendLine(`[TunnelProvider] Tunnel disposed for port ${targetPort}`); + } + ); + + this.activeTunnels.set(targetPort, tunnel); + return tunnel; + } + + /** + * Dispose all active tunnels. + */ + dispose(): void { + for (const tunnel of this.activeTunnels.values()) { + tunnel.dispose(); + } + this.activeTunnels.clear(); + } +} diff --git a/code/extensions/che-port/tsconfig.json b/code/extensions/che-port/tsconfig.json index 0c7b3744d3a7..24ef01fb7307 100644 --- a/code/extensions/che-port/tsconfig.json +++ b/code/extensions/che-port/tsconfig.json @@ -13,5 +13,8 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.tunnels.d.ts", + "../../src/vscode-dts/vscode.proposed.tunnelFactory.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts" ] } diff --git a/code/src/vs/workbench/contrib/url/browser/cheTunnelResolver.ts b/code/src/vs/workbench/contrib/url/browser/cheTunnelResolver.ts new file mode 100644 index 000000000000..2a984613b4b6 --- /dev/null +++ b/code/src/vs/workbench/contrib/url/browser/cheTunnelResolver.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the EPL-2.0 License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { extractLocalHostUriMetaDataForPortMapping, ITunnelService, RemoteTunnel } from '../../../../platform/tunnel/common/tunnel.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; + +/** + * Contribution that enables localhost port forwarding in browser context. + * This bridges asExternalUri() calls to the TunnelService, allowing extensions + * like GitLab Duo to have their localhost webviews forwarded to public URLs. + */ +export class CheTunnelResolverContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.cheTunnelResolver'; + + private readonly activeTunnels = new Map(); + + constructor( + @IOpenerService openerService: IOpenerService, + @ITunnelService private readonly tunnelService: ITunnelService, + @ILogService private readonly logService: ILogService, + @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, + ) { + super(); + + // Only activate in Che/DevWorkspace environment + if (!this.isDevWorkspaceEnvironment()) { + return; + } + + // Don't override if embedder already provided resolveExternalUri + if (environmentService.options?.resolveExternalUri) { + this.logService.debug('[CheTunnelResolver] Embedder resolveExternalUri already provided, skipping'); + return; + } + + this.logService.info('[CheTunnelResolver] Registering external URI resolver for localhost port forwarding'); + + this._register(openerService.registerExternalUriResolver({ + resolveExternalUri: async (resource, options) => { + return this.resolveExternalUri(resource, options); + } + })); + } + + private isDevWorkspaceEnvironment(): boolean { + // Check for DevWorkspace environment indicators + // The actual env vars are on the server, but we can check the window location + // or rely on che-specific configuration + return typeof window !== 'undefined' && + (window.location.hostname.includes('che') || + window.location.hostname.includes('devspaces') || + window.location.hostname.includes('codeready') || + document.querySelector('meta[name="che-workspace"]') !== null || + // Check if che-port extension is likely available + this.tunnelService.hasTunnelProvider); + } + + private async resolveExternalUri(resource: URI, _options?: { readonly allowTunneling?: boolean }): Promise<{ resolved: URI; dispose(): void } | undefined> { + const portMapping = extractLocalHostUriMetaDataForPortMapping(resource); + + if (!portMapping) { + // Not a localhost URI, don't handle + return undefined; + } + + this.logService.info(`[CheTunnelResolver] Resolving localhost URI: ${resource.toString()}`); + + // Check if we have a tunnel provider + if (!this.tunnelService.hasTunnelProvider) { + this.logService.warn('[CheTunnelResolver] No tunnel provider available'); + return undefined; + } + + const tunnelKey = `${portMapping.address}:${portMapping.port}`; + + // Check for existing tunnel + const existingTunnel = this.activeTunnels.get(tunnelKey); + if (existingTunnel) { + this.logService.debug(`[CheTunnelResolver] Reusing existing tunnel for ${tunnelKey}`); + const resolved = this.buildResolvedUri(resource, existingTunnel); + return { + resolved, + dispose: () => { /* Keep tunnel alive for reuse */ } + }; + } + + try { + // Open a new tunnel + this.logService.info(`[CheTunnelResolver] Opening tunnel for ${portMapping.address}:${portMapping.port}`); + + const tunnel = await this.tunnelService.openTunnel( + undefined, // addressProvider + portMapping.address, + portMapping.port, + undefined, // localHost + undefined, // localPort + false, // elevateIfNeeded + 'public', // privacy + 'http' // protocol + ); + + if (!tunnel) { + this.logService.warn(`[CheTunnelResolver] Failed to open tunnel for ${tunnelKey}`); + return undefined; + } + + if (typeof tunnel === 'string') { + // Error message + this.logService.error(`[CheTunnelResolver] Tunnel error: ${tunnel}`); + return undefined; + } + + this.logService.info(`[CheTunnelResolver] Tunnel opened: ${tunnelKey} -> ${tunnel.localAddress}`); + + // Store for reuse + this.activeTunnels.set(tunnelKey, tunnel); + + const resolved = this.buildResolvedUri(resource, tunnel); + + return { + resolved, + dispose: () => { + // Don't dispose immediately - keep tunnel for potential reuse + // The tunnel will be disposed when the contribution is disposed + } + }; + } catch (error) { + this.logService.error(`[CheTunnelResolver] Error opening tunnel: ${error}`); + return undefined; + } + } + + private buildResolvedUri(original: URI, tunnel: RemoteTunnel): URI { + const localAddress = tunnel.localAddress; + + // localAddress can be a full URL like "https://code-redirect-1.workspace.example.com" + // or just "host:port" + if (localAddress.startsWith('http://') || localAddress.startsWith('https://')) { + const tunnelUri = URI.parse(localAddress); + // Preserve original path and query + return tunnelUri.with({ + path: original.path, + query: original.query, + fragment: original.fragment + }); + } + + // If it's just host:port, construct the URI + return original.with({ authority: localAddress }); + } + + override dispose(): void { + // Clean up all tunnels + for (const [key, tunnel] of this.activeTunnels) { + this.logService.debug(`[CheTunnelResolver] Disposing tunnel: ${key}`); + tunnel.dispose(); + } + this.activeTunnels.clear(); + super.dispose(); + } +} diff --git a/code/src/vs/workbench/contrib/url/browser/url.contribution.ts b/code/src/vs/workbench/contrib/url/browser/url.contribution.ts index dd994c8c155d..096657a508a8 100644 --- a/code/src/vs/workbench/contrib/url/browser/url.contribution.ts +++ b/code/src/vs/workbench/contrib/url/browser/url.contribution.ts @@ -13,6 +13,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IURLService } from '../../../../platform/url/common/url.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { ExternalUriResolverContribution } from './externalUriResolver.js'; +import { CheTunnelResolverContribution } from './cheTunnelResolver.js'; import { manageTrustedDomainSettingsCommand } from './trustedDomains.js'; import { TrustedDomainsFileSystemProvider } from './trustedDomainsFileSystemProvider.js'; import { OpenerValidatorContributions } from './trustedDomainsValidator.js'; @@ -82,6 +83,11 @@ registerWorkbenchContribution2( ExternalUriResolverContribution, WorkbenchPhase.BlockRestore // registration only ); +registerWorkbenchContribution2( + CheTunnelResolverContribution.ID, + CheTunnelResolverContribution, + WorkbenchPhase.BlockRestore // registration only +); const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration);