mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-16 06:17:21 -07:00
Merge branch 'main' into issue-17132-env-compat-warning
This commit is contained in:
@@ -77,7 +77,10 @@ export async function checkPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
return { decision, rule: result.rule };
|
||||
return {
|
||||
decision,
|
||||
rule: result.rule,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,6 +192,8 @@ export class ToolExecutor {
|
||||
tool: call.tool,
|
||||
invocation: call.invocation,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
outcome: call.outcome,
|
||||
};
|
||||
}
|
||||
@@ -263,6 +265,8 @@ export class ToolExecutor {
|
||||
response: successResponse,
|
||||
invocation: call.invocation,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
outcome: call.outcome,
|
||||
};
|
||||
}
|
||||
@@ -287,6 +291,8 @@ export class ToolExecutor {
|
||||
response,
|
||||
tool: call.tool,
|
||||
durationMs: startTime ? Date.now() - startTime : undefined,
|
||||
startTime,
|
||||
endTime: Date.now(),
|
||||
outcome: call.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export type ErroredToolCall = {
|
||||
response: ToolCallResponseInfo;
|
||||
tool?: AnyDeclarativeTool;
|
||||
durationMs?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
schedulerId?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
@@ -98,6 +100,8 @@ export type SuccessfulToolCall = {
|
||||
response: ToolCallResponseInfo;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
schedulerId?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
@@ -125,6 +129,8 @@ export type CancelledToolCall = {
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
schedulerId?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
|
||||
@@ -243,6 +243,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
mcp_server_name?: string;
|
||||
extension_name?: string;
|
||||
extension_id?: string;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metadata?: { [key: string]: any };
|
||||
|
||||
@@ -256,6 +258,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
prompt_id: string,
|
||||
tool_type: 'native' | 'mcp',
|
||||
error?: string,
|
||||
start_time?: number,
|
||||
end_time?: number,
|
||||
);
|
||||
constructor(
|
||||
call?: CompletedToolCall,
|
||||
@@ -266,6 +270,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
prompt_id?: string,
|
||||
tool_type?: 'native' | 'mcp',
|
||||
error?: string,
|
||||
start_time?: number,
|
||||
end_time?: number,
|
||||
) {
|
||||
this['event.name'] = 'tool_call';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
@@ -282,6 +288,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
this.error_type = call.response.errorType;
|
||||
this.prompt_id = call.request.prompt_id;
|
||||
this.content_length = call.response.contentLength;
|
||||
this.start_time = call.startTime;
|
||||
this.end_time = call.endTime;
|
||||
if (
|
||||
typeof call.tool !== 'undefined' &&
|
||||
call.tool instanceof DiscoveredMCPTool
|
||||
@@ -332,6 +340,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
this.prompt_id = prompt_id!;
|
||||
this.tool_type = tool_type!;
|
||||
this.error = error;
|
||||
this.start_time = start_time;
|
||||
this.end_time = end_time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +361,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
mcp_server_name: this.mcp_server_name,
|
||||
extension_name: this.extension_name,
|
||||
extension_id: this.extension_id,
|
||||
start_time: this.start_time,
|
||||
end_time: this.end_time,
|
||||
metadata: this.metadata,
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -1089,7 +1089,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
|
||||
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: grep_search_ripgrep 1`] = `
|
||||
{
|
||||
"description": "Searches for a regular expression pattern within file contents.",
|
||||
"description": "Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard \`run_shell_command("grep ...")\` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via \`total_max_matches\`).",
|
||||
"name": "grep_search",
|
||||
"parametersJsonSchema": {
|
||||
"properties": {
|
||||
|
||||
@@ -131,7 +131,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated
|
||||
grep_search_ripgrep: {
|
||||
name: GREP_TOOL_NAME,
|
||||
description:
|
||||
'Searches for a regular expression pattern within file contents.',
|
||||
'Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via `total_max_matches`).',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -2056,6 +2056,90 @@ describe('connectToMcpServer with OAuth', () => {
|
||||
capturedTransport._requestInit?.headers?.['Authorization'];
|
||||
expect(authHeader).toBe('Bearer test-access-token-from-discovery');
|
||||
});
|
||||
|
||||
it('should use discoverOAuthFromWWWAuthenticate when it succeeds and skip discoverOAuthConfig', async () => {
|
||||
const serverUrl = 'http://test-server.com/mcp';
|
||||
const authUrl = 'http://auth.example.com/auth';
|
||||
const tokenUrl = 'http://auth.example.com/token';
|
||||
const wwwAuthHeader = `Bearer realm="test", resource_metadata="http://test-server.com/.well-known/oauth-protected-resource"`;
|
||||
|
||||
vi.mocked(mockedClient.connect).mockRejectedValueOnce(
|
||||
new StreamableHTTPError(
|
||||
401,
|
||||
`Unauthorized\nwww-authenticate: ${wwwAuthHeader}`,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue({
|
||||
authorizationUrl: authUrl,
|
||||
tokenUrl,
|
||||
scopes: ['read'],
|
||||
});
|
||||
|
||||
vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined);
|
||||
|
||||
const client = await connectToMcpServer(
|
||||
'0.0.1',
|
||||
'test-server',
|
||||
{ httpUrl: serverUrl, oauth: { enabled: true } },
|
||||
false,
|
||||
workspaceContext,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith(
|
||||
wwwAuthHeader,
|
||||
serverUrl,
|
||||
);
|
||||
expect(OAuthUtils.discoverOAuthConfig).not.toHaveBeenCalled();
|
||||
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should fall back to extractBaseUrl + discoverOAuthConfig when discoverOAuthFromWWWAuthenticate returns null', async () => {
|
||||
const serverUrl = 'http://test-server.com/mcp';
|
||||
const baseUrl = 'http://test-server.com';
|
||||
const authUrl = 'http://auth.example.com/auth';
|
||||
const tokenUrl = 'http://auth.example.com/token';
|
||||
const wwwAuthHeader = `Bearer realm="test"`;
|
||||
|
||||
vi.mocked(mockedClient.connect).mockRejectedValueOnce(
|
||||
new StreamableHTTPError(
|
||||
401,
|
||||
`Unauthorized\nwww-authenticate: ${wwwAuthHeader}`,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(OAuthUtils.extractBaseUrl).mockReturnValue(baseUrl);
|
||||
vi.mocked(OAuthUtils.discoverOAuthConfig).mockResolvedValue({
|
||||
authorizationUrl: authUrl,
|
||||
tokenUrl,
|
||||
scopes: ['read'],
|
||||
});
|
||||
|
||||
vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined);
|
||||
|
||||
const client = await connectToMcpServer(
|
||||
'0.0.1',
|
||||
'test-server',
|
||||
{ httpUrl: serverUrl, oauth: { enabled: true } },
|
||||
false,
|
||||
workspaceContext,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
expect(client).toBe(mockedClient);
|
||||
expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith(
|
||||
wwwAuthHeader,
|
||||
serverUrl,
|
||||
);
|
||||
expect(OAuthUtils.extractBaseUrl).toHaveBeenCalledWith(serverUrl);
|
||||
expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(baseUrl);
|
||||
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectToMcpServer - HTTP→SSE fallback', () => {
|
||||
|
||||
@@ -719,18 +719,17 @@ async function handleAutomaticOAuth(
|
||||
try {
|
||||
debugLogger.log(`🔐 '${mcpServerName}' requires OAuth authentication`);
|
||||
|
||||
// Always try to parse the resource metadata URI from the www-authenticate header
|
||||
let oauthConfig;
|
||||
const resourceMetadataUri =
|
||||
OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate);
|
||||
if (resourceMetadataUri) {
|
||||
oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri);
|
||||
} else if (hasNetworkTransport(mcpServerConfig)) {
|
||||
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
|
||||
|
||||
// Try to discover OAuth config from the WWW-Authenticate header first
|
||||
let oauthConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate(
|
||||
wwwAuthenticate,
|
||||
serverUrl,
|
||||
);
|
||||
|
||||
if (!oauthConfig && hasNetworkTransport(mcpServerConfig)) {
|
||||
// Fallback: try to discover OAuth config from the base URL
|
||||
const serverUrl = new URL(
|
||||
mcpServerConfig.httpUrl || mcpServerConfig.url!,
|
||||
);
|
||||
const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`;
|
||||
const baseUrl = OAuthUtils.extractBaseUrl(serverUrl!);
|
||||
oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl);
|
||||
}
|
||||
|
||||
@@ -754,8 +753,6 @@ async function handleAutomaticOAuth(
|
||||
};
|
||||
|
||||
// Perform OAuth authentication
|
||||
// Pass the server URL for proper discovery
|
||||
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
|
||||
debugLogger.log(
|
||||
`Starting OAuth authentication for server '${mcpServerName}'...`,
|
||||
);
|
||||
|
||||
@@ -183,6 +183,26 @@ describe('WebFetchTool', () => {
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => {
|
||||
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'response' }] } }],
|
||||
});
|
||||
const tool = new WebFetchTool(mockConfig, bus);
|
||||
const params = { prompt: 'fetch https://ratelimit.example.com' };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
// Execute 10 times to hit the limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await invocation.execute(new AbortController().signal);
|
||||
}
|
||||
|
||||
// The 11th time should fail due to rate limit
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR);
|
||||
expect(result.error?.message).toContain('Rate limit exceeded for host');
|
||||
});
|
||||
|
||||
it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => {
|
||||
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
|
||||
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
|
||||
|
||||
@@ -33,10 +33,46 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { LRUCache } from 'mnemonist';
|
||||
|
||||
const URL_FETCH_TIMEOUT_MS = 10000;
|
||||
const MAX_CONTENT_LENGTH = 100000;
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
|
||||
const MAX_REQUESTS_PER_WINDOW = 10;
|
||||
const hostRequestHistory = new LRUCache<string, number[]>(1000);
|
||||
|
||||
function checkRateLimit(url: string): {
|
||||
allowed: boolean;
|
||||
waitTimeMs?: number;
|
||||
} {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
const now = Date.now();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||
|
||||
let history = hostRequestHistory.get(hostname) || [];
|
||||
// Clean up old timestamps
|
||||
history = history.filter((timestamp) => timestamp > windowStart);
|
||||
|
||||
if (history.length >= MAX_REQUESTS_PER_WINDOW) {
|
||||
// Calculate wait time based on the oldest timestamp in the current window
|
||||
const oldestTimestamp = history[0];
|
||||
const waitTimeMs = oldestTimestamp + RATE_LIMIT_WINDOW_MS - now;
|
||||
hostRequestHistory.set(hostname, history); // Update cleaned history
|
||||
return { allowed: false, waitTimeMs: Math.max(0, waitTimeMs) };
|
||||
}
|
||||
|
||||
history.push(now);
|
||||
hostRequestHistory.set(hostname, history);
|
||||
return { allowed: true };
|
||||
} catch (_e) {
|
||||
// If URL parsing fails, we fallback to allowed (should be caught by parsePrompt anyway)
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a prompt to extract valid URLs and identify malformed ones.
|
||||
*/
|
||||
@@ -258,6 +294,23 @@ ${textContent}
|
||||
const userPrompt = this.params.prompt;
|
||||
const { validUrls: urls } = parsePrompt(userPrompt);
|
||||
const url = urls[0];
|
||||
|
||||
// Enforce rate limiting
|
||||
const rateLimitResult = checkRateLimit(url);
|
||||
if (!rateLimitResult.allowed) {
|
||||
const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000);
|
||||
const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`;
|
||||
debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`);
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isPrivate = isPrivateIp(url);
|
||||
|
||||
if (isPrivate) {
|
||||
|
||||
Reference in New Issue
Block a user