Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Emitter, Event } from '../../../../base/common/event.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js';
9
import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
10
import type { IProtocolTransport } from '../../../../platform/agentHost/common/state/sessionTransport.js';
11
import type { ProtocolMessage, AhpServerNotification, JsonRpcResponse } from '../../../../platform/agentHost/common/state/sessionProtocol.js';
12
import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../platform/agentHost/common/transportConstants.js';
13
import {
14
ITunnelAgentHostService,
15
TUNNEL_ADDRESS_PREFIX,
16
TUNNEL_MIN_PROTOCOL_VERSION,
17
TunnelTags,
18
type ICachedTunnel,
19
type ITunnelInfo,
20
} from '../../../../platform/agentHost/common/tunnelAgentHost.js';
21
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
22
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
23
import { ILogService } from '../../../../platform/log/common/log.js';
24
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
25
import type { IDiscoveredTunnel, ITunnelConnection, ITunnelDiscoveryProvider } from '../../../../workbench/browser/web.api.js';
26
import { IBrowserWorkbenchEnvironmentService } from '../../../../workbench/services/environment/browser/environmentService.js';
27
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
28
29
const LOG_PREFIX = '[WebTunnelAgentHost]';
30
31
/** Storage key for recently used tunnel cache. */
32
const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels';
33
34
/**
35
* Web (browser) implementation of {@link ITunnelAgentHostService}.
36
*
37
* Delegates to the embedder's {@link ITunnelDiscoveryProvider} (provided via
38
* `IWorkbenchConstructionOptions.tunnelDiscoveryProvider`) for:
39
* - **Discovery**: listing available agent host tunnels
40
* - **Relay address**: obtaining the WebSocket proxy URL for connecting
41
*
42
* This decouples VS Code core from any specific embedder (vscode.dev,
43
* github.dev, etc.). The embedder handles the actual Dev Tunnels API
44
* calls and relay proxying.
45
*/
46
export class WebTunnelAgentHostService extends Disposable implements ITunnelAgentHostService {
47
declare readonly _serviceBrand: undefined;
48
49
private readonly _onDidChangeTunnels = this._register(new Emitter<void>());
50
readonly onDidChangeTunnels: Event<void> = this._onDidChangeTunnels.event;
51
52
private readonly _discoveryProvider: ITunnelDiscoveryProvider | undefined;
53
54
constructor(
55
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
56
@IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService,
57
@ILogService private readonly _logService: ILogService,
58
@IInstantiationService private readonly _instantiationService: IInstantiationService,
59
@IConfigurationService private readonly _configurationService: IConfigurationService,
60
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
61
@IStorageService private readonly _storageService: IStorageService,
62
) {
63
super();
64
this._discoveryProvider = environmentService.options?.tunnelDiscoveryProvider;
65
if (!this._discoveryProvider) {
66
this._logService.debug(`${LOG_PREFIX} No tunnelDiscoveryProvider — tunnel discovery disabled`);
67
}
68
}
69
70
// Discovery
71
72
async listTunnels(options?: { silent?: boolean }): Promise<ITunnelInfo[]> {
73
if (!this._discoveryProvider) {
74
return [];
75
}
76
77
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
78
return [];
79
}
80
81
try {
82
// The embedder acquires tokens internally via its own auth flow
83
const discovered = await this._discoveryProvider.listTunnels();
84
const results: ITunnelInfo[] = [];
85
86
for (const tunnel of discovered) {
87
const info = this._toTunnelInfo(tunnel);
88
if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {
89
results.push(info);
90
}
91
}
92
93
this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);
94
return results;
95
} catch (err) {
96
this._logService.error(`${LOG_PREFIX} Failed to list tunnels`, err);
97
return [];
98
}
99
}
100
101
private _toTunnelInfo(tunnel: IDiscoveredTunnel): ITunnelInfo | undefined {
102
if (!tunnel.tunnelId || !tunnel.clusterId) {
103
return undefined;
104
}
105
106
const tags = new TunnelTags(tunnel.tags);
107
108
return {
109
tunnelId: tunnel.tunnelId,
110
clusterId: tunnel.clusterId,
111
name: tags.name || tunnel.name || tunnel.tunnelId,
112
tags: tunnel.tags as string[],
113
protocolVersion: tags.protocolVersion,
114
hostConnectionCount: tunnel.hostConnectionCount,
115
};
116
}
117
118
// Connection (via embedder)
119
120
async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise<void> {
121
if (!this._discoveryProvider) {
122
throw new Error('No tunnelDiscoveryProvider available');
123
}
124
125
const { tunnelId, clusterId } = tunnel;
126
this._logService.info(`${LOG_PREFIX} Connecting to tunnel '${tunnel.name}' (${tunnelId})`);
127
128
// The embedder handles the full connection including auth
129
const connection = await this._discoveryProvider.connect(tunnelId, clusterId);
130
131
// Derive connection token from tunnel ID (same convention as CLI and desktop)
132
const connectionToken = await deriveConnectionToken(tunnelId);
133
134
const transport = new TunnelConnectionTransport(connection, this._logService);
135
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;
136
const protocolClient = this._instantiationService.createInstance(
137
RemoteAgentHostProtocolClient, address, transport,
138
);
139
140
try {
141
await protocolClient.connect();
142
this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`);
143
144
await this._remoteAgentHostService.addManagedConnection({
145
name: tunnel.name,
146
connectionToken,
147
connection: {
148
type: RemoteAgentHostEntryType.Tunnel,
149
tunnelId,
150
clusterId,
151
label: tunnel.name,
152
authProvider,
153
},
154
}, protocolClient);
155
156
this._onDidChangeTunnels.fire();
157
} catch (err) {
158
protocolClient.dispose();
159
this._logService.error(`${LOG_PREFIX} Connection setup failed`, err);
160
throw err;
161
}
162
}
163
164
async disconnect(address: string): Promise<void> {
165
await this._remoteAgentHostService.removeRemoteAgentHost(address);
166
this._onDidChangeTunnels.fire();
167
}
168
169
// Auth
170
171
async getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> {
172
for (const provider of ['github', 'microsoft'] as const) {
173
const sessions = await this._authenticationService.getSessions(provider, undefined, {}, true);
174
if (sessions.length > 0) {
175
return provider;
176
}
177
}
178
return undefined;
179
}
180
181
// Tunnel cache
182
183
getCachedTunnels(): ICachedTunnel[] {
184
const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);
185
if (!raw) {
186
return [];
187
}
188
try {
189
return JSON.parse(raw);
190
} catch {
191
return [];
192
}
193
}
194
195
cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void {
196
const cached = this.getCachedTunnels();
197
const filtered = cached.filter(t => t.tunnelId !== tunnel.tunnelId);
198
filtered.unshift({
199
tunnelId: tunnel.tunnelId,
200
clusterId: tunnel.clusterId,
201
name: tunnel.name,
202
authProvider,
203
});
204
this._storeCachedTunnels(filtered.slice(0, 20));
205
this._onDidChangeTunnels.fire();
206
}
207
208
removeCachedTunnel(tunnelId: string): void {
209
const cached = this.getCachedTunnels();
210
this._storeCachedTunnels(cached.filter(t => t.tunnelId !== tunnelId));
211
this._onDidChangeTunnels.fire();
212
}
213
214
private _storeCachedTunnels(tunnels: ICachedTunnel[]): void {
215
if (tunnels.length === 0) {
216
this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);
217
} else {
218
this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER);
219
}
220
}
221
}
222
223
/**
224
* Adapts an {@link ITunnelConnection} (embedder-provided) into an
225
* {@link IProtocolTransport} for {@link RemoteAgentHostProtocolClient}.
226
*
227
* The connection is already established by the time this adapter is created,
228
* so there is no `connect()` method — the protocol client skips that step.
229
*/
230
class TunnelConnectionTransport extends Disposable implements IProtocolTransport {
231
private readonly _onMessage = this._register(new Emitter<ProtocolMessage>());
232
readonly onMessage = this._onMessage.event;
233
234
private readonly _onClose = this._register(new Emitter<void>());
235
readonly onClose = this._onClose.event;
236
237
private _malformedFrames = 0;
238
239
constructor(
240
private readonly _connection: ITunnelConnection,
241
private readonly _logService: ILogService,
242
) {
243
super();
244
this._register(_connection.onMessage((data: string) => {
245
let message: ProtocolMessage;
246
try {
247
message = JSON.parse(data) as ProtocolMessage;
248
} catch (err) {
249
this._malformedFrames++;
250
if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {
251
const preview = data.length > 80 ? data.slice(0, 80) + '…' : data;
252
this._logService.warn(
253
`[TunnelConnectionTransport] Malformed frame #${this._malformedFrames} (len=${data.length}): ${preview}`,
254
err instanceof Error ? err.message : String(err)
255
);
256
}
257
if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {
258
this._logService.warn(
259
'[TunnelConnectionTransport] Malformed frame threshold exceeded; forcing tunnel close.'
260
);
261
this._connection.close();
262
}
263
return;
264
}
265
this._onMessage.fire(message);
266
}));
267
this._register(_connection.onClose(() => {
268
this._onClose.fire();
269
}));
270
}
271
272
send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void {
273
this._connection.send(JSON.stringify(message));
274
}
275
276
override dispose(): void {
277
this._connection.close();
278
super.dispose();
279
}
280
}
281
282
/**
283
* Derive a connection token from a tunnel ID using the same convention
284
* as the VS Code CLI and the desktop shared-process service.
285
*/
286
async function deriveConnectionToken(tunnelId: string): Promise<string> {
287
const encoder = new TextEncoder();
288
const data = encoder.encode(tunnelId);
289
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
290
const hashArray = new Uint8Array(hashBuffer);
291
292
// Base64url encode (matches Node's createHash('sha256').digest('base64url'))
293
let result = btoa(String.fromCharCode(...hashArray))
294
.replace(/\+/g, '-')
295
.replace(/\//g, '_')
296
.replace(/=+$/, '');
297
298
if (result.startsWith('-')) {
299
result = 'a' + result;
300
}
301
return result;
302
}
303
304