diff --git a/remote/package-lock.json b/remote/package-lock.json index fd94c9d768c..297aea0a4ce 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.23", + "@github/copilot": "^1.0.4-0", + "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", @@ -44,6 +46,7 @@ "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", + "ws": "^8.19.0", "yauzl": "^3.0.0", "yazl": "^2.4.3" } @@ -77,6 +80,142 @@ "node": ">=18" } }, + "node_modules/@github/copilot": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz", + "integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==", + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.4", + "@github/copilot-darwin-x64": "1.0.4", + "@github/copilot-linux-arm64": "1.0.4", + "@github/copilot-linux-x64": "1.0.4", + "@github/copilot-win32-arm64": "1.0.4", + "@github/copilot-win32-x64": "1.0.4" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz", + "integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz", + "integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz", + "integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", + "integrity": "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.2", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz", + "integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz", + "integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@microsoft/1ds-core-js": { "version": "3.2.13", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-3.2.13.tgz", @@ -1455,6 +1594,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vscode-oniguruma": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", @@ -1482,6 +1630,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/remote/package.json b/remote/package.json index ec73a84fff0..9051c2a9a07 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,6 +4,8 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.23", + "@github/copilot": "^1.0.4-0", + "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", @@ -39,6 +41,7 @@ "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", + "ws": "^8.19.0", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index 3bd5c73a0af..2040037b4e4 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -17,6 +17,8 @@ import { join } from 'node:path'; // SEE https://nodejs.org/docs/latest/api/module.html#initialize const _specifierToUrl: Record = {}; +const _specifierToFormat: Record = {}; +const _nodeModulesPath: string[] = []; export async function initialize(injectPath: string): Promise { // populate mappings @@ -24,19 +26,40 @@ export async function initialize(injectPath: string): Promise { const injectPackageJSONPath = fileURLToPath(new URL('../package.json', pathToFileURL(injectPath))); const packageJSON = JSON.parse(String(await promises.readFile(injectPackageJSONPath))); + // Remember the node_modules root for subpath resolution + _nodeModulesPath.push(join(injectPackageJSONPath, `../node_modules`)); + for (const [name] of Object.entries(packageJSON.dependencies)) { try { const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`); - let { main } = JSON.parse(String(await promises.readFile(path))); + const pkgJson = JSON.parse(String(await promises.readFile(path))); + + // Determine the entry point: prefer exports["."].import for ESM, then main + let main: string | undefined; + if (pkgJson.exports?.['.']) { + const dotExport = pkgJson.exports['.']; + if (typeof dotExport === 'string') { + main = dotExport; + } else if (typeof dotExport === 'object' && dotExport !== null) { + main = dotExport.import ?? dotExport.default; + } + } + if (typeof main !== 'string') { + main = typeof pkgJson.main === 'string' ? pkgJson.main : undefined; + } if (!main) { main = 'index.js'; } - if (!main.endsWith('.js')) { + if (!main.endsWith('.js') && !main.endsWith('.mjs') && !main.endsWith('.cjs')) { main += '.js'; } const mainPath = join(injectPackageJSONPath, `../node_modules/${name}/${main}`); _specifierToUrl[name] = pathToFileURL(mainPath).href; + // Determine module format: .mjs is always ESM, .cjs always CJS, otherwise check type field + _specifierToFormat[name] = main.endsWith('.mjs') || pkgJson.type === 'module' ? 'module' + : main.endsWith('.cjs') ? 'commonjs' + : 'commonjs'; } catch (err) { console.error(name); @@ -52,12 +75,30 @@ export async function resolve(specifier: string | number, context: unknown, next const newSpecifier = _specifierToUrl[specifier]; if (newSpecifier !== undefined) { return { - format: 'commonjs', + format: _specifierToFormat[specifier] ?? 'commonjs', shortCircuit: true, url: newSpecifier }; } + // Handle subpath imports (e.g., 'vscode-jsonrpc/node') by resolving + // through the redirected node_modules directory. + if (_nodeModulesPath.length > 0 && typeof specifier === 'string' && !specifier.startsWith('.') && !specifier.startsWith('node:')) { + for (const nmPath of _nodeModulesPath) { + // Try resolving the specifier as a file inside node_modules + let candidate = join(nmPath, specifier); + if (!candidate.endsWith('.js')) { + candidate += '.js'; + } + try { + await promises.access(candidate); + return nextResolve(pathToFileURL(candidate).href, context); + } catch { + // not found, let next resolver handle it + } + } + } + // Defer to the next hook in the chain, which would be the // Node.js default resolve if this is the last user-specified loader. return nextResolve(specifier, context); diff --git a/src/server-main.ts b/src/server-main.ts index a589510cfc8..f5af9e32e6a 100644 --- a/src/server-main.ts +++ b/src/server-main.ts @@ -25,7 +25,7 @@ perf.mark('code/server/start'); // Do a quick parse to determine if a server or the cli needs to be started const parsedArgs = minimist(process.argv.slice(2), { boolean: ['start-server', 'list-extensions', 'print-ip-address', 'help', 'version', 'accept-server-license-terms', 'update-extensions'], - string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility'], + string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility', 'agent-host-port', 'agent-host-path'], alias: { help: 'h', version: 'v' } }); ['host', 'port', 'accept-server-license-terms'].forEach(e => { diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index bbdfdcdd578..fca07981888 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -8,12 +8,15 @@ import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.c import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; -import { AgentHostIpcChannels } from '../common/agentService.js'; +import { AgentHostIpcChannels, type AgentProvider } from '../common/agentService.js'; +import { SessionStatus } from '../common/state/sessionState.js'; import { AgentService } from './agentService.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; +import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js'; +import { WebSocketProtocolServer } from './webSocketTransport.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; -import { getLogLevel } from '../../log/common/log.js'; +import { getLogLevel, ILogService } from '../../log/common/log.js'; import { LogService } from '../../log/common/logService.js'; import { LoggerService } from '../../log/node/loggerService.js'; import { LoggerChannel } from '../../log/common/logIpc.js'; @@ -24,6 +27,8 @@ import { localize } from '../../../nls.js'; // Entry point for the agent host utility process. // Sets up IPC, logging, and registers agent providers (Copilot). +// When VSCODE_AGENT_HOST_PORT or VSCODE_AGENT_HOST_SOCKET_PATH env vars +// are set, also starts a WebSocket server for external clients. startAgentHost(); @@ -59,9 +64,81 @@ function startAgentHost(): void { const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + // Start WebSocket server for external clients if configured + startWebSocketServer(agentService, logService, disposables); + process.once('exit', () => { agentService.dispose(); logService.dispose(); disposables.dispose(); }); } + +/** + * When the parent process passes WebSocket configuration via environment + * variables, start a protocol server that external clients can connect to. + * This reuses the same {@link AgentService} and {@link SessionStateManager} + * that the IPC channel uses, so both IPC and WebSocket clients share state. + */ +async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore): Promise { + const port = process.env['VSCODE_AGENT_HOST_PORT']; + const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; + + if (!port && !socketPath) { + return; + } + + const connectionToken = process.env['VSCODE_AGENT_HOST_CONNECTION_TOKEN']; + const host = process.env['VSCODE_AGENT_HOST_HOST'] || 'localhost'; + + const wsServer = disposables.add(await WebSocketProtocolServer.create( + socketPath + ? { + socketPath, + connectionTokenValidate: connectionToken + ? (token) => token === connectionToken + : undefined, + } + : { + port: parseInt(port!, 10), + host, + connectionTokenValidate: connectionToken + ? (token) => token === connectionToken + : undefined, + }, + logService, + )); + + // Create a side-effect handler that delegates to AgentService + const sideEffects: IProtocolSideEffectHandler = { + handleAction(action) { + agentService.dispatchAction(action, 'ws-server', 0); + }, + async handleCreateSession(command) { + await agentService.createSession({ + provider: command.provider as AgentProvider | undefined, + model: command.model, + workingDirectory: command.workingDirectory, + }); + }, + handleDisposeSession(session) { + agentService.disposeSession(session); + }, + async handleListSessions() { + const sessions = await agentService.listSessions(); + return sessions.map(s => ({ + resource: s.session, + provider: '' as AgentProvider, + title: s.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + })); + }, + }; + + disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + + const listenTarget = socketPath ?? `${host}:${port}`; + logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index a265d4a9dc4..662e1b2f98f 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -7,7 +7,6 @@ // Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] import { DisposableStore } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; @@ -20,17 +19,11 @@ import { IProductService } from '../../product/common/productService.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; -import { AgentSession, type AgentProvider, type IAgent } from '../common/agentService.js'; -import { SessionStateManager } from './sessionStateManager.js'; +import { type AgentProvider } from '../common/agentService.js'; +import { SessionStatus } from '../common/state/sessionState.js'; +import { AgentService } from './agentService.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js'; -import { mapProgressEventToAction } from './agentEventMapper.js'; -import { - ISessionModelInfo, - SessionStatus, type ISessionSummary -} from '../common/state/sessionState.js'; -import type { ISessionAction } from '../common/state/sessionActions.js'; -import type { ICreateSessionParams } from '../common/state/sessionProtocol.js'; // ---- Options ---------------------------------------------------------------- @@ -52,7 +45,7 @@ function parseServerOptions(): IServerOptions { // ---- Main ------------------------------------------------------------------- -function main(): void { +async function main(): Promise { const options = parseServerOptions(); const disposables = new DisposableStore(); @@ -77,51 +70,10 @@ function main(): void { logService.info('[AgentHostServer] Starting standalone agent host server'); - // Create state manager - const stateManager = disposables.add(new SessionStateManager(logService)); - - // Agent registry — maps provider id to agent instance - const agents = new Map(); - - function registerAgent(agent: IAgent): void { - agents.set(agent.id, agent); - disposables.add(agent.onDidSessionProgress(e => { - const turnId = stateManager.getActiveTurnId(e.session); - if (turnId) { - const action = mapProgressEventToAction(e, e.session, turnId); - if (action) { - stateManager.dispatchServerAction(action); - } - } - })); - // Publish agent to root state (models fetched async) - publishAgentsToRootState(); - logService.info(`[AgentHostServer] Registered agent: ${agent.id}`); - } - - async function publishAgentsToRootState(): Promise { - const agentInfos = await Promise.all([...agents.values()].map(async a => { - const d = a.getDescriptor(); - let models: ISessionModelInfo[]; - try { - const rawModels = await a.listModels(); - models = rawModels.map(m => ({ - id: m.id, provider: m.provider, name: m.name, - maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, - policyState: m.policyState, - })); - } catch { - models = []; - } - return { provider: d.provider, displayName: d.displayName, description: d.description, models }; - })); - stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: agentInfos }); - } - - function getAgent(session: URI): IAgent | undefined { - const provider = AgentSession.provider(session); - return provider ? agents.get(provider) : agents.values().next().value; - } + // Create agent service — handles agent registration, session lifecycle, + // event mapping, and state management (reuses the same logic as the + // utility-process entry point in agentHostMain.ts). + const agentService = disposables.add(new AgentService(logService)); // Register agents if (!options.quiet) { @@ -135,121 +87,52 @@ function main(): void { services.set(ILogService, logService); const instantiationService = new InstantiationService(services); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); - registerAgent(copilotAgent); + agentService.registerProvider(copilotAgent); } if (options.enableMockAgent) { // Dynamic import to avoid bundling test code in production import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { const mockAgent = disposables.add(new ScriptedMockAgent()); - registerAgent(mockAgent); + agentService.registerProvider(mockAgent); }).catch(err => { logService.error('[AgentHostServer] Failed to load mock agent', err); }); } // WebSocket server - const wsServer = disposables.add(new WebSocketProtocolServer(options.port, logService)); + const wsServer = disposables.add(await WebSocketProtocolServer.create(options.port, logService)); - // Side-effect handler — routes to the correct agent based on session URI + // Side-effect handler — delegates to AgentService for all orchestration const sideEffects: IProtocolSideEffectHandler = { - handleAction(action: ISessionAction): void { - switch (action.type) { - case 'session/turnStarted': { - const agent = getAgent(action.session); - if (!agent) { - stateManager.dispatchServerAction({ - type: 'session/error', - session: action.session, - turnId: action.turnId, - error: { errorType: 'noAgent', message: 'No agent found for session' }, - }); - return; - } - const attachments = action.userMessage.attachments?.map(a => ({ - type: a.type, - path: a.path, - displayName: a.displayName, - })); - agent.sendMessage(action.session, action.userMessage.text, attachments).catch(err => { - logService.error('[AgentHostServer] sendMessage failed', err); - stateManager.dispatchServerAction({ - type: 'session/error', - session: action.session, - turnId: action.turnId, - error: { errorType: 'sendFailed', message: String(err) }, - }); - }); - break; - } - case 'session/permissionResolved': { - const agent = getAgent(action.session); - agent?.respondToPermissionRequest(action.requestId, action.approved); - break; - } - case 'session/turnCancelled': { - const agent = getAgent(action.session); - agent?.abortSession(action.session).catch(() => { }); - break; - } - case 'session/modelChanged': { - const agent = getAgent(action.session); - agent?.changeModel?.(action.session, action.model).catch(err => { - logService.error('[AgentHostServer] changeModel failed', err); - }); - break; - } - } + handleAction(action) { + agentService.dispatchAction(action, 'ws-server', 0); }, - async handleCreateSession(command: ICreateSessionParams): Promise { - const provider = (command.provider ?? agents.keys().next().value) as AgentProvider; - const agent = agents.get(provider); - if (!agent) { - throw new Error(`No agent registered for provider: ${provider}`); - } - const session = await agent.createSession({ - provider, + async handleCreateSession(command) { + await agentService.createSession({ + provider: command.provider as AgentProvider | undefined, model: command.model, workingDirectory: command.workingDirectory, }); - const summary: ISessionSummary = { - resource: session, - provider, - title: 'Session', + }, + handleDisposeSession(session) { + agentService.disposeSession(session); + }, + async handleListSessions() { + const sessions = await agentService.listSessions(); + return sessions.map(s => ({ + resource: s.session, + provider: '' as AgentProvider, + title: s.summary ?? 'Session', status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - }; - stateManager.createSession(summary); - stateManager.dispatchServerAction({ type: 'session/ready', session }); - }, - handleDisposeSession(session: URI): void { - const agent = getAgent(session); - agent?.disposeSession(session).catch(() => { }); - stateManager.removeSession(session); - }, - async handleListSessions(): Promise { - const allSessions: ISessionSummary[] = []; - for (const agent of agents.values()) { - const sessions = await agent.listSessions(); - const provider = agent.id; - for (const s of sessions) { - allSessions.push({ - resource: s.session, - provider, - title: s.summary ?? 'Session', - status: SessionStatus.Idle, - createdAt: s.startTime, - modifiedAt: s.modifiedTime, - }); - } - } - return allSessions; + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + })); }, }; // Wire up protocol handler - disposables.add(new ProtocolServerHandler(stateManager, wsServer, sideEffects, logService)); + disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); // Report ready const address = wsServer.address; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 02bc38f51ce..e0b3341b980 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -36,6 +36,9 @@ export class AgentService extends Disposable implements IAgentService { /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: SessionStateManager; + /** Exposes the state manager for co-hosting a WebSocket protocol server. */ + get stateManager(): SessionStateManager { return this._stateManager; } + /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ @@ -228,6 +231,13 @@ export class AgentService extends Disposable implements IAgentService { } break; } + case 'session/modelChanged': { + const provider = this._findProviderForSession(action.session); + provider?.changeModel?.(action.session, action.model).catch(err => { + this._logService.error(`[AgentService] changeModel failed for session/modelChanged`, err); + }); + break; + } } } diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 0a9678f35ad..fec30ea5754 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { Client, IIPCOptions } from '../../../base/parts/ipc/node/ipc.cp.js'; @@ -10,26 +11,77 @@ import { IEnvironmentService, INativeEnvironmentService } from '../../environmen import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; +/** + * Options for configuring the agent host WebSocket server in the child process. + * When set, the agent host exposes a WebSocket endpoint for external clients. + */ +export interface IAgentHostWebSocketConfig { + /** TCP port to listen on. Mutually exclusive with `socketPath`. */ + readonly port?: string; + /** Unix domain socket / named pipe path. Takes precedence over `port`. */ + readonly socketPath?: string; + /** Host/IP to bind to. */ + readonly host?: string; + /** Connection token value. When set, WebSocket clients must present this token. */ + readonly connectionToken?: string; +} + /** * Spawns the agent host as a Node child process (fallback when * Electron utility process is unavailable, e.g. dev/test). */ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarter { + + private _wsConfig: IAgentHostWebSocketConfig | undefined; + + private readonly _onRequestConnection = this._register(new Emitter()); + readonly onRequestConnection = this._onRequestConnection.event; + constructor( @IEnvironmentService private readonly _environmentService: INativeEnvironmentService ) { super(); } + /** + * Configures the child process to also start a WebSocket server. + * Must be called before {@link start}. Triggers eager process start + * via {@link onRequestConnection}. + */ + setWebSocketConfig(config: IAgentHostWebSocketConfig): void { + this._wsConfig = config; + // Signal the process manager to start immediately rather than + // waiting for a renderer window to connect. + this._onRequestConnection.fire(); + } + start(): IAgentHostConnection { + const env: Record = { + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + }; + + // Forward WebSocket server configuration to the child process via env vars + if (this._wsConfig) { + if (this._wsConfig.port) { + env['VSCODE_AGENT_HOST_PORT'] = this._wsConfig.port; + } + if (this._wsConfig.socketPath) { + env['VSCODE_AGENT_HOST_SOCKET_PATH'] = this._wsConfig.socketPath; + } + if (this._wsConfig.host) { + env['VSCODE_AGENT_HOST_HOST'] = this._wsConfig.host; + } + if (this._wsConfig.connectionToken) { + env['VSCODE_AGENT_HOST_CONNECTION_TOKEN'] = this._wsConfig.connectionToken; + } + } + const opts: IIPCOptions = { serverName: 'Agent Host', args: ['--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath], - env: { - VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', - VSCODE_PIPE_LOGGING: 'true', - VSCODE_VERBOSE_LOGGING: 'true', - } + env, }; const agentHostDebug = parseAgentHostDebugPort(this._environmentService.args, this._environmentService.isBuilt); diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts index a56c2b8060c..e8b202654cf 100644 --- a/src/vs/platform/agentHost/node/webSocketTransport.ts +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -6,14 +6,32 @@ // WebSocket transport for the sessions process protocol. // Uses JSON serialization with URI revival for cross-process communication. -import { WebSocketServer, WebSocket } from 'ws'; import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { connectionTokenQueryName } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import type { IProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; +/** + * Options for creating a {@link WebSocketProtocolServer}. + * Provide either `port`+`host` or `socketPath`, not both. + */ +export interface IWebSocketServerOptions { + /** TCP port to listen on. Ignored when {@link socketPath} is set. */ + readonly port?: number; + /** Host/IP to bind to. Defaults to `'127.0.0.1'`. */ + readonly host?: string; + /** Unix domain socket / Windows named pipe path. Takes precedence over port. */ + readonly socketPath?: string; + /** + * Optional token validator. When provided, WebSocket upgrade requests + * must include a valid token in the `tkn` query parameter. + */ + readonly connectionTokenValidate?: (token: unknown) => boolean; +} + // ---- JSON serialization helpers --------------------------------------------- function uriReplacer(_key: string, value: unknown): unknown { @@ -53,7 +71,10 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT private readonly _onClose = this._register(new Emitter()); readonly onClose = this._onClose.event; - constructor(private readonly _ws: WebSocket) { + constructor( + private readonly _ws: import('ws').WebSocket, + private readonly _WebSocket: typeof import('ws').WebSocket, + ) { super(); this._ws.on('message', (data: Buffer | string) => { @@ -77,7 +98,7 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT } send(message: IProtocolMessage): void { - if (this._ws.readyState === WebSocket.OPEN) { + if (this._ws.readyState === this._WebSocket.OPEN) { this._ws.send(JSON.stringify(message, uriReplacer)); } } @@ -93,10 +114,15 @@ export class WebSocketProtocolTransport extends Disposable implements IProtocolT /** * WebSocket server that accepts client connections and wraps each one * as an {@link IProtocolTransport}. + * + * Use the static {@link create} method to construct — it dynamically imports + * `ws` and `http`/`url` so the modules are only loaded when needed. */ export class WebSocketProtocolServer extends Disposable implements IProtocolServer { - private readonly _wss: WebSocketServer; + private readonly _wss: import('ws').WebSocketServer; + private readonly _httpServer: import('http').Server | undefined; + private readonly _WebSocket: typeof import('ws').WebSocket; private readonly _onConnection = this._register(new Emitter()); readonly onConnection = this._onConnection.event; @@ -109,17 +135,66 @@ export class WebSocketProtocolServer extends Disposable implements IProtocolServ return `${addr.address}:${addr.port}`; } - constructor( - private readonly _port: number, - @ILogService private readonly _logService: ILogService, + /** + * Creates a new WebSocket protocol server. Dynamically imports `ws`, + * `http`, and `url` so callers don't pay the cost when unused. + */ + static async create( + options: IWebSocketServerOptions | number, + logService: ILogService, + ): Promise { + const [ws, http, url] = await Promise.all([ + import('ws'), + import('http'), + import('url'), + ]); + return new WebSocketProtocolServer(options, logService, ws, http, url); + } + + private constructor( + options: IWebSocketServerOptions | number, + private readonly _logService: ILogService, + ws: typeof import('ws'), + http: typeof import('http'), + url: typeof import('url'), ) { super(); - this._wss = new WebSocketServer({ port: this._port, host: '127.0.0.1' }); - this._logService.info(`[WebSocketProtocol] Server listening on 127.0.0.1:${this._port}`); - this._wss.on('connection', (ws) => { + this._WebSocket = ws.WebSocket; + + // Backwards compat: accept a plain port number + const opts: IWebSocketServerOptions = typeof options === 'number' ? { port: options } : options; + const host = opts.host ?? '127.0.0.1'; + + const verifyClient = opts.connectionTokenValidate + ? (info: { req: import('http').IncomingMessage }, cb: (res: boolean, code?: number, message?: string) => void) => { + const parsedUrl = url.parse(info.req.url ?? '', true); + const token = parsedUrl.query[connectionTokenQueryName]; + if (!opts.connectionTokenValidate!(token)) { + this._logService.warn('[WebSocketProtocol] Connection rejected: invalid connection token'); + cb(false, 403, 'Forbidden'); + return; + } + cb(true); + } + : undefined; + + if (opts.socketPath) { + // For socket paths, create an HTTP server listening on the path + // and attach the WebSocket server to it. + this._httpServer = http.createServer(); + this._wss = new ws.WebSocketServer({ server: this._httpServer, verifyClient }); + this._httpServer.listen(opts.socketPath, () => { + this._logService.info(`[WebSocketProtocol] Server listening on socket ${opts.socketPath}`); + }); + } else { + this._wss = new ws.WebSocketServer({ port: opts.port, host, verifyClient }); + this._logService.info(`[WebSocketProtocol] Server listening on ${host}:${opts.port}`); + } + + this._wss.on('connection', (wsConn) => { this._logService.trace('[WebSocketProtocol] New client connection'); - const transport = new WebSocketProtocolTransport(ws); + const transport = new WebSocketProtocolTransport(wsConn, this._WebSocket); this._onConnection.fire(transport); }); @@ -130,6 +205,7 @@ export class WebSocketProtocolServer extends Disposable implements IProtocolServ override dispose(): void { this._wss.close(); + this._httpServer?.close(); super.dispose(); } } diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index da7e417cd5c..fb9b82189b9 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -834,6 +834,7 @@ export async function createServer(address: string | net.AddressInfo | null, arg output += `\n`; console.log(output); } + return remoteExtensionHostAgentServer; } diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index ef423dc80fb..7f4fdcf9f5c 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -85,6 +85,9 @@ export const serverOptions: OptionDescriptions> = { 'remote-auto-shutdown-without-delay': { type: 'boolean' }, 'inspect-ptyhost': { type: 'string', allowEmptyValue: true }, + 'agent-host-port': { type: 'string', cat: 'o', args: 'port', description: nls.localize('agent-host-port', "The port the agent host WebSocket server should listen to.") }, + 'agent-host-path': { type: 'string', cat: 'o', args: 'path', description: nls.localize('agent-host-path', "The path to a socket file for the agent host WebSocket server to listen to.") }, + 'use-host-proxy': { type: 'boolean' }, 'without-browser-env-var': { type: 'boolean' }, 'reconnection-grace-time': { type: 'string', cat: 'o', args: 'seconds', description: nls.localize('reconnection-grace-time', "Override the reconnection grace time window in seconds. Defaults to 10800 (3 hours).") }, @@ -215,6 +218,9 @@ export interface ServerParsedArgs { 'remote-auto-shutdown-without-delay'?: boolean; 'inspect-ptyhost'?: string; + 'agent-host-port'?: string; + 'agent-host-path'?: string; + 'use-host-proxy'?: boolean; 'without-browser-env-var'?: boolean; 'reconnection-grace-time'?: string; diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index 069b2c61db5..2285b3de466 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -56,7 +56,7 @@ import { ServerTelemetryChannel } from '../../platform/telemetry/common/remoteTe import { IServerTelemetryService, ServerNullTelemetryService, ServerTelemetryService } from '../../platform/telemetry/common/serverTelemetryService.js'; import { RemoteTerminalChannel } from './remoteTerminalChannel.js'; import { createURITransformer } from '../../base/common/uriTransformer.js'; -import { ServerConnectionToken } from './serverConnectionToken.js'; +import { ServerConnectionToken, ServerConnectionTokenType } from './serverConnectionToken.js'; import { ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; import { REMOTE_TERMINAL_CHANNEL_NAME } from '../../workbench/contrib/terminal/common/remote/remoteTerminalChannel.js'; import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; @@ -232,6 +232,14 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const agentHostStarter = instantiationService.createInstance(NodeAgentHostStarter); disposables.add(instantiationService.createInstance(AgentHostProcessManager, agentHostStarter)); + if (args['agent-host-port'] || args['agent-host-path']) { + agentHostStarter.setWebSocketConfig({ + port: args['agent-host-port'], + socketPath: args['agent-host-path'], + host: args.host || 'localhost', + connectionToken: connectionToken.type === ServerConnectionTokenType.Mandatory ? connectionToken.value : undefined, + }); + } services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService)); services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService));