Merge remote-tracking branch 'origin/main' into mk-bundling-no-npmrc

This commit is contained in:
mkorwel
2025-10-24 09:03:40 -07:00
9 changed files with 607 additions and 271 deletions
+403 -102
View File
@@ -15,8 +15,29 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
- [Collector-Based Export (Advanced)](#collector-based-export-advanced-1)
- [Logs and Metrics](#logs-and-metrics)
- [Logs](#logs)
- [Sessions](#sessions)
- [Tools](#tools)
- [Files](#files)
- [API](#api)
- [Model Routing](#model-routing)
- [Chat and Streaming](#chat-and-streaming)
- [Resilience](#resilience)
- [Extensions](#extensions)
- [Agent Runs](#agent-runs)
- [IDE](#ide)
- [UI](#ui)
- [Metrics](#metrics)
- [Custom](#custom)
- [Sessions](#sessions-1)
- [Tools](#tools-1)
- [API](#api-1)
- [Token Usage](#token-usage)
- [Files](#files-1)
- [Chat and Streaming](#chat-and-streaming-1)
- [Model Routing](#model-routing-1)
- [Agent Runs](#agent-runs-1)
- [UI](#ui-1)
- [Performance](#performance)
- [GenAI Semantic Convention](#genai-semantic-convention)
## Key Benefits
@@ -210,10 +231,13 @@ attributes on all logs and metrics.
### Logs
Logs are timestamped records of specific events. The following events are logged
for Gemini CLI:
for Gemini CLI, grouped by category.
- `gemini_cli.config`: This event occurs once at startup with the CLI's
configuration.
#### Sessions
Captures startup configuration and user prompt submissions.
- `gemini_cli.config`: Emitted once at startup with the CLI configuration.
- **Attributes**:
- `model` (string)
- `embedding_model` (string)
@@ -222,81 +246,42 @@ for Gemini CLI:
- `approval_mode` (string)
- `api_key_enabled` (boolean)
- `vertex_ai_enabled` (boolean)
- `code_assist_enabled` (boolean)
- `log_prompts_enabled` (boolean)
- `log_user_prompts_enabled` (boolean)
- `file_filtering_respect_git_ignore` (boolean)
- `debug_mode` (boolean)
- `mcp_servers` (string)
- `output_format` (string: "text", "json", or "stream-json")
- `mcp_servers_count` (int)
- `mcp_tools` (string, if applicable)
- `mcp_tools_count` (int, if applicable)
- `output_format` ("text", "json", or "stream-json")
- `gemini_cli.user_prompt`: This event occurs when a user submits a prompt.
- `gemini_cli.user_prompt`: Emitted when a user submits a prompt.
- **Attributes**:
- `prompt_length` (int)
- `prompt_id` (string)
- `prompt` (string, this attribute is excluded if `log_prompts_enabled` is
configured to be `false`)
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
- `auth_type` (string)
- `gemini_cli.tool_call`: This event occurs for each function call.
#### Tools
Captures tool executions, output truncation, and Smart Edit behavior.
- `gemini_cli.tool_call`: Emitted for each tool (function) call.
- **Attributes**:
- `function_name`
- `function_args`
- `duration_ms`
- `success` (boolean)
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if
applicable)
- `decision` ("accept", "reject", "auto_accept", or "modify", if applicable)
- `error` (if applicable)
- `error_type` (if applicable)
- `prompt_id` (string)
- `tool_type` ("native" or "mcp")
- `mcp_server_name` (string, if applicable)
- `content_length` (int, if applicable)
- `metadata` (if applicable, dictionary of string -> any)
- `metadata` (if applicable)
- `gemini_cli.file_operation`: This event occurs for each file operation.
- **Attributes**:
- `tool_name` (string)
- `operation` (string: "create", "read", "update")
- `lines` (int, if applicable)
- `mimetype` (string, if applicable)
- `extension` (string, if applicable)
- `programming_language` (string, if applicable)
- `diff_stat` (json string, if applicable): A JSON string with the following
members:
- `ai_added_lines` (int)
- `ai_removed_lines` (int)
- `user_added_lines` (int)
- `user_removed_lines` (int)
- `gemini_cli.api_request`: This event occurs when making a request to Gemini
API.
- **Attributes**:
- `model`
- `request_text` (if applicable)
- `gemini_cli.api_error`: This event occurs if the API request fails.
- **Attributes**:
- `model`
- `error`
- `error_type`
- `status_code`
- `duration_ms`
- `auth_type`
- `gemini_cli.api_response`: This event occurs upon receiving a response from
Gemini API.
- **Attributes**:
- `model`
- `status_code`
- `duration_ms`
- `error` (optional)
- `input_token_count`
- `output_token_count`
- `cached_content_token_count`
- `thoughts_token_count`
- `tool_token_count`
- `response_text` (if applicable)
- `auth_type`
- `gemini_cli.tool_output_truncated`: This event occurs when the output of a
tool call is too large and gets truncated.
- `gemini_cli.tool_output_truncated`: Output of a tool call was truncated.
- **Attributes**:
- `tool_name` (string)
- `original_content_length` (int)
@@ -305,32 +290,211 @@ for Gemini CLI:
- `lines` (int)
- `prompt_id` (string)
- `gemini_cli.malformed_json_response`: This event occurs when a `generateJson`
response from Gemini API cannot be parsed as a json.
- `gemini_cli.smart_edit_strategy`: Smart Edit strategy chosen.
- **Attributes**:
- `model`
- `strategy` (string)
- `gemini_cli.flash_fallback`: This event occurs when Gemini CLI switches to
flash as fallback.
- `gemini_cli.smart_edit_correction`: Smart Edit correction result.
- **Attributes**:
- `auth_type`
- `correction` ("success" | "failure")
- `gemini_cli.slash_command`: This event occurs when a user executes a slash
command.
#### Files
Tracks file operations performed by tools.
- `gemini_cli.file_operation`: Emitted for each file operation.
- **Attributes**:
- `tool_name` (string)
- `operation` ("create" | "read" | "update")
- `lines` (int, optional)
- `mimetype` (string, optional)
- `extension` (string, optional)
- `programming_language` (string, optional)
#### API
Captures Gemini API requests, responses, and errors.
- `gemini_cli.api_request`: Request sent to Gemini API.
- **Attributes**:
- `model` (string)
- `prompt_id` (string)
- `request_text` (string, optional)
- `gemini_cli.api_response`: Response received from Gemini API.
- **Attributes**:
- `model` (string)
- `status_code` (int|string)
- `duration_ms` (int)
- `input_token_count` (int)
- `output_token_count` (int)
- `cached_content_token_count` (int)
- `thoughts_token_count` (int)
- `tool_token_count` (int)
- `total_token_count` (int)
- `response_text` (string, optional)
- `prompt_id` (string)
- `auth_type` (string)
- `gemini_cli.api_error`: API request failed.
- **Attributes**:
- `model` (string)
- `error` (string)
- `error_type` (string)
- `status_code` (int|string)
- `duration_ms` (int)
- `prompt_id` (string)
- `auth_type` (string)
- `gemini_cli.malformed_json_response`: `generateJson` response could not be
parsed.
- **Attributes**:
- `model` (string)
#### Model Routing
Tracks model selections via slash commands and router decisions.
- `gemini_cli.slash_command`: A slash command was executed.
- **Attributes**:
- `command` (string)
- `subcommand` (string, if applicable)
- `subcommand` (string, optional)
- `status` ("success" | "error")
- `gemini_cli.extension_enable`: This event occurs when an extension is enabled
- `gemini_cli.extension_install`: This event occurs when an extension is
installed
- `gemini_cli.slash_command.model`: Model was selected via slash command.
- **Attributes**:
- `model_name` (string)
- `gemini_cli.model_routing`: Model router made a decision.
- **Attributes**:
- `decision_model` (string)
- `decision_source` (string)
- `routing_latency_ms` (int)
- `reasoning` (string, optional)
- `failed` (boolean)
- `error_message` (string, optional)
#### Chat and Streaming
Observes streaming integrity, compression, and retry behavior.
- `gemini_cli.chat_compression`: Chat context was compressed.
- **Attributes**:
- `tokens_before` (int)
- `tokens_after` (int)
- `gemini_cli.chat.invalid_chunk`: Invalid chunk received from a stream.
- **Attributes**:
- `error.message` (string, optional)
- `gemini_cli.chat.content_retry`: Retry triggered due to a content error.
- **Attributes**:
- `attempt_number` (int)
- `error_type` (string)
- `retry_delay_ms` (int)
- `model` (string)
- `gemini_cli.chat.content_retry_failure`: All content retries failed.
- **Attributes**:
- `total_attempts` (int)
- `final_error_type` (string)
- `total_duration_ms` (int, optional)
- `model` (string)
- `gemini_cli.conversation_finished`: Conversation session ended.
- **Attributes**:
- `approvalMode` (string)
- `turnCount` (int)
- `gemini_cli.next_speaker_check`: Next speaker determination.
- **Attributes**:
- `prompt_id` (string)
- `finish_reason` (string)
- `result` (string)
#### Resilience
Records fallback mechanisms for models and network operations.
- `gemini_cli.flash_fallback`: Switched to a flash model as fallback.
- **Attributes**:
- `auth_type` (string)
- `gemini_cli.ripgrep_fallback`: Switched to grep as fallback for file search.
- **Attributes**:
- `error` (string, optional)
- `gemini_cli.web_fetch_fallback_attempt`: Attempted web-fetch fallback.
- **Attributes**:
- `reason` ("private_ip" | "primary_failed")
#### Extensions
Tracks extension lifecycle and settings changes.
- `gemini_cli.extension_install`: An extension was installed.
- **Attributes**:
- `extension_name` (string)
- `extension_version` (string)
- `extension_source` (string)
- `status` (string)
- `gemini_cli.extension_uninstall`: This event occurs when an extension is
uninstalled
- `gemini_cli.extension_uninstall`: An extension was uninstalled.
- **Attributes**:
- `extension_name` (string)
- `status` (string)
- `gemini_cli.extension_enable`: An extension was enabled.
- **Attributes**:
- `extension_name` (string)
- `setting_scope` (string)
- `gemini_cli.extension_disable`: An extension was disabled.
- **Attributes**:
- `extension_name` (string)
- `setting_scope` (string)
- `gemini_cli.extension_update`: An extension was updated.
- **Attributes**:
- `extension_name` (string)
- `extension_version` (string)
- `extension_previous_version` (string)
- `extension_source` (string)
- `status` (string)
#### Agent Runs
Tracks agent lifecycle and outcomes.
- `gemini_cli.agent.start`: Agent run started.
- **Attributes**:
- `agent_id` (string)
- `agent_name` (string)
- `gemini_cli.agent.finish`: Agent run finished.
- **Attributes**:
- `agent_id` (string)
- `agent_name` (string)
- `duration_ms` (int)
- `turn_count` (int)
- `terminate_reason` (string)
#### IDE
Captures IDE connectivity and conversation lifecycle events.
- `gemini_cli.ide_connection`: IDE companion connection.
- **Attributes**:
- `connection_type` (string)
#### UI
Tracks terminal rendering issues and related signals.
- `kitty_sequence_overflow`: Terminal kitty control sequence overflow.
- **Attributes**:
- `sequence_length` (int)
- `truncated_sequence` (string)
### Metrics
@@ -338,27 +502,35 @@ Metrics are numerical measurements of behavior over time.
#### Custom
##### Sessions
Counts CLI sessions at startup.
- `gemini_cli.session.count` (Counter, Int): Incremented once per CLI startup.
##### Tools
Measures tool usage and latency.
- `gemini_cli.tool.call.count` (Counter, Int): Counts tool calls.
- **Attributes**:
- `function_name`
- `success` (boolean)
- `decision` (string: "accept", "reject", or "modify", if applicable)
- `tool_type` (string: "mcp", or "native", if applicable)
- `model_added_lines` (Int, optional): Lines added by model in the proposed
changes, if applicable
- `model_removed_lines` (Int, optional): Lines removed by model in the
proposed changes, if applicable
- `user_added_lines` (Int, optional): Lines added by user edits after model
proposal, if applicable
- `user_removed_lines` (Int, optional): Lines removed by user edits after
model proposal, if applicable
- `decision` (string: "accept", "reject", "modify", or "auto_accept", if
applicable)
- `tool_type` (string: "mcp" or "native", if applicable)
- `model_added_lines` (Int, optional)
- `model_removed_lines` (Int, optional)
- `user_added_lines` (Int, optional)
- `user_removed_lines` (Int, optional)
- `gemini_cli.tool.call.latency` (Histogram, ms): Measures tool call latency.
- **Attributes**:
- `function_name`
- `decision` (string: "accept", "reject", or "modify", if applicable)
##### API
Tracks API request volume and latency.
- `gemini_cli.api.request.count` (Counter, Int): Counts all API requests.
- **Attributes**:
@@ -370,32 +542,161 @@ Metrics are numerical measurements of behavior over time.
latency.
- **Attributes**:
- `model`
- **Note**: This metric overlaps with `gen_ai.client.operation.duration` below
that's compliant with GenAI Semantic Conventions.
- Note: Overlaps with `gen_ai.client.operation.duration` (GenAI conventions).
- `gemini_cli.token.usage` (Counter, Int): Counts the number of tokens used.
##### Token Usage
Tracks tokens used by model and type.
- `gemini_cli.token.usage` (Counter, Int): Counts tokens used.
- **Attributes**:
- `model`
- `type` (string: "input", "output", "thought", "cache", or "tool")
- **Note**: This metric overlaps with `gen_ai.client.token.usage` below for
`input`/`output` token types that's compliant with GenAI Semantic
Conventions.
- `type` ("input", "output", "thought", "cache", or "tool")
- Note: Overlaps with `gen_ai.client.token.usage` for `input`/`output`.
##### Files
Counts file operations with basic context.
- `gemini_cli.file.operation.count` (Counter, Int): Counts file operations.
- **Attributes**:
- `operation` (string: "create", "read", "update"): The type of file
operation.
- `lines` (Int, if applicable): Number of lines in the file.
- `mimetype` (string, if applicable): Mimetype of the file.
- `extension` (string, if applicable): File extension of the file.
- `programming_language` (string, if applicable): The programming language
of the file.
- `operation` ("create", "read", "update")
- `lines` (Int, optional)
- `mimetype` (string, optional)
- `extension` (string, optional)
- `programming_language` (string, optional)
##### Chat and Streaming
Resilience counters for compression, invalid chunks, and retries.
- `gemini_cli.chat_compression` (Counter, Int): Counts chat compression
operations
operations.
- **Attributes**:
- `tokens_before`: (Int): Number of tokens in context prior to compression
- `tokens_after`: (Int): Number of tokens in context after compression
- `tokens_before` (Int)
- `tokens_after` (Int)
- `gemini_cli.chat.invalid_chunk.count` (Counter, Int): Counts invalid chunks
from streams.
- `gemini_cli.chat.content_retry.count` (Counter, Int): Counts retries due to
content errors.
- `gemini_cli.chat.content_retry_failure.count` (Counter, Int): Counts requests
where all content retries failed.
##### Model Routing
Routing latency/failures and slash-command selections.
- `gemini_cli.slash_command.model.call_count` (Counter, Int): Counts model
selections via slash command.
- **Attributes**:
- `slash_command.model.model_name` (string)
- `gemini_cli.model_routing.latency` (Histogram, ms): Model routing decision
latency.
- **Attributes**:
- `routing.decision_model` (string)
- `routing.decision_source` (string)
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
failures.
- **Attributes**:
- `routing.decision_source` (string)
- `routing.error_message` (string)
##### Agent Runs
Agent lifecycle metrics: runs, durations, and turns.
- `gemini_cli.agent.run.count` (Counter, Int): Counts agent runs.
- **Attributes**:
- `agent_name` (string)
- `terminate_reason` (string)
- `gemini_cli.agent.duration` (Histogram, ms): Agent run durations.
- **Attributes**:
- `agent_name` (string)
- `gemini_cli.agent.turns` (Histogram, turns): Turns taken per agent run.
- **Attributes**:
- `agent_name` (string)
##### UI
UI stability signals such as flicker count.
- `gemini_cli.ui.flicker.count` (Counter, Int): Counts UI frames that flicker
(render taller than terminal).
##### Performance
Optional performance monitoring for startup, CPU/memory, and phase timing.
- `gemini_cli.startup.duration` (Histogram, ms): CLI startup time by phase.
- **Attributes**:
- `phase` (string)
- `details` (map, optional)
- `gemini_cli.memory.usage` (Histogram, bytes): Memory usage.
- **Attributes**:
- `memory_type` ("heap_used", "heap_total", "external", "rss")
- `component` (string, optional)
- `gemini_cli.cpu.usage` (Histogram, percent): CPU usage percentage.
- **Attributes**:
- `component` (string, optional)
- `gemini_cli.tool.queue.depth` (Histogram, count): Number of tools in the
execution queue.
- `gemini_cli.tool.execution.breakdown` (Histogram, ms): Tool time by phase.
- **Attributes**:
- `function_name` (string)
- `phase` ("validation", "preparation", "execution", "result_processing")
- `gemini_cli.api.request.breakdown` (Histogram, ms): API request time by phase.
- **Attributes**:
- `model` (string)
- `phase` ("request_preparation", "network_latency", "response_processing",
"token_processing")
- `gemini_cli.token.efficiency` (Histogram, ratio): Token efficiency metrics.
- **Attributes**:
- `model` (string)
- `metric` (string)
- `context` (string, optional)
- `gemini_cli.performance.score` (Histogram, score): Composite performance
score.
- **Attributes**:
- `category` (string)
- `baseline` (number, optional)
- `gemini_cli.performance.regression` (Counter, Int): Regression detection
events.
- **Attributes**:
- `metric` (string)
- `severity` ("low", "medium", "high")
- `current_value` (number)
- `baseline_value` (number)
- `gemini_cli.performance.regression.percentage_change` (Histogram, percent):
Percent change from baseline when regression detected.
- **Attributes**:
- `metric` (string)
- `severity` ("low", "medium", "high")
- `current_value` (number)
- `baseline_value` (number)
- `gemini_cli.performance.baseline.comparison` (Histogram, percent): Comparison
to baseline.
- **Attributes**:
- `metric` (string)
- `category` (string)
- `current_value` (number)
- `baseline_value` (number)
#### GenAI Semantic Convention
+1 -4
View File
@@ -9,9 +9,6 @@ import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { TestRig } from './test-helper.js';
// Windows skip (Option A: avoid infra scope)
const d = process.platform === 'win32' ? describe.skip : describe;
// BOM encoders
const utf8BOM = (s: string) =>
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(s, 'utf8')]);
@@ -53,7 +50,7 @@ const utf32BE = (s: string) => {
let rig: TestRig;
let dir: string;
d('BOM end-to-end integration', () => {
describe('BOM end-to-end integraion', () => {
beforeAll(async () => {
rig = new TestRig();
await rig.setup('bom-integration');
@@ -82,31 +82,76 @@ describe('GoogleCredentialProvider', () => {
describe('with provider instance', () => {
let provider: GoogleCredentialProvider;
let mockGetAccessToken: Mock;
let mockClient: {
getAccessToken: Mock;
credentials?: { expiry_date: number | null };
};
beforeEach(() => {
// clear and reset mock client before each test
mockGetAccessToken = vi.fn();
mockClient = {
getAccessToken: mockGetAccessToken,
};
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
provider = new GoogleCredentialProvider(validConfig);
vi.clearAllMocks();
});
it('should return credentials', async () => {
const mockClient = {
getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }),
};
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
mockGetAccessToken.mockResolvedValue({ token: 'test-token' });
const credentials = await provider.tokens();
expect(credentials?.access_token).toBe('test-token');
});
it('should return undefined if access token is not available', async () => {
const mockClient = {
getAccessToken: vi.fn().mockResolvedValue({ token: null }),
};
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
mockGetAccessToken.mockResolvedValue({ token: null });
const credentials = await provider.tokens();
expect(credentials).toBeUndefined();
});
it('should return a cached token if it is not expired', async () => {
vi.useFakeTimers();
mockClient.credentials = { expiry_date: Date.now() + 3600 * 1000 }; // 1 hour
mockGetAccessToken.mockResolvedValue({ token: 'test-token' });
// first call
const firstTokens = await provider.tokens();
expect(firstTokens?.access_token).toBe('test-token');
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
// second call
vi.advanceTimersByTime(1800 * 1000); // Advance time by 30 minutes
const secondTokens = await provider.tokens();
expect(secondTokens).toBe(firstTokens);
expect(mockGetAccessToken).toHaveBeenCalledTimes(1); // Should not be called again
vi.useRealTimers();
});
it('should fetch a new token if the cached token is expired', async () => {
vi.useFakeTimers();
// first call
mockClient.credentials = { expiry_date: Date.now() + 1000 }; // Expires in 1 second
mockGetAccessToken.mockResolvedValue({ token: 'expired-token' });
const firstTokens = await provider.tokens();
expect(firstTokens?.access_token).toBe('expired-token');
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
// second call
vi.advanceTimersByTime(1001); // Advance time past expiry
mockClient.credentials = { expiry_date: Date.now() + 3600 * 1000 }; // New expiry
mockGetAccessToken.mockResolvedValue({ token: 'new-token' });
const newTokens = await provider.tokens();
expect(newTokens?.access_token).toBe('new-token');
expect(mockGetAccessToken).toHaveBeenCalledTimes(2); // new fetch
vi.useRealTimers();
});
});
});
+25 -2
View File
@@ -13,11 +13,14 @@ import type {
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { GoogleAuth } from 'google-auth-library';
import type { MCPServerConfig } from '../config/config.js';
import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/];
export class GoogleCredentialProvider implements OAuthClientProvider {
private readonly auth: GoogleAuth;
private cachedToken?: OAuthTokens;
private tokenExpiryTime?: number;
// Properties required by OAuthClientProvider, with no-op values
readonly redirectUrl = '';
@@ -65,6 +68,19 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
}
async tokens(): Promise<OAuthTokens | undefined> {
// check for a valid, non-expired cached token.
if (
this.cachedToken &&
this.tokenExpiryTime &&
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
) {
return this.cachedToken;
}
// Clear invalid/expired cache.
this.cachedToken = undefined;
this.tokenExpiryTime = undefined;
const client = await this.auth.getClient();
const accessTokenResponse = await client.getAccessToken();
@@ -73,11 +89,18 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
return undefined;
}
const tokens: OAuthTokens = {
const newToken: OAuthTokens = {
access_token: accessTokenResponse.token,
token_type: 'Bearer',
};
return tokens;
const expiryTime = client.credentials?.expiry_date;
if (expiryTime) {
this.tokenExpiryTime = expiryTime;
this.cachedToken = newToken;
}
return newToken;
}
saveTokens(_tokens: OAuthTokens): void {
+37
View File
@@ -325,4 +325,41 @@ describe('OAuthUtils', () => {
expect(() => OAuthUtils.buildResourceParameter('not-a-url')).toThrow();
});
});
describe('parseTokenExpiry', () => {
it('should return the expiry time in milliseconds for a valid token', () => {
// Corresponds to a date of 2100-01-01T00:00:00Z
const expiry = 4102444800;
const payload = { exp: expiry };
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
const result = OAuthUtils.parseTokenExpiry(token);
expect(result).toBe(expiry * 1000);
});
it('should return undefined for a token without an expiry time', () => {
const payload = { iat: 1678886400 };
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
const result = OAuthUtils.parseTokenExpiry(token);
expect(result).toBeUndefined();
});
it('should return undefined for a token with an invalid expiry time', () => {
const payload = { exp: 'not-a-number' };
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
const result = OAuthUtils.parseTokenExpiry(token);
expect(result).toBeUndefined();
});
it('should return undefined for a malformed token', () => {
const token = 'not-a-valid-token';
const result = OAuthUtils.parseTokenExpiry(token);
expect(result).toBeUndefined();
});
it('should return undefined for a token with invalid JSON in payload', () => {
const token = `header.${Buffer.from('{ not valid json').toString('base64')}.signature`;
const result = OAuthUtils.parseTokenExpiry(token);
expect(result).toBeUndefined();
});
});
});
+24
View File
@@ -38,6 +38,8 @@ export interface OAuthProtectedResourceMetadata {
resource_encryption_enc_values_supported?: string[];
}
export const FIVE_MIN_BUFFER_MS = 5 * 60 * 1000;
/**
* Utility class for common OAuth operations.
*/
@@ -362,4 +364,26 @@ export class OAuthUtils {
const url = new URL(endpointUrl);
return `${url.protocol}//${url.host}${url.pathname}`;
}
/**
* Parses a JWT string to extract its expiry time.
* @param idToken The JWT ID token.
* @returns The expiry time in **milliseconds**, or undefined if parsing fails.
*/
static parseTokenExpiry(idToken: string): number | undefined {
try {
const payload = JSON.parse(
Buffer.from(idToken.split('.')[1], 'base64').toString(),
);
if (payload && typeof payload.exp === 'number') {
return payload.exp * 1000; // Convert seconds to milliseconds
}
} catch (e) {
console.error('Failed to parse ID token for expiry time with error:', e);
}
// Return undefined if try block fails or 'exp' is missing/invalid
return undefined;
}
}
@@ -11,11 +11,10 @@ import type {
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { GoogleAuth } from 'google-auth-library';
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
import type { MCPServerConfig } from '../config/config.js';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
const fiveMinBufferMs = 5 * 60 * 1000;
function createIamApiUrl(targetSA: string): string {
return `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(targetSA)}:generateIdToken`;
}
@@ -78,7 +77,7 @@ export class ServiceAccountImpersonationProvider
if (
this.cachedToken &&
this.tokenExpiryTime &&
Date.now() < this.tokenExpiryTime - fiveMinBufferMs
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
) {
return this.cachedToken;
}
@@ -112,7 +111,7 @@ export class ServiceAccountImpersonationProvider
return undefined;
}
const expiryTime = this.parseTokenExpiry(idToken);
const expiryTime = OAuthUtils.parseTokenExpiry(idToken);
// Note: We are placing the OIDC ID Token into the `access_token` field.
// This is because the CLI uses this field to construct the
// `Authorization: Bearer <token>` header, which is the correct way to
@@ -146,26 +145,4 @@ export class ServiceAccountImpersonationProvider
// No-op
return '';
}
/**
* Parses a JWT string to extract its expiry time.
* @param idToken The JWT ID token.
* @returns The expiry time in **milliseconds**, or undefined if parsing fails.
*/
private parseTokenExpiry(idToken: string): number | undefined {
try {
const payload = JSON.parse(
Buffer.from(idToken.split('.')[1], 'base64').toString(),
);
if (payload && typeof payload.exp === 'number') {
return payload.exp * 1000; // Convert seconds to milliseconds
}
} catch (e) {
console.error('Failed to parse ID token for expiry time with error:', e);
}
// Return undefined if try block fails or 'exp' is missing/invalid
return undefined;
}
}
+26 -37
View File
@@ -201,23 +201,14 @@ describe('FileExclusions', () => {
});
describe('BINARY_EXTENSIONS', () => {
it('should include common binary file extensions', () => {
expect(BINARY_EXTENSIONS).toContain('.exe');
expect(BINARY_EXTENSIONS).toContain('.dll');
expect(BINARY_EXTENSIONS).toContain('.jar');
expect(BINARY_EXTENSIONS).toContain('.zip');
});
it('should include additional binary extensions', () => {
expect(BINARY_EXTENSIONS).toContain('.dat');
expect(BINARY_EXTENSIONS).toContain('.obj');
expect(BINARY_EXTENSIONS).toContain('.wasm');
});
it('should include media file extensions', () => {
expect(BINARY_EXTENSIONS).toContain('.pdf');
expect(BINARY_EXTENSIONS).toContain('.png');
expect(BINARY_EXTENSIONS).toContain('.jpg');
it.each([
['common binary file extensions', ['.exe', '.dll', '.jar', '.zip']],
['additional binary extensions', ['.dat', '.obj', '.wasm']],
['media file extensions', ['.pdf', '.png', '.jpg']],
])('should include %s', (_, extensions) => {
extensions.forEach((ext) => {
expect(BINARY_EXTENSIONS).toContain(ext);
});
});
it('should be sorted', () => {
@@ -235,11 +226,25 @@ describe('BINARY_EXTENSIONS', () => {
});
describe('extractExtensionsFromPatterns', () => {
it('should extract simple extensions', () => {
const patterns = ['**/*.exe', '**/*.jar', '**/*.zip'];
it.each([
[
'simple extensions',
['**/*.exe', '**/*.jar', '**/*.zip'],
['.exe', '.jar', '.zip'],
],
[
'compound extensions',
['**/*.tar.gz', '**/*.min.js', '**/*.d.ts'],
['.gz', '.js', '.ts'],
],
[
'dotfiles',
['**/*.gitignore', '**/*.profile', '**/*.bashrc'],
['.bashrc', '.gitignore', '.profile'],
],
])('should extract %s', (_, patterns, expected) => {
const result = extractExtensionsFromPatterns(patterns);
expect(result).toEqual(['.exe', '.jar', '.zip']);
expect(result).toEqual(expected);
});
it('should handle brace expansion patterns', () => {
@@ -293,22 +298,6 @@ describe('extractExtensionsFromPatterns', () => {
expect(result).toEqual(['.css', '.html', '.js', '.jsx', '.ts', '.tsx']);
});
it('should handle compound extensions correctly using path.extname', () => {
const patterns = ['**/*.tar.gz', '**/*.min.js', '**/*.d.ts'];
const result = extractExtensionsFromPatterns(patterns);
// Should extract the final extension part only
expect(result).toEqual(['.gz', '.js', '.ts']);
});
it('should handle dotfiles correctly', () => {
const patterns = ['**/*.gitignore', '**/*.profile', '**/*.bashrc'];
const result = extractExtensionsFromPatterns(patterns);
// Dotfiles should be extracted properly
expect(result).toEqual(['.bashrc', '.gitignore', '.profile']);
});
it('should handle edge cases with path.extname', () => {
const patterns = ['**/*.hidden.', '**/*.config.json'];
const result = extractExtensionsFromPatterns(patterns);
+33 -90
View File
@@ -8,76 +8,31 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { escapePath, unescapePath, isSubpath } from './paths.js';
describe('escapePath', () => {
it('should escape spaces', () => {
expect(escapePath('my file.txt')).toBe('my\\ file.txt');
});
it('should escape tabs', () => {
expect(escapePath('file\twith\ttabs.txt')).toBe('file\\\twith\\\ttabs.txt');
});
it('should escape parentheses', () => {
expect(escapePath('file(1).txt')).toBe('file\\(1\\).txt');
});
it('should escape square brackets', () => {
expect(escapePath('file[backup].txt')).toBe('file\\[backup\\].txt');
});
it('should escape curly braces', () => {
expect(escapePath('file{temp}.txt')).toBe('file\\{temp\\}.txt');
});
it('should escape semicolons', () => {
expect(escapePath('file;name.txt')).toBe('file\\;name.txt');
});
it('should escape ampersands', () => {
expect(escapePath('file&name.txt')).toBe('file\\&name.txt');
});
it('should escape pipes', () => {
expect(escapePath('file|name.txt')).toBe('file\\|name.txt');
});
it('should escape asterisks', () => {
expect(escapePath('file*.txt')).toBe('file\\*.txt');
});
it('should escape question marks', () => {
expect(escapePath('file?.txt')).toBe('file\\?.txt');
});
it('should escape dollar signs', () => {
expect(escapePath('file$name.txt')).toBe('file\\$name.txt');
});
it('should escape backticks', () => {
expect(escapePath('file`name.txt')).toBe('file\\`name.txt');
});
it('should escape single quotes', () => {
expect(escapePath("file'name.txt")).toBe("file\\'name.txt");
});
it('should escape double quotes', () => {
expect(escapePath('file"name.txt')).toBe('file\\"name.txt');
});
it('should escape hash symbols', () => {
expect(escapePath('file#name.txt')).toBe('file\\#name.txt');
});
it('should escape exclamation marks', () => {
expect(escapePath('file!name.txt')).toBe('file\\!name.txt');
});
it('should escape tildes', () => {
expect(escapePath('file~name.txt')).toBe('file\\~name.txt');
});
it('should escape less than and greater than signs', () => {
expect(escapePath('file<name>.txt')).toBe('file\\<name\\>.txt');
it.each([
['spaces', 'my file.txt', 'my\\ file.txt'],
['tabs', 'file\twith\ttabs.txt', 'file\\\twith\\\ttabs.txt'],
['parentheses', 'file(1).txt', 'file\\(1\\).txt'],
['square brackets', 'file[backup].txt', 'file\\[backup\\].txt'],
['curly braces', 'file{temp}.txt', 'file\\{temp\\}.txt'],
['semicolons', 'file;name.txt', 'file\\;name.txt'],
['ampersands', 'file&name.txt', 'file\\&name.txt'],
['pipes', 'file|name.txt', 'file\\|name.txt'],
['asterisks', 'file*.txt', 'file\\*.txt'],
['question marks', 'file?.txt', 'file\\?.txt'],
['dollar signs', 'file$name.txt', 'file\\$name.txt'],
['backticks', 'file`name.txt', 'file\\`name.txt'],
['single quotes', "file'name.txt", "file\\'name.txt"],
['double quotes', 'file"name.txt', 'file\\"name.txt'],
['hash symbols', 'file#name.txt', 'file\\#name.txt'],
['exclamation marks', 'file!name.txt', 'file\\!name.txt'],
['tildes', 'file~name.txt', 'file\\~name.txt'],
[
'less than and greater than signs',
'file<name>.txt',
'file\\<name\\>.txt',
],
])('should escape %s', (_, input, expected) => {
expect(escapePath(input)).toBe(expected);
});
it('should handle multiple special characters', () => {
@@ -135,26 +90,14 @@ describe('escapePath', () => {
});
describe('unescapePath', () => {
it('should unescape spaces', () => {
expect(unescapePath('my\\ file.txt')).toBe('my file.txt');
});
it('should unescape tabs', () => {
expect(unescapePath('file\\\twith\\\ttabs.txt')).toBe(
'file\twith\ttabs.txt',
);
});
it('should unescape parentheses', () => {
expect(unescapePath('file\\(1\\).txt')).toBe('file(1).txt');
});
it('should unescape square brackets', () => {
expect(unescapePath('file\\[backup\\].txt')).toBe('file[backup].txt');
});
it('should unescape curly braces', () => {
expect(unescapePath('file\\{temp\\}.txt')).toBe('file{temp}.txt');
it.each([
['spaces', 'my\\ file.txt', 'my file.txt'],
['tabs', 'file\\\twith\\\ttabs.txt', 'file\twith\ttabs.txt'],
['parentheses', 'file\\(1\\).txt', 'file(1).txt'],
['square brackets', 'file\\[backup\\].txt', 'file[backup].txt'],
['curly braces', 'file\\{temp\\}.txt', 'file{temp}.txt'],
])('should unescape %s', (_, input, expected) => {
expect(unescapePath(input)).toBe(expected);
});
it('should unescape multiple special characters', () => {