diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index fe4c52292a..1cab2abaa9 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -121,6 +121,7 @@ jobs: 'area/security', 'area/platform', 'area/extensions', + 'area/documentation', 'area/unknown' ]; const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name)); @@ -255,6 +256,14 @@ jobs: "Issues with a specific extension." "Feature request for the extension ecosystem." + area/documentation + - Description: Issues related to user-facing documentation and other content on the documentation website. + - Example Issues: + "A typo in a README file." + "DOCS: A command is not working as described in the documentation." + "A request for a new documentation page." + "Instructions missing for skills feature" + area/unknown - Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate. diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 25b0cdf4ec..50dd56883e 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -63,7 +63,7 @@ jobs: echo '🔍 Finding issues missing area labels...' NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)" + --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)" echo '🔍 Finding issues missing kind labels...' NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ @@ -204,6 +204,7 @@ jobs: Categorization Guidelines (Area): area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality area/core: User Interface, OS Support, Core Functionality + area/documentation: End-user and contributor-facing documentation, website-related area/enterprise: Telemetry, Policy, Quota / Licensing area/extensions: Gemini CLI extensions capability area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 61d4a5c040..8b8f592335 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -4,6 +4,10 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. +> **Note:** Looking for a high-level comparison of all available subscriptions? +> To compare features and find the right quota for your needs, see our +> [Plans page](/plans/). + For most users, we recommend starting Gemini CLI and logging in with your personal Google account. diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index a5eed9ab1d..bc83d990d5 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -39,6 +39,10 @@ When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. +> **Note:** Looking to upgrade for higher limits? To compare subscription +> options and find the right quota for your needs, see our +> [Plans page](/plans/). + Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see a message prompting fallback to Gemini 2.5 Flash. diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md index d4ed22a1cb..62258bae09 100644 --- a/docs/resources/quota-and-pricing.md +++ b/docs/resources/quota-and-pricing.md @@ -1,14 +1,13 @@ # Gemini CLI: Quotas and pricing Gemini CLI offers a generous free tier that covers many individual developers' -use cases. For enterprise or professional usage, or if you need higher limits, +use cases. For enterprise or professional usage, or if you need increased quota, several options are available depending on your authentication account type. -See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and -Terms of Service. +For a high-level comparison of available subscriptions and to select the right +quota for your needs, see the [Plans page](/plans/). -> **Note:** Published prices are list price; additional negotiated commercial -> discounting may apply. +## Overview This article outlines the specific quotas and pricing applicable to Gemini CLI when using different authentication methods. @@ -23,10 +22,11 @@ Generally, there are three categories to choose from: ## Free usage -Your journey begins with a generous free tier, perfect for experimentation and -light use. +Access to Gemini CLI begins with a generous free tier, perfect for +experimentation and light use. -Your free usage limits depend on your authorization type. +Your free usage is governed by the following limits, which depend on your +authorization type. ### Log in with Google (Gemini Code Assist for individuals) @@ -78,14 +78,12 @@ Gemini CLI by upgrading to one of the following subscriptions: Learn more at [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas) -- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview) - by signing up in the Google Cloud console. Learn more at - [Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini). +- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview). Quotas and pricing are based on a fixed price subscription with assigned license seats. For predictable costs, you can sign in with Google. - This includes: + This includes the following request limits: - Gemini Code Assist Standard edition: - 1500 model requests / user / day - 120 model requests / user / minute @@ -106,18 +104,27 @@ recommended path for uninterrupted access. To do this, log in using a Gemini API key or Vertex AI. -- Vertex AI (Regular Mode): - - Quota: Governed by a dynamic shared quota system or pre-purchased - provisioned throughput. - - Cost: Based on model and token usage. +### Vertex AI (regular mode) + +An enterprise-grade platform for building, deploying, and managing AI models, +including Gemini. It offers enhanced security, data governance, and integration +with other Google Cloud services. + +- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned + throughput. +- Cost: Based on model and token usage. Learn more at [Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota) and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). -- Gemini API key: - - Quota: Varies by pricing tier. - - Cost: Varies by pricing tier and model/token usage. +### Gemini API key + +Ideal for developers who want to quickly build applications with the Gemini +models. This is the most direct way to use the models. + +- Quota: Varies by pricing tier. +- Cost: Varies by pricing tier and model/token usage. Learn more at [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits), @@ -125,7 +132,8 @@ Learn more at It’s important to highlight that when using an API key, you pay per token/call. This can be more expensive for many small calls with few tokens, but it's the -only way to ensure your workflow isn't interrupted by quota limits. +only way to ensure your workflow isn't interrupted by reaching a limit on your +quota. ## Gemini for workspace plans @@ -135,12 +143,12 @@ Flow video editor). These plans do not apply to the API usage which powers the Gemini CLI. Supporting these plans is under active consideration for future support. -## Check usage and quota +## Check usage and limits -You can check your current token usage and quota information using the +You can check your current token usage and applicable limits using the `/stats model` command. This command provides a snapshot of your current -session's token usage, as well as your overall quota and usage for the supported -models. +session's token usage, as well as information about the limits associated with +your current quota. For more information on the `/stats` command and its subcommands, see the [Command Reference](../../reference/commands.md#stats). @@ -149,17 +157,16 @@ A summary of model usage is also presented on exit at the end of a session. ## Tips to avoid high costs -When using a Pay as you Go API key, be mindful of your usage to avoid unexpected +When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected costs. -- Don't blindly accept every suggestion, especially for computationally - intensive tasks like refactoring large codebases. -- Be intentional with your prompts and commands. You are paying per call, so - think about the most efficient way to get the job done. - -## Gemini API vs. Vertex - -- Gemini API (gemini developer api): This is the fastest way to use the Gemini - models directly. -- Vertex AI: This is the enterprise-grade platform for building, deploying, and - managing Gemini models with specific security and control requirements. +- **Be selective with suggestions**: Before accepting a suggestion, especially + for a computationally intensive task like refactoring a large codebase, + consider if it's the most cost-effective approach. +- **Use precise prompts**: You are paying per call, so think about the most + efficient way to get your desired result. A well-crafted prompt can often get + you the answer you need in a single call, rather than multiple back-and-forth + interactions. +- **Monitor your usage**: Use the `/stats model` command to track your token + usage during a session. This can help you stay aware of your spending in real + time. diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 02515815d0..0c90ad2b0f 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApprovalMode, PolicyDecision, @@ -29,6 +29,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); describe('Policy Engine Integration Tests', () => { + beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', '')); + + afterEach(() => vi.unstubAllEnvs()); + describe('Policy configuration produces valid PolicyEngine config', () => { it('should create a working PolicyEngine from basic settings', async () => { const settings: Settings = { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index ad057ca8c2..6f2cd9ab7a 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -37,6 +37,7 @@ import { import type { Key } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core'; @@ -494,7 +495,7 @@ export const InputPrompt: React.FC = ({ buffer.insert(textToInsert, { paste: true }); if (isLargePaste(textToInsert)) { appEvents.emit(AppEvent.TransientMessage, { - message: 'Press Ctrl+O to expand pasted text', + message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`, type: TransientMessageType.Hint, }); } @@ -730,7 +731,7 @@ export const InputPrompt: React.FC = ({ buffer.handleInput(key); if (key.sequence && isLargePaste(key.sequence)) { appEvents.emit(AppEvent.TransientMessage, { - message: 'Press Ctrl+O to expand pasted text', + message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`, type: TransientMessageType.Hint, }); } diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 233f905760..76b8f95ce7 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -137,6 +137,14 @@ describe('', () => { { status: CoreToolCallStatus.Error, resultDisplay: 'Error output' }, undefined, ], + [ + 'renders in Cancelled state with partial output', + { + status: CoreToolCallStatus.Cancelled, + resultDisplay: 'Partial output before cancellation', + }, + undefined, + ], [ 'renders in Alternate Buffer mode while focused', { diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index b51d7c435b..3fa8a62bf8 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -309,6 +309,14 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi " `; +exports[` > Snapshots > renders in Cancelled state with partial output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ - Shell Command A shell command │ +│ │ +│ Partial output before cancellation │ +" +`; + exports[` > Snapshots > renders in Error state 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ x Shell Command A shell command │ diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index c10104591d..05cef4fcf2 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -25,6 +25,7 @@ import { } from '../../utils/textUtils.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; +import { formatCommand } from '../../utils/keybindingUtils.js'; /** * Represents a single item in the settings dialog. @@ -625,7 +626,7 @@ export function BaseSettingsDialog({ {/* Help text */} - (Use Enter to select, Ctrl+L to reset + (Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 438251ed1f..699503c23f 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -159,7 +159,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -333,7 +333,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -439,8 +439,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -614,7 +614,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -765,7 +765,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -1132,8 +1132,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -1245,8 +1245,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -1366,8 +1366,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -1492,8 +1492,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -1663,7 +1663,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -1814,7 +1814,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -1969,7 +1969,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2124,7 +2124,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2275,7 +2275,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2418,7 +2418,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2568,7 +2568,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2719,7 +2719,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -2825,8 +2825,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -2939,8 +2939,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -3111,7 +3111,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -3262,7 +3262,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -3525,7 +3525,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -3676,7 +3676,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. @@ -3782,8 +3782,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. ## Tool Usage - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. + - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. - **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 6d65596ce4..4759677696 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -599,24 +599,24 @@ describe('Core System Prompt (prompts.ts)', () => { expect(prompt).not.toContain('via `&`'); }); - it("should include 'ctrl + f' instructions when interactive shell is enabled", () => { + it("should include 'tab' instructions when interactive shell is enabled", () => { vi.mocked(mockConfig.getActiveModel).mockReturnValue( PREVIEW_GEMINI_MODEL, ); vi.mocked(mockConfig.isInteractive).mockReturnValue(true); vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(true); const prompt = getCoreSystemPrompt(mockConfig); - expect(prompt).toContain('ctrl + f'); + expect(prompt).toContain('tab'); }); - it("should NOT include 'ctrl + f' instructions when interactive shell is disabled", () => { + it("should NOT include 'tab' instructions when interactive shell is disabled", () => { vi.mocked(mockConfig.getActiveModel).mockReturnValue( PREVIEW_GEMINI_MODEL, ); vi.mocked(mockConfig.isInteractive).mockReturnValue(true); vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(false); const prompt = getCoreSystemPrompt(mockConfig); - expect(prompt).not.toContain('ctrl + f'); + expect(prompt).not.toContain('`tab`'); }); }); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 95cec40f50..6aaafa6054 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as http from 'node:http'; import * as crypto from 'node:crypto'; -import type * as net from 'node:net'; import { URL } from 'node:url'; import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import type { OAuthToken } from './token-storage/types.js'; @@ -16,6 +14,23 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js'; import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { getConsentForOauth } from '../utils/authConsent.js'; +import { + generatePKCEParams, + startCallbackServer, + getPortFromUrl, + buildAuthorizationUrl, + exchangeCodeForToken, + refreshAccessToken as refreshAccessTokenShared, + REDIRECT_PATH, + type OAuthFlowConfig, + type OAuthTokenResponse, +} from '../utils/oauth-flow.js'; + +// Re-export types that were moved to oauth-flow.ts for backward compatibility. +export type { + OAuthAuthorizationResponse, + OAuthTokenResponse, +} from '../utils/oauth-flow.js'; /** * OAuth configuration for an MCP server. @@ -34,25 +49,6 @@ export interface MCPOAuthConfig { registrationUrl?: string; } -/** - * OAuth authorization response. - */ -export interface OAuthAuthorizationResponse { - code: string; - state: string; -} - -/** - * OAuth token response from the authorization server. - */ -export interface OAuthTokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - scope?: string; -} - /** * Dynamic client registration request (RFC 7591). */ @@ -80,18 +76,6 @@ export interface OAuthClientRegistrationResponse { scope?: string; } -/** - * PKCE (Proof Key for Code Exchange) parameters. - */ -interface PKCEParams { - codeVerifier: string; - codeChallenge: string; - state: string; -} - -const REDIRECT_PATH = '/oauth/callback'; -const HTTP_OK = 200; - /** * Provider for handling OAuth authentication for MCP servers. */ @@ -239,375 +223,18 @@ export class MCPOAuthProvider { } /** - * Generate PKCE parameters for OAuth flow. - * - * @returns PKCE parameters including code verifier, challenge, and state + * Build the OAuth resource parameter from an MCP server URL, if available. + * Returns undefined if the URL is not provided or cannot be processed. */ - private generatePKCEParams(): PKCEParams { - // Generate code verifier (43-128 characters) - // using 64 bytes results in ~86 characters, safely above the minimum of 43 - const codeVerifier = crypto.randomBytes(64).toString('base64url'); - - // Generate code challenge using SHA256 - const codeChallenge = crypto - .createHash('sha256') - .update(codeVerifier) - .digest('base64url'); - - // Generate state for CSRF protection - const state = crypto.randomBytes(16).toString('base64url'); - - return { codeVerifier, codeChallenge, state }; - } - - /** - * Start a local HTTP server to handle OAuth callback. - * The server will listen on the specified port (or port 0 for OS assignment). - * - * @param expectedState The state parameter to validate - * @returns Object containing the port (available immediately) and a promise for the auth response - */ - private startCallbackServer( - expectedState: string, - port?: number, - ): { - port: Promise; - response: Promise; - } { - let portResolve: (port: number) => void; - let portReject: (error: Error) => void; - const portPromise = new Promise((resolve, reject) => { - portResolve = resolve; - portReject = reject; - }); - - const responsePromise = new Promise( - (resolve, reject) => { - let serverPort: number; - - const server = http.createServer( - async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - const url = new URL(req.url!, `http://localhost:${serverPort}`); - - if (url.pathname !== REDIRECT_PATH) { - res.writeHead(404); - res.end('Not found'); - return; - } - - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - - if (error) { - res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authentication Failed

-

Error: ${error.replace(//g, '>')}

-

${(url.searchParams.get('error_description') || '').replace(//g, '>')}

-

You can close this window.

- - - `); - server.close(); - reject(new Error(`OAuth error: ${error}`)); - return; - } - - if (!code || !state) { - res.writeHead(400); - res.end('Missing code or state parameter'); - return; - } - - if (state !== expectedState) { - res.writeHead(400); - res.end('Invalid state parameter'); - server.close(); - reject(new Error('State mismatch - possible CSRF attack')); - return; - } - - // Send success response to browser - res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); - res.end(` - - -

Authentication Successful!

-

You can close this window and return to Gemini CLI.

- - - - `); - - server.close(); - resolve({ code, state }); - } catch (error) { - server.close(); - reject(error); - } - }, - ); - - server.on('error', (error) => { - portReject(error); - reject(error); - }); - - // Determine which port to use (env var, argument, or OS-assigned) - let listenPort = 0; // Default to OS-assigned port - - const portStr = process.env['OAUTH_CALLBACK_PORT']; - if (portStr) { - const envPort = parseInt(portStr, 10); - if (isNaN(envPort) || envPort <= 0 || envPort > 65535) { - const error = new Error( - `Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`, - ); - portReject(error); - reject(error); - return; - } - listenPort = envPort; - } else if (port !== undefined) { - listenPort = port; - } - - server.listen(listenPort, () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const address = server.address() as net.AddressInfo; - serverPort = address.port; - debugLogger.log( - `OAuth callback server listening on port ${serverPort}`, - ); - portResolve(serverPort); // Resolve port promise immediately - }); - - // Timeout after 5 minutes - setTimeout( - () => { - server.close(); - reject(new Error('OAuth callback timeout')); - }, - 5 * 60 * 1000, - ); - }, - ); - - return { port: portPromise, response: responsePromise }; - } - - /** - * Extract the port number from a URL string if available and valid. - * - * @param urlString The URL string to parse - * @returns The port number or undefined if not found or invalid - */ - private getPortFromUrl(urlString?: string): number | undefined { - if (!urlString) { - return undefined; - } - + private buildResourceParam(mcpServerUrl?: string): string | undefined { + if (!mcpServerUrl) return undefined; try { - const url = new URL(urlString); - if (url.port) { - const parsedPort = parseInt(url.port, 10); - if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { - return parsedPort; - } - } - } catch { - // Ignore invalid URL - } - - return undefined; - } - - /** - * Build the authorization URL for the OAuth flow. - - * - * @param config OAuth configuration - * @param pkceParams PKCE parameters - * @param redirectPort The port to use for the redirect URI - * @param mcpServerUrl The MCP server URL to use as the resource parameter - * @returns The authorization URL - */ - private buildAuthorizationUrl( - config: MCPOAuthConfig, - pkceParams: PKCEParams, - redirectPort: number, - mcpServerUrl?: string, - ): string { - const redirectUri = - config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; - - const params = new URLSearchParams({ - client_id: config.clientId!, - response_type: 'code', - redirect_uri: redirectUri, - state: pkceParams.state, - code_challenge: pkceParams.codeChallenge, - code_challenge_method: 'S256', - }); - - if (config.scopes && config.scopes.length > 0) { - params.append('scope', config.scopes.join(' ')); - } - - if (config.audiences && config.audiences.length > 0) { - params.append('audience', config.audiences.join(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(mcpServerUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - const url = new URL(config.authorizationUrl!); - params.forEach((value, key) => { - url.searchParams.append(key, value); - }); - return url.toString(); - } - - /** - * Exchange authorization code for tokens. - * - * @param config OAuth configuration - * @param code Authorization code - * @param codeVerifier PKCE code verifier - * @param redirectPort The port to use for the redirect URI - * @param mcpServerUrl The MCP server URL to use as the resource parameter - * @returns The token response - */ - private async exchangeCodeForToken( - config: MCPOAuthConfig, - code: string, - codeVerifier: string, - redirectPort: number, - mcpServerUrl?: string, - ): Promise { - const redirectUri = - config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; - - const params = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - client_id: config.clientId!, - }); - - if (config.clientSecret) { - params.append('client_secret', config.clientSecret); - } - - if (config.audiences && config.audiences.length > 0) { - params.append('audience', config.audiences.join(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - const resourceUrl = mcpServerUrl; - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(resourceUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - const response = await fetch(config.tokenUrl!, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, application/x-www-form-urlencoded', - }, - body: params.toString(), - }); - - const responseText = await response.text(); - const contentType = response.headers.get('content-type') || ''; - - if (!response.ok) { - // Try to parse error from form-urlencoded response - let errorMessage: string | null = null; - try { - const errorParams = new URLSearchParams(responseText); - const error = errorParams.get('error'); - const errorDescription = errorParams.get('error_description'); - if (error) { - errorMessage = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`; - } - } catch { - // Fall back to raw error - } - throw new Error( - errorMessage || - `Token exchange failed: ${response.status} - ${responseText}`, - ); - } - - // Log unexpected content types for debugging - if ( - !contentType.includes('application/json') && - !contentType.includes('application/x-www-form-urlencoded') - ) { + return OAuthUtils.buildResourceParameter(mcpServerUrl); + } catch (error) { debugLogger.warn( - `Token endpoint returned unexpected content-type: ${contentType}. ` + - `Expected application/json or application/x-www-form-urlencoded. ` + - `Will attempt to parse response.`, + `Could not add resource parameter: ${getErrorMessage(error)}`, ); - } - - // Try to parse as JSON first, fall back to form-urlencoded - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(responseText) as OAuthTokenResponse; - } catch { - // Parse form-urlencoded response - const tokenParams = new URLSearchParams(responseText); - const accessToken = tokenParams.get('access_token'); - const tokenType = tokenParams.get('token_type') || 'Bearer'; - const expiresIn = tokenParams.get('expires_in'); - const refreshToken = tokenParams.get('refresh_token'); - const scope = tokenParams.get('scope'); - - if (!accessToken) { - // Check for error in response - const error = tokenParams.get('error'); - const errorDescription = tokenParams.get('error_description'); - throw new Error( - `Token exchange failed: ${error || 'no_access_token'} - ${errorDescription || responseText}`, - ); - } - - return { - access_token: accessToken, - token_type: tokenType, - expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, - refresh_token: refreshToken || undefined, - scope: scope || undefined, - } as OAuthTokenResponse; + return undefined; } } @@ -626,112 +253,21 @@ export class MCPOAuthProvider { tokenUrl: string, mcpServerUrl?: string, ): Promise { - const params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: config.clientId!, - }); - - if (config.clientSecret) { - params.append('client_secret', config.clientSecret); + if (!config.clientId) { + throw new Error('Missing required clientId for token refresh'); } - if (config.scopes && config.scopes.length > 0) { - params.append('scope', config.scopes.join(' ')); - } - - if (config.audiences && config.audiences.length > 0) { - params.append('audience', config.audiences.join(' ')); - } - - // Add resource parameter for MCP OAuth spec compliance - // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) - if (mcpServerUrl) { - try { - params.append( - 'resource', - OAuthUtils.buildResourceParameter(mcpServerUrl), - ); - } catch (error) { - debugLogger.warn( - `Could not add resource parameter: ${getErrorMessage(error)}`, - ); - } - } - - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, application/x-www-form-urlencoded', + return refreshAccessTokenShared( + { + clientId: config.clientId, + clientSecret: config.clientSecret, + scopes: config.scopes, + audiences: config.audiences, }, - body: params.toString(), - }); - - const responseText = await response.text(); - const contentType = response.headers.get('content-type') || ''; - - if (!response.ok) { - // Try to parse error from form-urlencoded response - let errorMessage: string | null = null; - try { - const errorParams = new URLSearchParams(responseText); - const error = errorParams.get('error'); - const errorDescription = errorParams.get('error_description'); - if (error) { - errorMessage = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`; - } - } catch { - // Fall back to raw error - } - throw new Error( - errorMessage || - `Token refresh failed: ${response.status} - ${responseText}`, - ); - } - - // Log unexpected content types for debugging - if ( - !contentType.includes('application/json') && - !contentType.includes('application/x-www-form-urlencoded') - ) { - debugLogger.warn( - `Token refresh endpoint returned unexpected content-type: ${contentType}. ` + - `Expected application/json or application/x-www-form-urlencoded. ` + - `Will attempt to parse response.`, - ); - } - - // Try to parse as JSON first, fall back to form-urlencoded - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(responseText) as OAuthTokenResponse; - } catch { - // Parse form-urlencoded response - const tokenParams = new URLSearchParams(responseText); - const accessToken = tokenParams.get('access_token'); - const tokenType = tokenParams.get('token_type') || 'Bearer'; - const expiresIn = tokenParams.get('expires_in'); - const refreshToken = tokenParams.get('refresh_token'); - const scope = tokenParams.get('scope'); - - if (!accessToken) { - // Check for error in response - const error = tokenParams.get('error'); - const errorDescription = tokenParams.get('error_description'); - throw new Error( - `Token refresh failed: ${error || 'unknown_error'} - ${errorDescription || responseText}`, - ); - } - - return { - access_token: accessToken, - token_type: tokenType, - expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, - refresh_token: refreshToken || undefined, - scope: scope || undefined, - } as OAuthTokenResponse; - } + refreshToken, + tokenUrl, + this.buildResourceParam(mcpServerUrl), + ); } /** @@ -830,17 +366,14 @@ export class MCPOAuthProvider { } // Generate PKCE parameters - const pkceParams = this.generatePKCEParams(); + const pkceParams = generatePKCEParams(); // Determine preferred port from redirectUri if available - const preferredPort = this.getPortFromUrl(config.redirectUri); + const preferredPort = getPortFromUrl(config.redirectUri); // Start callback server first to allocate port // This ensures we only create one server and eliminates race conditions - const callbackServer = this.startCallbackServer( - pkceParams.state, - preferredPort, - ); + const callbackServer = startCallbackServer(pkceParams.state, preferredPort); // Wait for server to start and get the allocated port // We need this port for client registration and auth URL building @@ -892,12 +425,24 @@ export class MCPOAuthProvider { ); } + // Build flow config for shared utilities + const flowConfig: OAuthFlowConfig = { + clientId: config.clientId, + clientSecret: config.clientSecret, + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + scopes: config.scopes, + audiences: config.audiences, + redirectUri: config.redirectUri, + }; + // Build authorization URL - const authUrl = this.buildAuthorizationUrl( - config, + const resource = this.buildResourceParam(mcpServerUrl); + const authUrl = buildAuthorizationUrl( + flowConfig, pkceParams, redirectPort, - mcpServerUrl, + resource, ); const userConsent = await getConsentForOauth( @@ -933,12 +478,12 @@ ${authUrl} ); // Exchange code for tokens - const tokenResponse = await this.exchangeCodeForToken( - config, + const tokenResponse = await exchangeCodeForToken( + flowConfig, code, pkceParams.codeVerifier, redirectPort, - mcpServerUrl, + resource, ); // Convert to our token format diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 3671490089..227b06be45 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -15,6 +15,7 @@ import { GREP_TOOL_NAME, MEMORY_TOOL_NAME, READ_FILE_TOOL_NAME, + SHELL_PARAM_IS_BACKGROUND, SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, WRITE_TODOS_TOOL_NAME, @@ -599,12 +600,12 @@ function toolUsageInteractive( interactiveShellEnabled: boolean, ): string { if (interactive) { - const ctrlF = interactiveShellEnabled - ? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.' + const focusHint = interactiveShellEnabled + ? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.' : ''; return ` -- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${ctrlF}`; + - **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. + - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`; } return ` - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index bebd3c9146..2e889dac6d 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -665,12 +665,12 @@ function toolUsageInteractive( interactiveShellEnabled: boolean, ): string { if (interactive) { - const ctrlF = interactiveShellEnabled - ? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.' + const focusHint = interactiveShellEnabled + ? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.' : ''; return ` - **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. If unsure, ask the user. -- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${ctrlF}`; +- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`; } return ` - **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index 005f3004d6..428b7f87a8 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -477,13 +477,24 @@ export class SchedulerStateManager { } } + // Capture any existing live output so it isn't lost when forcing cancellation. + let existingOutput: ToolResultDisplay | undefined = undefined; + if (call.status === CoreToolCallStatus.Executing && call.liveOutput) { + existingOutput = call.liveOutput; + } + if (isToolCallResponseInfo(reason)) { + const finalResponse = { ...reason }; + if (!finalResponse.resultDisplay) { + finalResponse.resultDisplay = resultDisplay ?? existingOutput; + } + return { request: call.request, tool: call.tool, invocation: call.invocation, status: CoreToolCallStatus.Cancelled, - response: reason, + response: finalResponse, durationMs: startTime ? Date.now() - startTime : undefined, outcome: call.outcome, schedulerId: call.schedulerId, @@ -508,7 +519,7 @@ export class SchedulerStateManager { }, }, ], - resultDisplay, + resultDisplay: resultDisplay ?? existingOutput, error: undefined, errorType: undefined, contentLength: errorMessage.length, diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 8646a3f6d4..51c5ab382f 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -49,6 +49,7 @@ import type { ToolOutputMaskingEvent, KeychainAvailabilityEvent, TokenStorageInitializationEvent, + StartupStatsEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -117,6 +118,7 @@ export enum EventNames { TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization', CONSECA_POLICY_GENERATION = 'conseca_policy_generation', CONSECA_VERDICT = 'conseca_verdict', + STARTUP_STATS = 'startup_stats', } export interface LogResponse { @@ -1691,6 +1693,30 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logStartupStatsEvent(event: StartupStatsEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_PHASES, + value: JSON.stringify(event.phases), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_OS_PLATFORM, + value: event.os_platform, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_OS_RELEASE, + value: event.os_release, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_IS_DOCKER, + value: JSON.stringify(event.is_docker), + }, + ]; + + this.enqueueLogEvent(this.createLogEvent(EventNames.STARTUP_STATS, data)); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index d799ca1caf..43bfa3278d 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -7,7 +7,7 @@ // Defines valid event metadata keys for Clearcut logging. export enum EventMetadataKey { // Deleted enums: 24 - // Next ID: 172 + // Next ID: 176 GEMINI_CLI_KEY_UNKNOWN = 0, @@ -54,6 +54,22 @@ export enum EventMetadataKey { // Logs the output format of the session. GEMINI_CLI_START_SESSION_OUTPUT_FORMAT = 94, + // ========================================================================== + // Startup Stats Event Keys + // ========================================================================== + + // Logs the array of startup phases. + GEMINI_CLI_STARTUP_PHASES = 172, + + // Logs the OS platform for startup stats. + GEMINI_CLI_STARTUP_OS_PLATFORM = 173, + + // Logs the OS release for startup stats. + GEMINI_CLI_STARTUP_OS_RELEASE = 174, + + // Logs whether the CLI is running in docker for startup stats. + GEMINI_CLI_STARTUP_IS_DOCKER = 175, + // ========================================================================== // User Prompt Event Keys // =========================================================================== diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 4c3ed55321..393519d3ec 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -791,6 +791,7 @@ export function logStartupStats( config: Config, event: StartupStatsEvent, ): void { + ClearcutLogger.getInstance(config)?.logStartupStatsEvent(event); bufferTelemetryEvent(() => { // Wait for experiments to load before emitting so we capture experimentIds void config diff --git a/packages/core/src/utils/oauth-flow.test.ts b/packages/core/src/utils/oauth-flow.test.ts new file mode 100644 index 0000000000..dee919c249 --- /dev/null +++ b/packages/core/src/utils/oauth-flow.test.ts @@ -0,0 +1,635 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { + OAuthFlowConfig, + OAuthRefreshConfig, + PKCEParams, +} from './oauth-flow.js'; +import { + generatePKCEParams, + getPortFromUrl, + buildAuthorizationUrl, + startCallbackServer, + exchangeCodeForToken, + refreshAccessToken, + REDIRECT_PATH, +} from './oauth-flow.js'; + +// Save real fetch for startCallbackServer tests (which hit a real local server) +const realFetch = global.fetch; + +// Mock fetch globally for token exchange/refresh tests +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +/** + * Helper to create a mock Response object. + */ +function createMockResponse( + body: string, + options: { status?: number; contentType?: string } = {}, +): Response { + const { status = 200, contentType = 'application/json' } = options; + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + headers: new Headers({ 'content-type': contentType }), + } as Response; +} + +const baseConfig: OAuthFlowConfig = { + clientId: 'test-client-id', + authorizationUrl: 'https://auth.example.com/authorize', + tokenUrl: 'https://auth.example.com/token', +}; + +const basePkceParams: PKCEParams = { + codeVerifier: 'test-verifier', + codeChallenge: 'test-challenge', + state: 'test-state', +}; + +describe('oauth-flow', () => { + beforeEach(() => { + vi.stubEnv('OAUTH_CALLBACK_PORT', ''); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('generatePKCEParams', () => { + it('should return codeVerifier, codeChallenge, and state', () => { + const params = generatePKCEParams(); + expect(params).toHaveProperty('codeVerifier'); + expect(params).toHaveProperty('codeChallenge'); + expect(params).toHaveProperty('state'); + }); + + it('should generate a code verifier of at least 43 characters', () => { + const params = generatePKCEParams(); + expect(params.codeVerifier.length).toBeGreaterThanOrEqual(43); + }); + + it('should generate unique values on each call', () => { + const params1 = generatePKCEParams(); + const params2 = generatePKCEParams(); + expect(params1.codeVerifier).not.toBe(params2.codeVerifier); + expect(params1.state).not.toBe(params2.state); + }); + + it('should generate base64url-encoded values', () => { + const params = generatePKCEParams(); + const base64urlRegex = /^[A-Za-z0-9_-]+$/; + expect(params.codeVerifier).toMatch(base64urlRegex); + expect(params.codeChallenge).toMatch(base64urlRegex); + expect(params.state).toMatch(base64urlRegex); + }); + }); + + describe('getPortFromUrl', () => { + it('should return undefined for undefined input', () => { + expect(getPortFromUrl(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(getPortFromUrl('')).toBeUndefined(); + }); + + it('should return undefined for invalid URL', () => { + expect(getPortFromUrl('not-a-url')).toBeUndefined(); + }); + + it('should return the port number from a URL with an explicit port', () => { + expect(getPortFromUrl('http://localhost:8080/callback')).toBe(8080); + }); + + it('should return undefined for a URL without an explicit port', () => { + expect(getPortFromUrl('https://example.com/callback')).toBeUndefined(); + }); + + it('should return port for edge case port 1', () => { + expect(getPortFromUrl('http://localhost:1')).toBe(1); + }); + + it('should return port for edge case port 65535', () => { + expect(getPortFromUrl('http://localhost:65535')).toBe(65535); + }); + }); + + describe('buildAuthorizationUrl', () => { + it('should build a valid authorization URL with required parameters', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + + expect(parsed.origin).toBe('https://auth.example.com'); + expect(parsed.pathname).toBe('/authorize'); + expect(parsed.searchParams.get('client_id')).toBe('test-client-id'); + expect(parsed.searchParams.get('response_type')).toBe('code'); + expect(parsed.searchParams.get('state')).toBe('test-state'); + expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge'); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + }); + + it('should use the default redirect URI based on port', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('redirect_uri')).toBe( + `http://localhost:3000${REDIRECT_PATH}`, + ); + }); + + it('should use a custom redirectUri from config when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + redirectUri: 'https://custom.example.com/callback', + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'https://custom.example.com/callback', + ); + }); + + it('should include scopes when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + scopes: ['read', 'write'], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('scope')).toBe('read write'); + }); + + it('should not include scope param when scopes array is empty', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + scopes: [], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.has('scope')).toBe(false); + }); + + it('should include audiences when provided', () => { + const config: OAuthFlowConfig = { + ...baseConfig, + audiences: ['https://api.example.com'], + }; + const url = buildAuthorizationUrl(config, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.get('audience')).toBe( + 'https://api.example.com', + ); + }); + + it('should include resource parameter when provided', () => { + const url = buildAuthorizationUrl( + baseConfig, + basePkceParams, + 3000, + 'https://mcp.example.com', + ); + const parsed = new URL(url); + expect(parsed.searchParams.get('resource')).toBe( + 'https://mcp.example.com', + ); + }); + + it('should not include resource parameter when not provided', () => { + const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000); + const parsed = new URL(url); + expect(parsed.searchParams.has('resource')).toBe(false); + }); + }); + + describe('startCallbackServer', () => { + it('should start a server and resolve port', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + expect(port).toBeGreaterThan(0); + + // Make a successful callback request to close the server + const res = await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`, + ); + expect(res.status).toBe(200); + await server.response; + }); + + it('should resolve response with code and state on valid callback', async () => { + const server = startCallbackServer('my-state'); + const port = await server.port; + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=auth-code-123&state=my-state`, + ); + + const response = await server.response; + expect(response.code).toBe('auth-code-123'); + expect(response.state).toBe('my-state'); + }); + + it('should reject on state mismatch', async () => { + const server = startCallbackServer('expected-state'); + const port = await server.port; + + // Attach rejection handler BEFORE triggering the callback to prevent + // unhandled rejection race with Vitest's detection. + const responseResult = server.response.then( + () => new Error('Expected rejection'), + (e: Error) => e, + ); + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=wrong-state`, + ).catch(() => { + // Connection may be reset by server closing — expected + }); + + const error = await responseResult; + expect(error.message).toContain('State mismatch - possible CSRF attack'); + }); + + it('should reject on OAuth error in callback', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + + // Attach rejection handler BEFORE triggering the callback + const responseResult = server.response.then( + () => new Error('Expected rejection'), + (e: Error) => e, + ); + + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?error=access_denied&error_description=User+denied`, + ).catch(() => { + // Connection may be reset by server closing — expected + }); + + const error = await responseResult; + expect(error.message).toContain('OAuth error: access_denied'); + }); + + it('should return 404 for non-callback paths', async () => { + const server = startCallbackServer('test-state'); + const port = await server.port; + + const res = await realFetch(`http://localhost:${port}/other-path`); + expect(res.status).toBe(404); + + // Clean up: send valid callback to close the server + await realFetch( + `http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`, + ); + await server.response; + }); + + it('should reject when OAUTH_CALLBACK_PORT env var is invalid', async () => { + vi.stubEnv('OAUTH_CALLBACK_PORT', 'not-a-number'); + + const server = startCallbackServer('test-state'); + + await expect(server.port).rejects.toThrow( + 'Invalid value for OAUTH_CALLBACK_PORT', + ); + await expect(server.response).rejects.toThrow( + 'Invalid value for OAUTH_CALLBACK_PORT', + ); + }); + }); + + describe('exchangeCodeForToken', () => { + it('should exchange code for token with JSON response', async () => { + const tokenResponse = { + access_token: 'test-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'test-refresh-token', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify(tokenResponse)), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'auth-code', + 'code-verifier', + 3000, + ); + + expect(result.access_token).toBe('test-access-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(3600); + expect(result.refresh_token).toBe('test-refresh-token'); + }); + + it('should send correct parameters in the request body', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(baseConfig, 'my-code', 'my-verifier', 4000); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://auth.example.com/token'); + const body = new URLSearchParams(options.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('my-code'); + expect(body.get('code_verifier')).toBe('my-verifier'); + expect(body.get('client_id')).toBe('test-client-id'); + expect(body.get('redirect_uri')).toBe( + `http://localhost:4000${REDIRECT_PATH}`, + ); + }); + + it('should include client_secret when provided', async () => { + const config: OAuthFlowConfig = { + ...baseConfig, + clientSecret: 'my-secret', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(config, 'code', 'verifier', 3000); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('client_secret')).toBe('my-secret'); + }); + + it('should include resource parameter when provided', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + 'https://mcp.example.com', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('resource')).toBe('https://mcp.example.com'); + }); + + it('should handle form-urlencoded token response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'access_token=form-token&token_type=Bearer&expires_in=7200', + { contentType: 'application/x-www-form-urlencoded' }, + ), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + ); + + expect(result.access_token).toBe('form-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(7200); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse('Bad request', { status: 400 }), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('Token exchange failed'); + }); + + it('should throw on non-ok response with form-urlencoded error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'error=invalid_grant&error_description=Code+expired', + { + status: 400, + contentType: 'application/x-www-form-urlencoded', + }, + ), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('invalid_grant'); + }); + + it('should throw when JSON response has no access_token and form-urlencoded fallback also fails', async () => { + // JSON that parses but has no access_token — falls through to form-urlencoded + // which also has no access_token + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify({ error: 'server_error' })), + ); + + await expect( + exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000), + ).rejects.toThrow('Token exchange failed'); + }); + + it('should use custom redirectUri from config', async () => { + const config: OAuthFlowConfig = { + ...baseConfig, + redirectUri: 'https://custom.example.com/cb', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await exchangeCodeForToken(config, 'code', 'verifier', 3000); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('redirect_uri')).toBe('https://custom.example.com/cb'); + }); + + it('should default token_type to Bearer when missing from JSON response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify({ access_token: 'tok' })), + ); + + const result = await exchangeCodeForToken( + baseConfig, + 'code', + 'verifier', + 3000, + ); + expect(result.token_type).toBe('Bearer'); + }); + }); + + describe('refreshAccessToken', () => { + const refreshConfig: OAuthRefreshConfig = { + clientId: 'test-client-id', + }; + + it('should refresh a token with JSON response', async () => { + const tokenResponse = { + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + }; + mockFetch.mockResolvedValueOnce( + createMockResponse(JSON.stringify(tokenResponse)), + ); + + const result = await refreshAccessToken( + refreshConfig, + 'old-refresh-token', + 'https://auth.example.com/token', + ); + + expect(result.access_token).toBe('new-access-token'); + expect(result.expires_in).toBe(3600); + }); + + it('should send correct parameters in the request body', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + refreshConfig, + 'my-refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('my-refresh-token'); + expect(body.get('client_id')).toBe('test-client-id'); + }); + + it('should include client_secret when provided', async () => { + const config: OAuthRefreshConfig = { + ...refreshConfig, + clientSecret: 'secret', + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + config, + 'refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('client_secret')).toBe('secret'); + }); + + it('should include scopes and audiences when provided', async () => { + const config: OAuthRefreshConfig = { + ...refreshConfig, + scopes: ['read', 'write'], + audiences: ['https://api.example.com'], + }; + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + config, + 'refresh-token', + 'https://auth.example.com/token', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('scope')).toBe('read write'); + expect(body.get('audience')).toBe('https://api.example.com'); + }); + + it('should include resource parameter when provided', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }), + ), + ); + + await refreshAccessToken( + refreshConfig, + 'refresh-token', + 'https://auth.example.com/token', + 'https://mcp.example.com', + ); + + const body = new URLSearchParams( + (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.get('resource')).toBe('https://mcp.example.com'); + }); + + it('should throw on non-ok response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse('Unauthorized', { status: 401 }), + ); + + await expect( + refreshAccessToken( + refreshConfig, + 'bad-token', + 'https://auth.example.com/token', + ), + ).rejects.toThrow('Token refresh failed'); + }); + + it('should handle form-urlencoded token response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + 'access_token=refreshed-token&token_type=Bearer&expires_in=1800', + { contentType: 'application/x-www-form-urlencoded' }, + ), + ); + + const result = await refreshAccessToken( + refreshConfig, + 'refresh-token', + 'https://auth.example.com/token', + ); + + expect(result.access_token).toBe('refreshed-token'); + expect(result.expires_in).toBe(1800); + }); + }); +}); diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts new file mode 100644 index 0000000000..9d5e6b8357 --- /dev/null +++ b/packages/core/src/utils/oauth-flow.ts @@ -0,0 +1,515 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared OAuth 2.0 Authorization Code flow primitives with PKCE support. + * + * These utilities are protocol-agnostic and can be used by both MCP OAuth + * and A2A OAuth authentication providers. + */ + +import * as http from 'node:http'; +import * as crypto from 'node:crypto'; +import type * as net from 'node:net'; +import { URL } from 'node:url'; +import { debugLogger } from './debugLogger.js'; + +/** + * Configuration for an OAuth 2.0 Authorization Code flow. + * Contains only the fields needed by the shared flow utilities. + */ +export interface OAuthFlowConfig { + clientId: string; + clientSecret?: string; + authorizationUrl: string; + tokenUrl: string; + scopes?: string[]; + audiences?: string[]; + redirectUri?: string; +} + +/** + * Configuration subset needed for token refresh operations. + */ +export type OAuthRefreshConfig = Pick< + OAuthFlowConfig, + 'clientId' | 'clientSecret' | 'scopes' | 'audiences' +>; + +/** + * PKCE (Proof Key for Code Exchange) parameters. + */ +export interface PKCEParams { + codeVerifier: string; + codeChallenge: string; + state: string; +} + +/** + * OAuth authorization response from the callback server. + */ +export interface OAuthAuthorizationResponse { + code: string; + state: string; +} + +/** + * OAuth token response from the authorization server. + */ +export interface OAuthTokenResponse { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +/** The path the local callback server listens on. */ +export const REDIRECT_PATH = '/oauth/callback'; + +const HTTP_OK = 200; + +/** + * Generate PKCE parameters for OAuth flow. + * + * @returns PKCE parameters including code verifier, challenge, and state + */ +export function generatePKCEParams(): PKCEParams { + // Generate code verifier (43-128 characters) + // using 64 bytes results in ~86 characters, safely above the minimum of 43 + const codeVerifier = crypto.randomBytes(64).toString('base64url'); + + // Generate code challenge using SHA256 + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + // Generate state for CSRF protection + const state = crypto.randomBytes(16).toString('base64url'); + + return { codeVerifier, codeChallenge, state }; +} + +/** + * Start a local HTTP server to handle OAuth callback. + * The server will listen on the specified port (or port 0 for OS assignment). + * + * @param expectedState The state parameter to validate + * @param port Optional preferred port to listen on + * @returns Object containing the port (available immediately) and a promise for the auth response + */ +export function startCallbackServer( + expectedState: string, + port?: number, +): { + port: Promise; + response: Promise; +} { + let portResolve: (port: number) => void; + let portReject: (error: Error) => void; + const portPromise = new Promise((resolve, reject) => { + portResolve = resolve; + portReject = reject; + }); + + const responsePromise = new Promise( + (resolve, reject) => { + let serverPort: number; + + const server = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const url = new URL(req.url!, `http://localhost:${serverPort}`); + + if (url.pathname !== REDIRECT_PATH) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authentication Failed

+

Error: ${error.replace(//g, '>')}

+

${(url.searchParams.get('error_description') || '').replace(//g, '>')}

+

You can close this window.

+ + + `); + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.writeHead(400); + res.end('Missing code or state parameter'); + return; + } + + if (state !== expectedState) { + res.writeHead(400); + res.end('Invalid state parameter'); + server.close(); + reject(new Error('State mismatch - possible CSRF attack')); + return; + } + + // Send success response to browser + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authentication Successful!

+

You can close this window and return to Gemini CLI.

+ + + + `); + + server.close(); + resolve({ code, state }); + } catch (error) { + server.close(); + reject(error); + } + }, + ); + + server.on('error', (error) => { + portReject(error); + reject(error); + }); + + // Determine which port to use (env var, argument, or OS-assigned) + let listenPort = 0; // Default to OS-assigned port + + const portStr = process.env['OAUTH_CALLBACK_PORT']; + if (portStr) { + const envPort = parseInt(portStr, 10); + if (isNaN(envPort) || envPort <= 0 || envPort > 65535) { + const error = new Error( + `Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`, + ); + portReject(error); + reject(error); + return; + } + listenPort = envPort; + } else if (port !== undefined) { + listenPort = port; + } + + server.listen(listenPort, () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const address = server.address() as net.AddressInfo; + serverPort = address.port; + debugLogger.log( + `OAuth callback server listening on port ${serverPort}`, + ); + portResolve(serverPort); // Resolve port promise immediately + }); + + // Timeout after 5 minutes + setTimeout( + () => { + server.close(); + reject(new Error('OAuth callback timeout')); + }, + 5 * 60 * 1000, + ); + }, + ); + + return { port: portPromise, response: responsePromise }; +} + +/** + * Extract the port number from a URL string if available and valid. + * + * @param urlString The URL string to parse + * @returns The port number or undefined if not found or invalid + */ +export function getPortFromUrl(urlString?: string): number | undefined { + if (!urlString) { + return undefined; + } + + try { + const url = new URL(urlString); + if (url.port) { + const parsedPort = parseInt(url.port, 10); + if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { + return parsedPort; + } + } + } catch { + // Ignore invalid URL + } + + return undefined; +} + +/** + * Build the authorization URL for the OAuth flow. + * + * @param config OAuth flow configuration + * @param pkceParams PKCE parameters + * @param redirectPort The port to use for the redirect URI + * @param resource Optional resource parameter value (RFC 8707) + * @returns The authorization URL + */ +export function buildAuthorizationUrl( + config: OAuthFlowConfig, + pkceParams: PKCEParams, + redirectPort: number, + resource?: string, +): string { + const redirectUri = + config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; + + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: 'code', + redirect_uri: redirectUri, + state: pkceParams.state, + code_challenge: pkceParams.codeChallenge, + code_challenge_method: 'S256', + }); + + if (config.scopes && config.scopes.length > 0) { + params.append('scope', config.scopes.join(' ')); + } + + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + + if (resource) { + params.append('resource', resource); + } + + const url = new URL(config.authorizationUrl); + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + return url.toString(); +} + +/** + * Parse a token endpoint response, handling both JSON and form-urlencoded formats. + * + * @param response The HTTP response from the token endpoint + * @param operationName Human-readable operation name for error messages (e.g., "Token exchange", "Token refresh") + * @param defaultErrorCode Default error code when access_token is missing (e.g., "no_access_token", "unknown_error") + * @returns The parsed token response + */ +async function parseTokenEndpointResponse( + response: Response, + operationName: string, + defaultErrorCode: string, +): Promise { + const responseText = await response.text(); + const contentType = response.headers.get('content-type') || ''; + + if (!response.ok) { + // Try to parse error from form-urlencoded response + let errorMessage: string | null = null; + try { + const errorParams = new URLSearchParams(responseText); + const error = errorParams.get('error'); + const errorDescription = errorParams.get('error_description'); + if (error) { + errorMessage = `${operationName} failed: ${error} - ${errorDescription || 'No description'}`; + } + } catch { + // Fall back to raw error + } + throw new Error( + errorMessage || + `${operationName} failed: ${response.status} - ${responseText}`, + ); + } + + // Log unexpected content types for debugging + if ( + !contentType.includes('application/json') && + !contentType.includes('application/x-www-form-urlencoded') + ) { + debugLogger.warn( + `${operationName} endpoint returned unexpected content-type: ${contentType}. ` + + `Expected application/json or application/x-www-form-urlencoded. ` + + `Will attempt to parse response.`, + ); + } + + // Try to parse as JSON first, fall back to form-urlencoded + try { + const data: unknown = JSON.parse(responseText); + if ( + data && + typeof data === 'object' && + 'access_token' in data && + typeof (data as Record)['access_token'] === 'string' + ) { + const obj = data as Record; + const result: OAuthTokenResponse = { + access_token: String(obj['access_token']), + token_type: + typeof obj['token_type'] === 'string' ? obj['token_type'] : 'Bearer', + expires_in: + typeof obj['expires_in'] === 'number' ? obj['expires_in'] : undefined, + refresh_token: + typeof obj['refresh_token'] === 'string' + ? obj['refresh_token'] + : undefined, + scope: typeof obj['scope'] === 'string' ? obj['scope'] : undefined, + }; + return result; + } + // JSON parsed but doesn't look like a token response — fall through + } catch { + // Not JSON — fall through to form-urlencoded parsing + } + + // Parse form-urlencoded response + const tokenParams = new URLSearchParams(responseText); + const accessToken = tokenParams.get('access_token'); + const tokenType = tokenParams.get('token_type') || 'Bearer'; + const expiresIn = tokenParams.get('expires_in'); + const refreshToken = tokenParams.get('refresh_token'); + const scope = tokenParams.get('scope'); + + if (!accessToken) { + // Check for error in response + const error = tokenParams.get('error'); + const errorDescription = tokenParams.get('error_description'); + throw new Error( + `${operationName} failed: ${error || defaultErrorCode} - ${errorDescription || responseText}`, + ); + } + + return { + access_token: accessToken, + token_type: tokenType, + expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, + refresh_token: refreshToken || undefined, + scope: scope || undefined, + } as OAuthTokenResponse; +} + +/** + * Exchange an authorization code for tokens. + * + * @param config OAuth flow configuration + * @param code Authorization code + * @param codeVerifier PKCE code verifier + * @param redirectPort The port to use for the redirect URI + * @param resource Optional resource parameter value (RFC 8707) + * @returns The token response + */ +export async function exchangeCodeForToken( + config: OAuthFlowConfig, + code: string, + codeVerifier: string, + redirectPort: number, + resource?: string, +): Promise { + const redirectUri = + config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: config.clientId, + }); + + if (config.clientSecret) { + params.append('client_secret', config.clientSecret); + } + + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + + if (resource) { + params.append('resource', resource); + } + + const response = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + return parseTokenEndpointResponse( + response, + 'Token exchange', + 'no_access_token', + ); +} + +/** + * Refresh an access token using a refresh token. + * + * @param config OAuth configuration subset needed for refresh + * @param refreshToken The refresh token + * @param tokenUrl The token endpoint URL + * @param resource Optional resource parameter value (RFC 8707) + * @returns The new token response + */ +export async function refreshAccessToken( + config: OAuthRefreshConfig, + refreshToken: string, + tokenUrl: string, + resource?: string, +): Promise { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: config.clientId, + }); + + if (config.clientSecret) { + params.append('client_secret', config.clientSecret); + } + + if (config.scopes && config.scopes.length > 0) { + params.append('scope', config.scopes.join(' ')); + } + + if (config.audiences && config.audiences.length > 0) { + params.append('audience', config.audiences.join(' ')); + } + + if (resource) { + params.append('resource', resource); + } + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + return parseTokenEndpointResponse(response, 'Token refresh', 'unknown_error'); +}