diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index f3fee07cccbd9..26364fd6b412c 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -827,8 +827,9 @@ export interface IAgentService { /** * Routes a request received on an `mcp://` AHP side channel to the * MCP server implementation owned by the appropriate agent. The - * channel URI shape is `mcp:////` - * (the latter two segments URL-encoded), matching the + * channel URI shape is `mcp:////` + * (the latter two segments URL-encoded), where `chatUri` is the concrete + * `ahp-chat://` URI, matching the * {@link McpServerCustomization.channel | channel} the agent host * advertises while the server is in * {@link McpServerStatus.Ready | `Ready`}. diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index b3c9e9e5c7732..a2b23f94b7ddc 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -5339,19 +5339,30 @@ export class AgentService extends Disposable implements IAgentService { if (!this._gitService) { throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`); } - const workingDirectory = await this._resolveGitBlobWorkingDirectory(fields); - if (!workingDirectory) { - throw new ProtocolError(AhpErrorCodes.NotFound, `No session repository resolves git-blob path: ${fields.absolutePath || fields.repoRelativePath}`); - } - const blob = await this._gitService.showBlob(workingDirectory, fields.sha, fields.repoRelativePath); - if (!blob) { - throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`); + const owningSession = this._sessionReleaseResource(URI.parse(fields.sessionUri)); + const wasRestored = !!this._stateManager.getSessionState(owningSession.toString()); + try { + if (!wasRestored) { + await this.restoreSession(owningSession); + } + const workingDirectory = await this._resolveGitBlobWorkingDirectory(fields, owningSession); + if (!workingDirectory) { + throw new ProtocolError(AhpErrorCodes.NotFound, `No session repository resolves git-blob path: ${fields.absolutePath || fields.repoRelativePath}`); + } + const blob = await this._gitService.showBlob(workingDirectory, fields.sha, fields.repoRelativePath); + if (!blob) { + throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`); + } + return { + data: blob.toString(), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } finally { + if (!wasRestored && this._stateManager.getSessionState(owningSession.toString()) && !this._hasSessionSubscribers(owningSession)) { + this._scheduleSessionRelease(owningSession); + } } - return { - data: blob.toString(), - encoding: ContentEncoding.Utf8, - contentType: 'text/plain', - }; } /** @@ -5380,12 +5391,13 @@ export class AgentService extends Disposable implements IAgentService { * [/work/app, /work/lib] + /outside/c.ts → undefined (NotFound) * [/work/app, /work/lib] + '' (legacy) → /work/app */ - private async _resolveGitBlobWorkingDirectory(fields: IGitBlobUriFields): Promise { + private async _resolveGitBlobWorkingDirectory(fields: IGitBlobUriFields, owningSession: URI): Promise { const gitService = this._gitService; if (!gitService) { return undefined; } - const workingDirectories = getEffectiveWorkingDirectories(this._stateManager, fields.sessionUri); + const workingDirectories = getEffectiveWorkingDirectories(this._stateManager, fields.sessionUri) + ?? getEffectiveWorkingDirectories(this._stateManager, owningSession.toString()); // Backwards-compat: no resolvable absolute path means we cannot match a // repository root, so fall back to today's primary-directory behavior. if (!fields.absolutePath) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 58356715d2baa..81182641cf1ea 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1285,7 +1285,7 @@ export class CopilotAgent extends Disposable implements IAgent { async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record | undefined): Promise { const entry = this._findChatByUri(chat); - if (!entry) { + if (!entry || !isEqual(entry.chatChannelUri, chat)) { throw new Error(`Method not found: no active chat ${chat.toString()}`); } return entry.handleMcpRequest(serverName, method, params); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9efd41365ffdb..a175cb7d375d5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -58,6 +58,7 @@ import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, Protocol import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; +import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -400,6 +401,31 @@ suite('AgentService (node dispatcher)', () => { // No throw - success }); + test('forwards the exact chat URI encoded in an MCP channel', async () => { + const provider: IAgent = copilotAgent; + const calls: Array<{ chat: string; serverName: string; method: string; params: Record | undefined }> = []; + provider.handleMcpRequest = async (chat, serverName, method, params) => { + calls.push({ chat: chat.toString(), serverName, method, params }); + return 'result'; + }; + service.registerProvider(provider); + const session = AgentSession.uri('copilot', 'agent-host-session'); + const chat = URI.parse(buildChatUri(session, 'peer-chat')); + const params = { uri: 'ui://example/app' }; + + const result = await service.handleMcpRequest(buildMcpChannel(chat, 'server'), 'resources/read', params); + + assert.deepStrictEqual({ result, calls }, { + result: 'result', + calls: [{ + chat: chat.toString(), + serverName: 'server', + method: 'resources/read', + params, + }], + }); + }); + test('throws on duplicate provider registration', () => { service.registerProvider(copilotAgent); const duplicate = new MockAgent('copilot'); @@ -1087,6 +1113,68 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(result.data, 'blob:src/app.ts'); }); + test('git-blob restores the session before resolving its working directory', async () => { + const repoA = URI.file('/workspace/repoA'); + const showBlobCalls: Array<{ workingDirectory: string; ref: string; repoRelativePath: string }> = []; + const gitService = createBlobGitService(new Map([[repoA.toString(), repoA]]), showBlobCalls); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const agent = new MockAgent('copilot'); + agent.sessionMetadataOverrides = { workingDirectories: [repoA] }; + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const { session } = await createAgentSession(agent); + const sessionRestoredBeforeRead = !!localService.stateManager.getSessionState(session.toString()); + + const result = await localService.resourceRead(URI.parse(buildGitBlobUri(session.toString(), 'baseSha', 'src/app.ts', '/workspace/repoA/src/app.ts'))); + + assert.deepStrictEqual({ + sessionRestoredBeforeRead, + sessionRestored: !!localService.stateManager.getSessionState(session.toString()), + showBlobCalls, + data: result.data, + }, { + sessionRestoredBeforeRead: false, + sessionRestored: true, + showBlobCalls: [{ workingDirectory: repoA.toString(), ref: 'baseSha', repoRelativePath: 'src/app.ts' }], + data: 'blob:src/app.ts', + }); + }); + + test('git-blob temporarily restores the owning session for nested subagents', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const repoA = URI.file('/workspace/repoA'); + const showBlobCalls: Array<{ workingDirectory: string; ref: string; repoRelativePath: string }> = []; + const gitService = createBlobGitService(new Map([[repoA.toString(), repoA]]), showBlobCalls); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const agent = new MockAgent('copilot'); + agent.sessionMetadataOverrides = { workingDirectories: [repoA] }; + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const { session } = await createAgentSession(agent); + const childSession = URI.parse(buildSubagentSessionUri(session, 'child')); + const nestedSession = URI.parse(buildSubagentSessionUri(childSession, 'nested')); + + const result = await localService.resourceRead(URI.parse(buildGitBlobUri(nestedSession.toString(), 'baseSha', 'src/app.ts', '/workspace/repoA/src/app.ts'))); + localService.addSubscriber(nestedSession, 'client'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + const retainedForSubscriber = !!localService.stateManager.getSessionState(session.toString()); + localService.unsubscribe(nestedSession, 'client'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + + assert.deepStrictEqual({ + showBlobCalls, + data: result.data, + retainedForSubscriber, + releasedAfterUnsubscribe: !localService.stateManager.getSessionState(session.toString()), + }, { + showBlobCalls: [{ workingDirectory: repoA.toString(), ref: 'baseSha', repoRelativePath: 'src/app.ts' }], + data: 'blob:src/app.ts', + retainedForSubscriber: true, + releasedAfterUnsubscribe: true, + }); + }); + }); + test('single-folder git-blob uses the primary directory even for a path outside the root (AC-1.1 unchanged)', async () => { const repoA = URI.file('/workspace/repoA'); const showBlobCalls: Array<{ workingDirectory: string; ref: string; repoRelativePath: string }> = []; diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index d4d94d7123846..42e0f551cf411 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -17,6 +17,7 @@ import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { getCustomizationEnablementKey, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../../node/agentHostCustomizationEnablementService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { CodexClientCustomizationStore, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; +import type { ICodexMcpServerEntry } from '../../../node/codex/codexMcpServers.js'; import { targetForMcpServer } from '../../../node/shared/customizationEnablementGate.js'; import { McpCustomizationController, type IMcpCustomizationControllerOptions } from '../../../node/shared/mcpCustomizationController.js'; @@ -51,6 +52,12 @@ interface ICodexMcpControllerHarness { readonly _fire: (...args: readonly unknown[]) => void; } +interface ICodexMcpRequestHarness { + readonly _sessionIdByChatUri: Map; + readonly _sessions: Map; + readonly _mcpInventory: Map; +} + function resolveConversationSession(harness: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined { const resolver = (CodexAgent.prototype as unknown as { _resolveConversationSession(this: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined; @@ -65,6 +72,13 @@ function getOrCreateMcpController(harness: ICodexMcpControllerHarness, session: return getOrCreate.call(harness, session); } +function handleMcpRequest(harness: ICodexMcpRequestHarness, chat: URI): Promise { + const handler = (CodexAgent.prototype as unknown as { + handleMcpRequest(this: ICodexMcpRequestHarness, chat: URI, serverName: string, method: string, params: undefined): Promise; + }).handleMcpRequest; + return handler.call(harness, chat, 'server', 'tools/list', undefined); +} + function emptyHarness(): ICodexConversationResolverHarness { return { id: CODEX_AGENT_PROVIDER_ID, _sessionIdByChatUri: new Map() }; } @@ -188,9 +202,40 @@ suite('CodexAgent', () => { nestedKey: `${pluginUri}#mcp=azure`, topLevelEnablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }], }); + store.dispose(); }); + test('routes MCP requests only to the exact bound chat', async () => { + const session = AgentSession.uri('codex', 'session-1'); + const boundChat = URI.parse(buildDefaultChatUri(session)); + const staleChat = URI.parse(buildDefaultChatUri(AgentSession.uri('codex', 'stale'))); + const harness: ICodexMcpRequestHarness = { + _sessionIdByChatUri: new Map([ + [boundChat.toString(), 'session-1'], + [staleChat.toString(), 'session-1'], + ]), + _sessions: new Map([['session-1', { chatChannel: boundChat }]]), + _mcpInventory: new Map([['server', { + state: { kind: McpServerStatus.Ready }, + tools: [], + resources: [], + resourceTemplates: [], + }]]), + }; + + assert.deepStrictEqual({ + result: await handleMcpRequest(harness, boundChat), + staleRejected: await handleMcpRequest(harness, staleChat).then( + () => false, + error => error instanceof Error && error.message.startsWith('Method not found: no active chat'), + ), + }, { + result: { tools: [] }, + staleRejected: true, + }); + }); + test('cold native discovery waits for the SDK and emits through one deterministic path', async () => { const sdkReady = new DeferredPromise(); const onDidDiscoverChats = new Emitter(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5aeb1c57c6e7f..44756cdb202bb 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6840,7 +6840,19 @@ suite('CopilotAgent', () => { dispose: () => { }, }, boundChat); - assert.strictEqual(await agent.handleMcpRequest(boundChat, 'srv', 'tools/call', undefined), 'srv/tools/call'); + const staleChat = URI.parse(buildChatUri(session, 'stale')); + chatBackings(agent).set(staleChat.toString(), { sdkSessionId: 'unrelated-sdk-id' }); + + assert.deepStrictEqual({ + result: await agent.handleMcpRequest(boundChat, 'srv', 'tools/call', undefined), + staleRejected: await agent.handleMcpRequest(staleChat, 'srv', 'tools/call', undefined).then( + () => false, + error => error instanceof Error && error.message.startsWith('Method not found: no active chat'), + ), + }, { + result: 'srv/tools/call', + staleRejected: true, + }); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/networkFilter/common/domainMatcher.ts b/src/vs/platform/networkFilter/common/domainMatcher.ts index fbcc995037e22..e8eda8aed5b70 100644 --- a/src/vs/platform/networkFilter/common/domainMatcher.ts +++ b/src/vs/platform/networkFilter/common/domainMatcher.ts @@ -110,6 +110,14 @@ function normalizeUriAuthority(authority: string | undefined): string | undefine return normalizeDomain(hostname, true); } +function normalizeBareIPv6Address(value: string): string | undefined { + if (!value.includes(':') || /[\[\]]/.test(value)) { + return undefined; + } + + return normalizeUriAuthority(`[${value}]`); +} + /** * Extracts the domain portion from a pattern string. * If the pattern contains `://`, it is parsed as a URI and the authority is returned. @@ -140,7 +148,7 @@ export function extractDomainPattern(pattern: string): string { */ export function matchesDomainPattern(domain: string, pattern: string): boolean { const extractedPattern = extractDomainPattern(pattern); - const normalizedPattern = normalizeDomain(extractedPattern, pattern.includes('://')) ?? normalizeUriAuthority(extractedPattern); + const normalizedPattern = normalizeUriAuthority(extractedPattern) ?? normalizeBareIPv6Address(extractedPattern) ?? normalizeDomain(extractedPattern, true); if (!normalizedPattern) { return false; } diff --git a/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts b/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts index 43b0eb2a12858..ad0bfb4ac7e37 100644 --- a/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts +++ b/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts @@ -131,6 +131,42 @@ suite('domainMatcher', () => { assert.strictEqual(matchesDomainPattern('example.com', 'https://example.com/page'), true); }); + test('matches explicitly configured local and private host patterns', () => { + assert.deepStrictEqual([ + matchesDomainPattern('localhost', 'localhost'), + matchesDomainPattern('sub.localhost', '*.localhost'), + matchesDomainPattern('127.0.0.1', '127.0.0.1'), + matchesDomainPattern('0.0.0.0', '0.0.0.0'), + matchesDomainPattern('service.internal', 'service.internal'), + matchesDomainPattern('localhost', 'other.localhost'), + ], [ + true, + true, + true, + true, + true, + false, + ]); + }); + + test('matches bracketed and bare IPv6 patterns', () => { + assert.deepStrictEqual([ + matchesDomainPattern('[::1]', '[::1]'), + matchesDomainPattern('[::1]', '::1'), + matchesDomainPattern('[fd00::1]', 'fd00::1'), + matchesDomainPattern('fd00', 'fd00::1'), + matchesDomainPattern('[2001:db8::1]', '2001:0db8:0:0:0:0:0:1'), + matchesDomainPattern('[2001:db8::1]', '2001:db8::2'), + ], [ + true, + true, + true, + false, + true, + false, + ]); + }); + test('returns false for invalid pattern', () => { assert.strictEqual(matchesDomainPattern('example.com', ''), false); }); diff --git a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts index 534308d8c2f0b..de533d0652aae 100644 --- a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts +++ b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts @@ -107,6 +107,26 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com/page')), false); }); + test('allows explicitly configured local hosts', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['localhost', '*.localhost', '127.0.0.1', '0.0.0.0', '::1']); + const service = await createService(); + assert.deepStrictEqual([ + service.isUriAllowed(URI.parse('http://localhost:3000')), + service.isUriAllowed(URI.parse('http://sub.localhost:3000')), + service.isUriAllowed(URI.parse('http://127.0.0.1:3000')), + service.isUriAllowed(URI.parse('http://0.0.0.0:3000')), + service.isUriAllowed(URI.parse('http://[::1]:3000')), + service.isUriAllowed(URI.parse('http://other.internal:3000')), + ], [ + true, + true, + true, + true, + true, + false, + ]); + }); + test('denies IPv6 literals when both domain lists are empty', async () => { const service = await createService(); assert.deepStrictEqual([ diff --git a/src/vs/sessions/contrib/chat/browser/media/newSessionPromptOptions.css b/src/vs/sessions/contrib/chat/browser/media/newSessionPromptOptions.css index 5baa540ede568..9b6f8ea08b45a 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newSessionPromptOptions.css +++ b/src/vs/sessions/contrib/chat/browser/media/newSessionPromptOptions.css @@ -101,6 +101,7 @@ } .new-session-prompt-option-title { + color: var(--vscode-descriptionForeground); display: flex; font-size: var(--vscode-agents-fontSize-body1); font-weight: var(--vscode-agents-fontWeight-semiBold); diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts index c301606b1a5aa..d84bc176901a2 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/textSearchHeading.ts @@ -335,12 +335,12 @@ export class PlainTextSearchHeadingImpl extends TextSearchHeadingImpl this._onChange.fire(event)); - this._register(folderMatch.onDispose(() => disposable.dispose())); + Event.once(folderMatch.onDispose)(() => disposable.dispose()); return folderMatch; } @@ -349,6 +349,6 @@ export class PlainTextSearchHeadingImpl extends TextSearchHeadingImpl { assert.strictEqual(otherFilesMatch.allDownstreamFileMatches().length, 0); }); + test('folder matches are not retained across queries', function () { + const testObject = aSearchResult(); + const disposeSpies = testObject.folderMatches().map(folderMatch => sinon.spy(folderMatch, 'dispose')); + + for (const folder of ['/second', '/third']) { + testObject.query = { + type: QueryType.Text, + contentPattern: { pattern: '' }, + folderQueries: [{ folder: createFileUriFromPathFromRoot(folder) }] + }; + testObject.add([], 'test', false); + } + + testObject.dispose(); + + assert.deepStrictEqual(disposeSpies.map(spy => spy.callCount), [1, 1]); + }); + test('batchReplace should trigger the onChange event correctly', async function () { const replaceSpy = sinon.spy(); instantiationService.stub(IReplaceService, 'replace', (arg: any) => {