Compare commits

..

2 Commits

Author SHA1 Message Date
jacob314 ed74c13aed Fix code colorizer ansi escape bug. 2026-03-05 12:37:00 -08:00
jacob314 3ece48ebc0 Fix so shell calls are formatted nicely as bash code blocks 2026-03-05 09:54:18 -08:00
75 changed files with 964 additions and 2003 deletions
-21
View File
@@ -264,27 +264,6 @@ jobs:
run: 'npm run build'
shell: 'pwsh'
- name: 'Ensure Chrome is available'
shell: 'pwsh'
run: |
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe"
)
$chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $chromeExists) {
Write-Host 'Chrome not found, installing via Chocolatey...'
choco install googlechrome -y --no-progress --ignore-checksums
}
$installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($installed) {
Write-Host "Chrome found at: $installed"
& $installed --version
} else {
Write-Error 'Chrome installation failed'
exit 1
}
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
@@ -121,7 +121,6 @@ jobs:
'area/security',
'area/platform',
'area/extensions',
'area/documentation',
'area/unknown'
];
const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name));
@@ -256,14 +255,6 @@ jobs:
"Issues with a specific extension."
"Feature request for the extension ecosystem."
area/documentation
- Description: Issues related to user-facing documentation and other content on the documentation website.
- Example Issues:
"A typo in a README file."
"DOCS: A command is not working as described in the documentation."
"A request for a new documentation page."
"Instructions missing for skills feature"
area/unknown
- Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate.
@@ -63,7 +63,7 @@ jobs:
echo '🔍 Finding issues missing area labels...'
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)"
echo '🔍 Finding issues missing kind labels...'
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
@@ -204,7 +204,6 @@ jobs:
Categorization Guidelines (Area):
area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality
area/core: User Interface, OS Support, Core Functionality
area/documentation: End-user and contributor-facing documentation, website-related
area/enterprise: Telemetry, Policy, Quota / Licensing
area/extensions: Gemini CLI extensions capability
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
-4
View File
@@ -4,10 +4,6 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you
quickly find the best way to sign in based on your account type and how you're
using the CLI.
> **Note:** Looking for a high-level comparison of all available subscriptions?
> To compare features and find the right quota for your needs, see our
> [Plans page](/plans/).
For most users, we recommend starting Gemini CLI and logging in with your
personal Google account.
-4
View File
@@ -39,10 +39,6 @@ When you encounter that limit, youll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. Youll also be told when your usage
limit resets and Gemini 3 Pro can be used again.
> **Note:** Looking to upgrade for higher limits? To compare subscription
> options and find the right quota for your needs, see our
> [Plans page](/plans/).
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, youll see
a message prompting fallback to Gemini 2.5 Flash.
+36 -43
View File
@@ -1,13 +1,14 @@
# Gemini CLI: Quotas and pricing
Gemini CLI offers a generous free tier that covers many individual developers'
use cases. For enterprise or professional usage, or if you need increased quota,
use cases. For enterprise or professional usage, or if you need higher limits,
several options are available depending on your authentication account type.
For a high-level comparison of available subscriptions and to select the right
quota for your needs, see the [Plans page](/plans/).
See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and
Terms of Service.
## Overview
> **Note:** Published prices are list price; additional negotiated commercial
> discounting may apply.
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
@@ -22,11 +23,10 @@ Generally, there are three categories to choose from:
## Free usage
Access to Gemini CLI begins with a generous free tier, perfect for
experimentation and light use.
Your journey begins with a generous free tier, perfect for experimentation and
light use.
Your free usage is governed by the following limits, which depend on your
authorization type.
Your free usage limits depend on your authorization type.
### Log in with Google (Gemini Code Assist for individuals)
@@ -78,12 +78,14 @@ Gemini CLI by upgrading to one of the following subscriptions:
Learn more at
[Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview).
- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview)
by signing up in the Google Cloud console. Learn more at
[Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini).
Quotas and pricing are based on a fixed price subscription with assigned
license seats. For predictable costs, you can sign in with Google.
This includes the following request limits:
This includes:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
@@ -104,27 +106,18 @@ recommended path for uninterrupted access.
To do this, log in using a Gemini API key or Vertex AI.
### Vertex AI (regular mode)
An enterprise-grade platform for building, deploying, and managing AI models,
including Gemini. It offers enhanced security, data governance, and integration
with other Google Cloud services.
- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned
throughput.
- Cost: Based on model and token usage.
- Vertex AI (Regular Mode):
- Quota: Governed by a dynamic shared quota system or pre-purchased
provisioned throughput.
- Cost: Based on model and token usage.
Learn more at
[Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota)
and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing).
### Gemini API key
Ideal for developers who want to quickly build applications with the Gemini
models. This is the most direct way to use the models.
- Quota: Varies by pricing tier.
- Cost: Varies by pricing tier and model/token usage.
- Gemini API key:
- Quota: Varies by pricing tier.
- Cost: Varies by pricing tier and model/token usage.
Learn more at
[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits),
@@ -132,8 +125,7 @@ Learn more at
Its important to highlight that when using an API key, you pay per token/call.
This can be more expensive for many small calls with few tokens, but it's the
only way to ensure your workflow isn't interrupted by reaching a limit on your
quota.
only way to ensure your workflow isn't interrupted by quota limits.
## Gemini for workspace plans
@@ -143,12 +135,12 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
## Check usage and limits
## Check usage and quota
You can check your current token usage and applicable limits using the
You can check your current token usage and quota information using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as information about the limits associated with
your current quota.
session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
@@ -157,16 +149,17 @@ A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
costs.
- **Be selective with suggestions**: Before accepting a suggestion, especially
for a computationally intensive task like refactoring a large codebase,
consider if it's the most cost-effective approach.
- **Use precise prompts**: You are paying per call, so think about the most
efficient way to get your desired result. A well-crafted prompt can often get
you the answer you need in a single call, rather than multiple back-and-forth
interactions.
- **Monitor your usage**: Use the `/stats model` command to track your token
usage during a session. This can help you stay aware of your spending in real
time.
- Don't blindly accept every suggestion, especially for computationally
intensive tasks like refactoring large codebases.
- Be intentional with your prompts and commands. You are paying per call, so
think about the most efficient way to get the job done.
## Gemini API vs. Vertex
- Gemini API (gemini developer api): This is the fastest way to use the Gemini
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]}
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
-206
View File
@@ -1,206 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Integration tests for the browser agent.
*
* These tests verify the complete end-to-end flow from CLI prompt through
* browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit
* tests in packages/core/src/agents/browser/ which mock all MCP components,
* these tests launch real Chrome instances in headless mode.
*
* Tests are skipped on systems without Chrome/Chromium installed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, assertModelHasOutput } from './test-helper.js';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const chromeAvailable = (() => {
try {
if (process.platform === 'darwin') {
execSync(
'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"',
{
stdio: 'ignore',
},
);
} else if (process.platform === 'linux') {
execSync(
'which google-chrome || which chromium-browser || which chromium',
{ stdio: 'ignore' },
);
} else if (process.platform === 'win32') {
// Check standard Windows installation paths using Node.js fs
const chromePaths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
`${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`,
];
const found = chromePaths.some((p) => existsSync(p));
if (!found) {
// Fall back to PATH check
execSync('where chrome || where chromium', { stdio: 'ignore' });
}
} else {
return false;
}
return true;
} catch {
return false;
}
})();
describe.skipIf(!chromeAvailable)('browser-agent', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should navigate to a page and capture accessibility tree', async () => {
rig.setup('browser-navigate-and-snapshot', {
fakeResponsesPath: join(
__dirname,
'browser-agent.navigate-snapshot.responses',
),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Open https://example.com in the browser and tell me the page title and main content.',
});
assertModelHasOutput(result);
const toolLogs = rig.readToolLogs();
const browserAgentCall = toolLogs.find(
(t) => t.toolRequest.name === 'browser_agent',
);
expect(
browserAgentCall,
'Expected browser_agent to be called',
).toBeDefined();
});
it('should take screenshots of web pages', async () => {
rig.setup('browser-screenshot', {
fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Navigate to https://example.com and take a screenshot.',
});
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
expect(browserCalls.length).toBeGreaterThan(0);
assertModelHasOutput(result);
});
it('should interact with page elements', async () => {
rig.setup('browser-interaction', {
fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Go to https://example.com, find any links on the page, and describe them.',
});
const toolLogs = rig.readToolLogs();
const browserAgentCall = toolLogs.find(
(t) => t.toolRequest.name === 'browser_agent',
);
expect(
browserAgentCall,
'Expected browser_agent to be called',
).toBeDefined();
assertModelHasOutput(result);
});
it('should clean up browser processes after completion', async () => {
rig.setup('browser-cleanup', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
await rig.run({
args: 'Open https://example.com in the browser and check the page title.',
});
// Test passes if we reach here, relying on Vitest's timeout mechanism
// to detect hanging browser processes.
});
it('should handle multiple browser operations in sequence', async () => {
rig.setup('browser-sequential', {
fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.',
});
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
expect(browserCalls.length).toBeGreaterThan(0);
// Should successfully complete all operations
assertModelHasOutput(result);
});
});
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
ApprovalMode,
PolicyDecision,
@@ -29,10 +29,6 @@ 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 = {
+2 -2
View File
@@ -20,8 +20,8 @@ import {
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
+1 -1
View File
@@ -50,7 +50,7 @@ import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
import { SessionStatsProvider } from '../ui/contexts/SessionContext.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
@@ -37,7 +37,6 @@ 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';
@@ -495,7 +494,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.insert(textToInsert, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
message: 'Press Ctrl+O to expand pasted text',
type: TransientMessageType.Hint,
});
}
@@ -731,7 +730,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.handleInput(key);
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
message: 'Press Ctrl+O to expand pasted text',
type: TransientMessageType.Hint,
});
}
@@ -0,0 +1,88 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="428" viewBox="0 0 920 428">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="428" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#f9e2af" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#f9e2af" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="891" y="19" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="36" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="53" fill="#89b4fa" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="53" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
<text x="207" y="53" fill="#6c7086" textLength="666" lengthAdjust="spacingAndGlyphs">cat &lt;&lt; &apos;EOF&apos; &gt; foo.txtRoses are red,Violets are blue,The CLI is fast,And h</text>
<text x="873" y="53" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="70" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#00cdcd" textLength="27" lengthAdjust="spacingAndGlyphs">cat</text>
<text x="45" y="87" fill="#e5e5e5" textLength="36" lengthAdjust="spacingAndGlyphs"> &lt;&lt; </text>
<text x="81" y="87" fill="#cdcd00" textLength="45" lengthAdjust="spacingAndGlyphs">&apos;EOF&apos;</text>
<text x="126" y="87" fill="#e5e5e5" textLength="90" lengthAdjust="spacingAndGlyphs"> &gt; foo.txt</text>
<text x="891" y="87" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#e5e5e5" textLength="126" lengthAdjust="spacingAndGlyphs">Roses are red,</text>
<text x="891" y="104" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="121" fill="#e5e5e5" textLength="153" lengthAdjust="spacingAndGlyphs">Violets are blue,</text>
<text x="891" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">The CLI is fast,</text>
<text x="891" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">And helpful too.</text>
<text x="891" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">End of the poem.</text>
<text x="891" y="172" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">EOF</text>
<text x="891" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="206" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="63" y="206" fill="#cdcd00" textLength="342" lengthAdjust="spacingAndGlyphs">&quot;Poem successfully written to foo.txt&quot;</text>
<text x="891" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Note: </text>
<text x="72" y="240" fill="#ffffff" textLength="819" lengthAdjust="spacingAndGlyphs">Command contains redirection which can be undesirable. </text>
<text x="891" y="240" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#333333" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Tip: </text>
<text x="72" y="257" fill="#333333" textLength="576" lengthAdjust="spacingAndGlyphs">Toggle auto-edit (Shift+Tab) to allow redirection in the future.</text>
<text x="891" y="257" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="274" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Allow execution of: &apos;cat, heredoc (&lt;&lt;), redirection (&gt;), echo&apos;? </text>
<text x="891" y="274" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="306" width="9" height="17" fill="#001a00" />
<text x="18" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="306" width="9" height="17" fill="#001a00" />
<rect x="36" y="306" width="18" height="17" fill="#001a00" />
<text x="36" y="308" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="306" width="9" height="17" fill="#001a00" />
<rect x="63" y="306" width="90" height="17" fill="#001a00" />
<text x="63" y="308" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="306" width="171" height="17" fill="#001a00" />
<text x="891" y="308" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="325" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="891" y="325" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="342" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 3. Allow for all future sessions </text>
<text x="891" y="342" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="359" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 4. No, suggest changes (esc) </text>
<text x="891" y="359" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="376" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#f9e2af" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -90,6 +90,33 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
"
`;
exports[`ToolConfirmationQueue > renders a multiline shell command with syntax highlighting and redirection warning (SVG snapshot) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
│ │
│ ? run_shell_command cat << 'EOF' > foo.txtRoses are red,Violets are blue,The CLI is fast,And h… │
│ │
│ cat << 'EOF' > foo.txt │
│ Roses are red, │
│ Violets are blue, │
│ The CLI is fast, │
│ And helpful too. │
│ End of the poem. │
│ EOF │
│ echo "Poem successfully written to foo.txt" │
│ │
│ Note: Command contains redirection which can be undesirable. │
│ Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future. │
│ Allow execution of: 'cat, heredoc (<<), redirection (>), echo'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Allow for all future sessions │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
@@ -137,14 +137,6 @@ 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',
{
@@ -240,6 +240,37 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Multiline Script',
command: 'echo "hello"\nfor i in 1 2 3; do\n echo $i\ndone',
rootCommand: 'echo',
rootCommands: ['echo'],
};
const result = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await result.waitUntilReady();
const output = result.lastFrame();
expect(output).toContain('echo "hello"');
expect(output).toContain('for i in 1 2 3; do');
expect(output).toContain('echo $i');
expect(output).toContain('done');
await expect(result).toMatchSvgSnapshot();
result.unmount();
});
describe('with folder trust', () => {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
@@ -40,6 +40,7 @@ import {
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
import { colorizeCode } from '../../utils/CodeColorizer.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
@@ -548,9 +549,19 @@ export const ToolConfirmationMessage: React.FC<
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Text key={idx} color={theme.text.link}>
{sanitizeForDisplay(cmd)}
</Text>
<Box
key={idx}
flexDirection="column"
paddingBottom={idx < commandsToDisplay.length - 1 ? 1 : 0}
>
{colorizeCode({
code: cmd,
language: 'bash',
maxWidth: Math.max(terminalWidth, 1),
settings,
hideLineNumbers: true,
})}
</Box>
))}
</Box>
</MaxSizedBox>
@@ -634,6 +645,7 @@ export const ToolConfirmationMessage: React.FC<
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
settings,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
@@ -309,14 +309,6 @@ 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 │
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="173" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">&quot;hello&quot;</text>
<text x="0" y="19" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">for</text>
<text x="27" y="19" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> i </text>
<text x="54" y="19" fill="#0000ee" textLength="18" lengthAdjust="spacingAndGlyphs">in</text>
<text x="72" y="19" fill="#e5e5e5" textLength="72" lengthAdjust="spacingAndGlyphs"> 1 2 3; </text>
<text x="144" y="19" fill="#0000ee" textLength="18" lengthAdjust="spacingAndGlyphs">do</text>
<text x="18" y="36" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="63" y="36" fill="#cd00cd" textLength="18" lengthAdjust="spacingAndGlyphs">$i</text>
<text x="0" y="53" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: &apos;echo&apos;? </text>
<rect x="0" y="102" width="9" height="17" fill="#001a00" />
<text x="0" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="102" width="9" height="17" fill="#001a00" />
<rect x="18" y="102" width="18" height="17" fill="#001a00" />
<text x="18" y="104" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="0" y="138" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -2,7 +2,9 @@
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello"
ls -la
whoami
Allow execution of 3 commands?
@@ -35,6 +37,19 @@ Do you want to proceed?
"
`;
exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot) 1`] = `
"echo "hello"
for i in 1 2 3; do
echo $i
done
Allow execution of: 'echo'?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
"MCP Server: testserver
Tool: testtool
@@ -25,7 +25,6 @@ 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.
@@ -626,7 +625,7 @@ export function BaseSettingsDialog({
{/* Help text */}
<Box marginX={1}>
<Text color={theme.text.secondary}>
(Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
(Use Enter to select, Ctrl+L to reset
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
</Text>
</Box>
@@ -786,8 +786,6 @@ export function KeypressProvider({
);
useEffect(() => {
terminalCapabilityManager.enableSupportedModes();
const wasRaw = stdin.isRaw;
if (wasRaw === false) {
setRawMode(true);
@@ -1799,7 +1799,6 @@ export const useGeminiStream = (
addItem,
registerBackgroundShell,
consumeUserHint,
config,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
@@ -23,7 +23,7 @@ vi.mock('../themes/theme-manager.js', () => ({
DEFAULT_THEME: { name: 'Default' },
}));
vi.mock('../themes/builtin/dark/holiday-dark.js', () => ({
vi.mock('../themes/holiday.js', () => ({
Holiday: { name: 'Holiday' },
}));
+1 -1
View File
@@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { debugState } from '../debug.js';
import { themeManager } from '../themes/theme-manager.js';
import { Holiday } from '../themes/builtin/dark/holiday-dark.js';
import { Holiday } from '../themes/holiday.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from './useTerminalSize.js';
import { shortAsciiLogo } from '../components/AsciiArt.js';
@@ -65,7 +65,7 @@ vi.mock('../themes/theme-manager.js', async (importOriginal) => {
};
});
vi.mock('../themes/builtin/light/default-light.js', () => ({
vi.mock('../themes/default-light.js', () => ({
DefaultLight: { name: 'default-light' },
}));
@@ -11,7 +11,7 @@ import {
shouldSwitchTheme,
} from '../themes/color-utils.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { DefaultLight } from '../themes/builtin/light/default-light.js';
import { DefaultLight } from '../themes/default-light.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useTerminalContext } from '../contexts/TerminalContext.js';
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { lightSemanticColors } from '../../semantic-tokens.js';
import { type ColorsTheme, Theme } from './theme.js';
import { lightSemanticColors } from './semantic-tokens.js';
const ansiLightColors: ColorsTheme = {
type: 'light',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { darkSemanticColors } from '../../semantic-tokens.js';
import { type ColorsTheme, Theme } from './theme.js';
import { darkSemanticColors } from './semantic-tokens.js';
const ansiColors: ColorsTheme = {
type: 'dark',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const atomOneDarkColors: ColorsTheme = {
type: 'dark',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const ayuLightColors: ColorsTheme = {
type: 'light',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const ayuDarkColors: ColorsTheme = {
type: 'dark',
@@ -1,10 +1,10 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { lightTheme, Theme } from '../../theme.js';
import { lightTheme, Theme } from './theme.js';
export const DefaultLight: Theme = new Theme(
'Default Light',
@@ -1,10 +1,10 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { darkTheme, Theme } from '../../theme.js';
import { darkTheme, Theme } from './theme.js';
export const DefaultDark: Theme = new Theme(
'Default',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const draculaColors: ColorsTheme = {
type: 'dark',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const githubDarkColors: ColorsTheme = {
type: 'dark',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const githubLightColors: ColorsTheme = {
type: 'light',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme, lightTheme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme, lightTheme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const googleCodeColors: ColorsTheme = {
type: 'light',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const holidayColors: ColorsTheme = {
type: 'dark',
@@ -1,12 +1,12 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ColorsTheme } from '../theme.js';
import { Theme } from '../theme.js';
import type { SemanticColors } from '../semantic-tokens.js';
import type { ColorsTheme } from './theme.js';
import { Theme } from './theme.js';
import type { SemanticColors } from './semantic-tokens.js';
const noColorColorsTheme: ColorsTheme = {
type: 'ansi',
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,8 +8,8 @@
* Shades of Purple Theme for Highlight.js.
* @author Ahmad Awais <https://twitter.com/mrahmadawais/>
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const shadesOfPurpleColors: ColorsTheme = {
type: 'dark',
@@ -1,12 +1,12 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
import { type SemanticColors } from '../../semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
import { type ColorsTheme, Theme, interpolateColor } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../constants.js';
const solarizedDarkColors: ColorsTheme = {
type: 'dark',
@@ -1,12 +1,12 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
import { type SemanticColors } from '../../semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
import { type ColorsTheme, Theme, interpolateColor } from './theme.js';
import { type SemanticColors } from './semantic-tokens.js';
import { DEFAULT_SELECTION_OPACITY } from '../constants.js';
const solarizedLightColors: ColorsTheme = {
type: 'light',
+18 -18
View File
@@ -1,23 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AyuDark } from './builtin/dark/ayu-dark.js';
import { AyuLight } from './builtin/light/ayu-light.js';
import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
import { Dracula } from './builtin/dark/dracula-dark.js';
import { GitHubDark } from './builtin/dark/github-dark.js';
import { GitHubLight } from './builtin/light/github-light.js';
import { GoogleCode } from './builtin/light/googlecode-light.js';
import { Holiday } from './builtin/dark/holiday-dark.js';
import { DefaultLight } from './builtin/light/default-light.js';
import { DefaultDark } from './builtin/dark/default-dark.js';
import { ShadesOfPurple } from './builtin/dark/shades-of-purple-dark.js';
import { SolarizedDark } from './builtin/dark/solarized-dark.js';
import { SolarizedLight } from './builtin/light/solarized-light.js';
import { XCode } from './builtin/light/xcode-light.js';
import { AyuDark } from './ayu.js';
import { AyuLight } from './ayu-light.js';
import { AtomOneDark } from './atom-one-dark.js';
import { Dracula } from './dracula.js';
import { GitHubDark } from './github-dark.js';
import { GitHubLight } from './github-light.js';
import { GoogleCode } from './googlecode.js';
import { Holiday } from './holiday.js';
import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { SolarizedDark } from './solarized-dark.js';
import { SolarizedLight } from './solarized-light.js';
import { XCode } from './xcode.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Theme, ThemeType, ColorsTheme } from './theme.js';
@@ -36,9 +36,9 @@ import {
DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { ANSI } from './builtin/dark/ansi-dark.js';
import { ANSILight } from './builtin/light/ansi-light.js';
import { NoColorTheme } from './builtin/no-color.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
import process from 'node:process';
import { debugLogger, homedir } from '@google/gemini-cli-core';
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
import { type ColorsTheme, Theme } from './theme.js';
import { interpolateColor } from './color-utils.js';
const xcodeColors: ColorsTheme = {
type: 'light',
@@ -50,4 +50,36 @@ describe('colorizeCode', () => {
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
unmount();
});
it('does not let colors from ansi escape codes leak into colorized code', async () => {
const code = 'line 1\n\x1b[41mline 2 with red background\x1b[0m\nline 3';
const settings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{
path: '',
settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
originalSettings: {
ui: { useAlternateBuffer: true, showLineNumbers: false },
},
},
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
const result = colorizeCode({
code,
language: 'javascript',
maxWidth: 80,
settings,
hideLineNumbers: true,
});
const renderResult = renderWithProviders(<>{result}</>);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
+7 -5
View File
@@ -14,6 +14,7 @@ import type {
ElementContent,
RootContent,
} from 'hast';
import stripAnsi from 'strip-ansi';
import { themeManager } from '../themes/theme-manager.js';
import type { Theme } from '../themes/theme.js';
import {
@@ -98,16 +99,17 @@ function highlightAndRenderLine(
theme: Theme,
): React.ReactNode {
try {
const strippedLine = stripAnsi(line);
const getHighlightedLine = () =>
!language || !lowlight.registered(language)
? lowlight.highlightAuto(line)
: lowlight.highlight(language, line);
? lowlight.highlightAuto(strippedLine)
: lowlight.highlight(language, strippedLine);
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
return renderedNode !== null ? renderedNode : line;
return renderedNode !== null ? renderedNode : strippedLine;
} catch (_error) {
return line;
return stripAnsi(line);
}
}
@@ -238,7 +240,7 @@ export function colorizeCode({
<Text color={activeTheme.defaultColor}>{`${index + 1}`}</Text>
</Box>
)}
<Text color={activeTheme.colors.Gray}>{line}</Text>
<Text color={activeTheme.colors.Gray}>{stripAnsi(line)}</Text>
</Box>
));
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
<text x="45" y="2" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<text x="0" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
<text x="45" y="19" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">2</text>
<text x="63" y="19" fill="#89b4fa" textLength="36" lengthAdjust="spacingAndGlyphs">with</text>
<text x="99" y="19" fill="#ffffff" textLength="801" lengthAdjust="spacingAndGlyphs"> red background </text>
<text x="0" y="36" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
<text x="45" y="36" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">3</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
"line 1
line 2 with red background
line 3"
`;
@@ -98,7 +98,6 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -142,8 +141,6 @@ describe('TerminalCapabilityManager', () => {
// Should resolve without waiting for timeout
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
});
@@ -159,7 +156,6 @@ describe('TerminalCapabilityManager', () => {
vi.advanceTimersByTime(1000);
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -171,7 +167,6 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
});
@@ -186,7 +181,6 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -202,8 +196,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -218,8 +210,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
@@ -234,7 +224,6 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(enableKittyKeyboardProtocol).toHaveBeenCalled();
@@ -252,8 +241,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -270,8 +257,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -287,8 +272,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.getTerminalBackgroundColor()).toBe('#1a1a1a');
expect(manager.getTerminalName()).toBe('tmux');
@@ -304,8 +287,6 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
@@ -138,6 +138,9 @@ export class TerminalCapabilityManager {
process.stdin.setRawMode(false);
}
this.detectionComplete = true;
this.enableSupportedModes();
resolve();
};
@@ -243,11 +246,9 @@ export class TerminalCapabilityManager {
enableSupportedModes() {
try {
if (this.kittySupported) {
debugLogger.log('Enabling Kitty keyboard protocol');
enableKittyKeyboardProtocol();
this.kittyEnabled = true;
} else if (this.modifyOtherKeysSupported) {
debugLogger.log('Enabling modifyOtherKeys');
enableModifyOtherKeys();
}
// Always enable bracketed paste since it'll be ignored if unsupported.
@@ -64,31 +64,6 @@ vi.mock('node:path', async (importOriginal) => {
};
});
vi.mock('../utils/sessionUtils.js', () => ({
SessionSelector: vi.fn().mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData: {
messages: [
{ type: 'user', content: [{ text: 'Hello' }] },
{
type: 'gemini',
content: [{ text: 'Hi' }],
toolCalls: [
{
id: 'call-1',
name: 'test_tool',
status: 'success',
resultDisplay: 'Tool output',
},
],
},
],
},
sessionPath: '/path/to/session.json',
}),
})),
}));
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: {
name: 'memory',
@@ -137,13 +112,6 @@ vi.mock(
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
convertSessionToClientHistory: vi.fn().mockReturnValue([]),
partListUnionToString: vi.fn().mockImplementation((content) => {
if (Array.isArray(content)) {
return content.map((p) => p.text || '').join('');
}
return '';
}),
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
@@ -240,7 +208,7 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(4);
expect(response.authMethods).toHaveLength(3);
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -260,8 +228,6 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -281,60 +247,6 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should authenticate correctly with gemini-custom-url and base-url in _meta', async () => {
await agent.authenticate({
methodId: 'gemini-custom-url',
_meta: {
'api-key': 'test-api-key',
'base-url': 'https://custom.api.endpoint',
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
'https://custom.api.endpoint',
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: 'gateway',
_meta: {
gateway: {
baseUrl: 'https://gateway.example.com',
headers: {
Authorization: 'Bearer test-token',
'X-Custom-Header': 'custom-value',
},
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
undefined,
'https://gateway.example.com',
{
Authorization: 'Bearer test-token',
'X-Custom-Header': 'custom-value',
},
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -612,17 +524,6 @@ describe('GeminiAgent', () => {
).rejects.toThrow('Session not found: unknown');
});
it('should load an existing session', async () => {
const response = await agent.loadSession({
sessionId: 'test-session-id',
cwd: '/tmp',
mcpServers: [],
});
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
expect(response.modes).toBeDefined();
});
it('should delegate setModel to session (unstable)', async () => {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
@@ -98,8 +98,6 @@ export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
private baseUrl: string | undefined;
private headers: Record<string, string> | undefined;
constructor(
private config: Config,
@@ -128,17 +126,6 @@ export class GeminiAgent {
},
},
},
{
id: 'gateway',
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
@@ -172,13 +159,8 @@ export class GeminiAgent {
}
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
const methodId = req.methodId;
let method = AuthType.USE_GEMINI;
if (methodId === 'gemini-custom-url' || methodId === 'gateway') {
method = AuthType.USE_GEMINI;
} else {
method = z.nativeEnum(AuthType).parse(methodId);
}
const { methodId } = req;
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
// Only clear credentials when switching to a different auth method
@@ -189,28 +171,6 @@ export class GeminiAgent {
const meta = hasMeta(req) ? req._meta : undefined;
const apiKey =
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
let baseUrl =
typeof meta?.['base-url'] === 'string' ? meta['base-url'] : undefined;
let headers: Record<string, string> | undefined;
if (methodId === 'gateway') {
method = AuthType.USE_GEMINI;
// Gateway specific handling
const gatewaySchema = z
.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
})
.optional();
const gatewayParams = gatewaySchema.parse(meta?.['gateway']);
if (gatewayParams?.baseUrl) {
baseUrl = gatewayParams.baseUrl;
}
if (gatewayParams?.headers) {
headers = gatewayParams.headers;
}
}
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
@@ -219,18 +179,7 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
if (baseUrl) {
this.baseUrl = baseUrl;
}
if (headers) {
this.headers = headers;
}
await this.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl ?? this.baseUrl,
headers,
);
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -260,12 +209,7 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(
authType,
this.apiKey,
this.baseUrl,
this.headers,
);
await config.refreshAuth(authType, this.apiKey);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -427,12 +371,7 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(
selectedAuthType,
this.apiKey,
this.baseUrl,
this.headers,
);
await config.refreshAuth(selectedAuthType, this.apiKey);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -147,7 +147,7 @@ describe('BrowserManager', () => {
// Verify StdioClientTransport was created with correct args
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
command: 'npx',
args: expect.arrayContaining([
'-y',
expect.stringMatching(/chrome-devtools-mcp@/),
@@ -185,7 +185,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
command: 'npx',
args: expect.arrayContaining(['--headless']),
}),
);
@@ -210,7 +210,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
command: 'npx',
args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
}),
);
@@ -283,7 +283,7 @@ export class BrowserManager {
// stderr is piped (not inherited) to prevent MCP server banners and
// warnings from corrupting the UI in alternate buffer mode.
this.mcpTransport = new StdioClientTransport({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
command: 'npx',
args: mcpArgs,
stderr: 'pipe',
});
-2
View File
@@ -512,8 +512,6 @@ describe('Server Config (config.ts)', () => {
config,
authType,
undefined,
undefined,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
+1 -8
View File
@@ -1207,12 +1207,7 @@ export class Config implements McpContext {
return this.contentGenerator;
}
async refreshAuth(
authMethod: AuthType,
apiKey?: string,
baseUrl?: string,
headers?: Record<string, string>,
) {
async refreshAuth(authMethod: AuthType, apiKey?: string) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1239,8 +1234,6 @@ export class Config implements McpContext {
this,
authMethod,
apiKey,
baseUrl,
headers,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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.
- **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.
- **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.
- **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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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 \`tab\` 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 \`ctrl + f\` 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.
- **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.
- **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.
- **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.
@@ -174,6 +174,7 @@ describe('createContentGenerator', () => {
headers: expect.objectContaining({
'User-Agent': expect.any(String),
'X-Test-Header': 'test-value',
'Another-Header': 'another value',
}),
},
AuthType.LOGIN_WITH_GOOGLE,
@@ -474,64 +475,6 @@ describe('createContentGenerator', () => {
apiVersion: 'v1alpha',
});
});
it('should include config.headers for Gemini auth but not for others', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
// Test USE_GEMINI gets headers
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
headers: { 'X-Custom-Config-Header': 'custom-value' },
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Config-Header': 'custom-value',
}),
}),
}),
);
// Test LOGIN_WITH_GOOGLE does NOT get headers (unless they were in baseHeaders, which they are not)
const mockCodeAssistGenerator = {} as unknown as ContentGenerator;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
mockCodeAssistGenerator as never,
);
await createContentGenerator(
{
authType: AuthType.LOGIN_WITH_GOOGLE,
headers: { 'X-Should-Not-Be-Here': 'nope' },
},
mockConfig,
);
expect(createCodeAssistContentGenerator).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.not.objectContaining({
'X-Should-Not-Be-Here': 'nope',
}),
}),
AuthType.LOGIN_WITH_GOOGLE,
mockConfig,
undefined,
);
});
});
describe('createContentGeneratorConfig', () => {
+2 -15
View File
@@ -90,19 +90,15 @@ export function getAuthTypeFromEnv(): AuthType | undefined {
export type ContentGeneratorConfig = {
apiKey?: string;
baseUrl?: string;
vertexai?: boolean;
authType?: AuthType;
proxy?: string;
headers?: Record<string, string>;
};
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
baseUrl?: string,
headers?: Record<string, string>,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
apiKey ||
@@ -119,7 +115,6 @@ export async function createContentGeneratorConfig(
const contentGeneratorConfig: ContentGeneratorConfig = {
authType,
proxy: config?.getProxy(),
headers,
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@@ -132,7 +127,6 @@ export async function createContentGeneratorConfig(
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
contentGeneratorConfig.baseUrl = baseUrl;
contentGeneratorConfig.vertexai = false;
return contentGeneratorConfig;
@@ -211,10 +205,7 @@ export async function createContentGenerator(
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI
) {
let headers: Record<string, string> = {
...baseHeaders,
...config.headers,
};
let headers: Record<string, string> = { ...baseHeaders };
if (gcConfig?.getUsageStatisticsEnabled()) {
const installationManager = new InstallationManager();
const installationId = installationManager.getInstallationId();
@@ -223,11 +214,7 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
const httpOptions: { headers: Record<string, string>; baseUrl?: string } =
{ headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
}
const httpOptions = { headers };
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
+4 -4
View File
@@ -599,24 +599,24 @@ describe('Core System Prompt (prompts.ts)', () => {
expect(prompt).not.toContain('via `&`');
});
it("should include 'tab' instructions when interactive shell is enabled", () => {
it("should include 'ctrl + f' 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('tab');
expect(prompt).toContain('ctrl + f');
});
it("should NOT include 'tab' instructions when interactive shell is disabled", () => {
it("should NOT include 'ctrl + f' 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('`tab`');
expect(prompt).not.toContain('ctrl + f');
});
});
+515 -60
View File
@@ -4,7 +4,9 @@
* 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';
@@ -14,23 +16,6 @@ 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.
@@ -49,6 +34,25 @@ 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).
*/
@@ -76,6 +80,18 @@ 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.
*/
@@ -223,19 +239,376 @@ export class MCPOAuthProvider {
}
/**
* Build the OAuth resource parameter from an MCP server URL, if available.
* Returns undefined if the URL is not provided or cannot be processed.
* Generate PKCE parameters for OAuth flow.
*
* @returns PKCE parameters including code verifier, challenge, and state
*/
private buildResourceParam(mcpServerUrl?: string): string | undefined {
if (!mcpServerUrl) return undefined;
try {
return OAuthUtils.buildResourceParameter(mcpServerUrl);
} catch (error) {
debugLogger.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
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;
}
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')
) {
debugLogger.warn(
`Token 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 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;
}
}
/**
@@ -253,21 +626,112 @@ export class MCPOAuthProvider {
tokenUrl: string,
mcpServerUrl?: string,
): Promise<OAuthTokenResponse> {
if (!config.clientId) {
throw new Error('Missing required clientId for token refresh');
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId!,
});
if (config.clientSecret) {
params.append('client_secret', config.clientSecret);
}
return refreshAccessTokenShared(
{
clientId: config.clientId,
clientSecret: config.clientSecret,
scopes: config.scopes,
audiences: config.audiences,
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',
},
refreshToken,
tokenUrl,
this.buildResourceParam(mcpServerUrl),
);
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;
}
}
/**
@@ -366,14 +830,17 @@ export class MCPOAuthProvider {
}
// Generate PKCE parameters
const pkceParams = generatePKCEParams();
const pkceParams = this.generatePKCEParams();
// Determine preferred port from redirectUri if available
const preferredPort = getPortFromUrl(config.redirectUri);
const preferredPort = this.getPortFromUrl(config.redirectUri);
// Start callback server first to allocate port
// This ensures we only create one server and eliminates race conditions
const callbackServer = startCallbackServer(pkceParams.state, preferredPort);
const callbackServer = this.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
@@ -425,24 +892,12 @@ 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 resource = this.buildResourceParam(mcpServerUrl);
const authUrl = buildAuthorizationUrl(
flowConfig,
const authUrl = this.buildAuthorizationUrl(
config,
pkceParams,
redirectPort,
resource,
mcpServerUrl,
);
const userConsent = await getConsentForOauth(
@@ -478,12 +933,12 @@ ${authUrl}
);
// Exchange code for tokens
const tokenResponse = await exchangeCodeForToken(
flowConfig,
const tokenResponse = await this.exchangeCodeForToken(
config,
code,
pkceParams.codeVerifier,
redirectPort,
resource,
mcpServerUrl,
);
// Convert to our token format
@@ -51,8 +51,3 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help"]
decision = "allow"
priority = 50
+4 -5
View File
@@ -15,7 +15,6 @@ 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,
@@ -600,12 +599,12 @@ function toolUsageInteractive(
interactiveShellEnabled: boolean,
): string {
if (interactive) {
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.'
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.'
: '';
return `
- **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}`;
- **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}`;
}
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 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.'
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.'
: '';
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).${focusHint}`;
- **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}`;
}
return `
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true.
+2 -13
View File
@@ -477,24 +477,13 @@ 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: finalResponse,
response: reason,
durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome,
schedulerId: call.schedulerId,
@@ -519,7 +508,7 @@ export class SchedulerStateManager {
},
},
],
resultDisplay: resultDisplay ?? existingOutput,
resultDisplay,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
@@ -49,7 +49,6 @@ import type {
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
StartupStatsEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
@@ -118,7 +117,6 @@ 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 {
@@ -1693,30 +1691,6 @@ 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: 176
// Next ID: 172
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -54,22 +54,6 @@ 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,7 +791,6 @@ 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
@@ -1,635 +0,0 @@
/**
* @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
@@ -1,515 +0,0 @@
/**
* @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');
}