Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<providerId>/<sessionId>/<serverName>`
* (the latter two segments URL-encoded), matching the
* channel URI shape is `mcp://<providerId>/<chatUri>/<serverName>`
* (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`}.
Expand Down
40 changes: 26 additions & 14 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

/**
Expand Down Expand Up @@ -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<URI | undefined> {
private async _resolveGitBlobWorkingDirectory(fields: IGitBlobUriFields, owningSession: URI): Promise<URI | undefined> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1285,7 +1285,7 @@ export class CopilotAgent extends Disposable implements IAgent {

async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
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);
Expand Down
88 changes: 88 additions & 0 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> | 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');
Expand Down Expand Up @@ -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 }> = [];
Expand Down
45 changes: 45 additions & 0 deletions src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -51,6 +52,12 @@ interface ICodexMcpControllerHarness {
readonly _fire: (...args: readonly unknown[]) => void;
}

interface ICodexMcpRequestHarness {
readonly _sessionIdByChatUri: Map<string, string>;
readonly _sessions: Map<string, { readonly chatChannel: URI | undefined }>;
readonly _mcpInventory: Map<string, ICodexMcpServerEntry>;
}

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;
Expand All @@ -65,6 +72,13 @@ function getOrCreateMcpController(harness: ICodexMcpControllerHarness, session:
return getOrCreate.call(harness, session);
}

function handleMcpRequest(harness: ICodexMcpRequestHarness, chat: URI): Promise<unknown> {
const handler = (CodexAgent.prototype as unknown as {
handleMcpRequest(this: ICodexMcpRequestHarness, chat: URI, serverName: string, method: string, params: undefined): Promise<unknown>;
}).handleMcpRequest;
return handler.call(harness, chat, 'server', 'tools/list', undefined);
}

function emptyHarness(): ICodexConversationResolverHarness {
return { id: CODEX_AGENT_PROVIDER_ID, _sessionIdByChatUri: new Map() };
}
Expand Down Expand Up @@ -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<string>();
const onDidDiscoverChats = new Emitter<readonly IAgentDiscoveredChat[]>();
Expand Down
14 changes: 13 additions & 1 deletion src/vs/platform/agentHost/test/node/copilotAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 9 additions & 1 deletion src/vs/platform/networkFilter/common/domainMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
36 changes: 36 additions & 0 deletions src/vs/platform/networkFilter/test/common/domainMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading