Merge branch 'main' into acp-base-url

This commit is contained in:
Shreya Keshive
2026-03-05 13:47:17 -05:00
committed by GitHub
21 changed files with 1397 additions and 600 deletions
@@ -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 = {
@@ -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<InputPromptProps> = ({
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<InputPromptProps> = ({
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,
});
}
@@ -137,6 +137,14 @@ describe('<ShellToolMessage />', () => {
{ 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',
{
@@ -309,6 +309,14 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Cancelled state with partial output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ - Shell Command A shell command │
│ │
│ Partial output before cancellation │
"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
@@ -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 */}
<Box marginX={1}>
<Text color={theme.text.secondary}>
(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)
</Text>
</Box>
@@ -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.
+4 -4
View File
@@ -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`');
});
});
+58 -513
View File
@@ -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<number>;
response: Promise<OAuthAuthorizationResponse>;
} {
let portResolve: (port: number) => void;
let portReject: (error: Error) => void;
const portPromise = new Promise<number>((resolve, reject) => {
portResolve = resolve;
portReject = reject;
});
const responsePromise = new Promise<OAuthAuthorizationResponse>(
(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(`
<html>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>You can close this window.</p>
</body>
</html>
`);
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(`
<html>
<body>
<h1>Authentication Successful!</h1>
<p>You can close this window and return to Gemini CLI.</p>
<script>window.close();</script>
</body>
</html>
`);
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<OAuthTokenResponse> {
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<OAuthTokenResponse> {
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
+5 -4
View File
@@ -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.
+3 -3
View File
@@ -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.
+13 -2
View File
@@ -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,
@@ -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.
@@ -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
// ===========================================================================
+1
View File
@@ -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
+635
View File
@@ -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);
});
});
});
+515
View File
@@ -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<number>;
response: Promise<OAuthAuthorizationResponse>;
} {
let portResolve: (port: number) => void;
let portReject: (error: Error) => void;
const portPromise = new Promise<number>((resolve, reject) => {
portResolve = resolve;
portReject = reject;
});
const responsePromise = new Promise<OAuthAuthorizationResponse>(
(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(`
<html>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>You can close this window.</p>
</body>
</html>
`);
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(`
<html>
<body>
<h1>Authentication Successful!</h1>
<p>You can close this window and return to Gemini CLI.</p>
<script>window.close();</script>
</body>
</html>
`);
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<OAuthTokenResponse> {
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<string, unknown>)['access_token'] === 'string'
) {
const obj = data as Record<string, unknown>;
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<OAuthTokenResponse> {
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<OAuthTokenResponse> {
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');
}