Compare commits

...

17 Commits

Author SHA1 Message Date
Sri Pasumarthi c0455b7351 feat: Track and return token usage metadata, including total and model-specific counts, within prompt responses. 2026-03-05 13:38:15 -08:00
Shreya Keshive 0135b03c8a fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences (#21171) 2026-03-05 19:57:28 +00:00
kartik 9773a084c9 fix:reorder env var redaction checks to scan values first (#21059)
Signed-off-by: Kartik Angiras <angiraskartik@gmail.com>
2026-03-05 19:22:45 +00:00
Juhyuk a830858f91 fix(core): prevent unhandled AbortError crash during stream loop detection (#21123)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: ruomeng <ruomeng@google.com>
2026-03-05 19:12:09 +00:00
Jerop Kipruto f47cb3c136 docs: use absolute paths for internal links in plan-mode.md (#21299) 2026-03-05 19:11:57 +00:00
Zheyuan Lin 291639633f feat(cli): add native gVisor (runsc) sandboxing support (#21062)
Co-authored-by: Zheyuan <zlin252@emory.edu>
Co-authored-by: Kartik Angiras <angiraskartik@gmail.com>
2026-03-05 18:39:57 +00:00
Jacob Richman c7e2dbe0cf Fix so shell calls are formatted (#21237) 2026-03-05 18:39:42 +00:00
Yuna Seol 31d65f40bd feat(telemetry): implement Clearcut logging for startup statistics (#21172)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-05 18:21:55 +00:00
Sam Roberts 93d9adc03c feat(triage): add area/documentation to issue triage (#21222) 2026-03-05 18:14:47 +00:00
Sam Roberts d2726d4a43 DOCS: Update quota and pricing page (#21194) 2026-03-05 18:09:14 +00:00
Tommaso Sciortino 1c3c7a343f fix(cli): replace hardcoded keybinding strings with dynamic formatters (#21159) 2026-03-05 17:52:01 +00:00
Dev Randalpura f20e45175e fix(ui): add partial output to cancelled shell UI (#21178)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-05 17:30:20 +00:00
Sandy Tao 0228c2b9f0 refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider (#20895) 2026-03-05 17:01:37 +00:00
Tommaso Sciortino ddafd79661 fix(cli): fix enabling kitty codes on Windows Terminal (#21136) 2026-03-05 15:29:12 +00:00
Aditya Bijalwan 09259a8ec4 test: add browser agent integration tests (#21151) 2026-03-05 13:29:35 +00:00
Adib234 173376ba67 fix(core): explicitly allow codebase_investigator and cli_help in read-only mode (#21157) 2026-03-05 13:20:04 +00:00
JAYADITYA 013d5dfbb0 refactor(cli): categorize built-in themes into dark/ and light/ directories (#18634) 2026-03-05 09:20:46 +00:00
100 changed files with 2505 additions and 824 deletions
+21
View File
@@ -264,6 +264,27 @@ 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,6 +121,7 @@ jobs:
'area/security',
'area/platform',
'area/extensions',
'area/documentation',
'area/unknown'
];
const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name));
@@ -255,6 +256,14 @@ jobs:
"Issues with a specific extension."
"Feature request for the extension ecosystem."
area/documentation
- Description: Issues related to user-facing documentation and other content on the documentation website.
- Example Issues:
"A typo in a README file."
"DOCS: A command is not working as described in the documentation."
"A request for a new documentation page."
"Instructions missing for skills feature"
area/unknown
- Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate.
@@ -63,7 +63,7 @@ jobs:
echo '🔍 Finding issues missing area labels...'
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)"
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
echo '🔍 Finding issues missing kind labels...'
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
@@ -204,6 +204,7 @@ jobs:
Categorization Guidelines (Area):
area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality
area/core: User Interface, OS Support, Core Functionality
area/documentation: End-user and contributor-facing documentation, website-related
area/enterprise: Telemetry, Policy, Quota / Licensing
area/extensions: Gemini CLI extensions capability
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
+25 -25
View File
@@ -344,32 +344,32 @@ Manual deletion also removes all associated artifacts:
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder
[`read_file`]: ../tools/file-system.md#2-read_file-readfile
[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext
[`write_file`]: ../tools/file-system.md#3-write_file-writefile
[`glob`]: ../tools/file-system.md#4-glob-findfiles
[`google_web_search`]: ../tools/web-search.md
[`replace`]: ../tools/file-system.md#6-replace-edit
[MCP tools]: ../tools/mcp-server.md
[`save_memory`]: ../tools/memory.md
[`activate_skill`]: ./skills.md
[`codebase_investigator`]: ../core/subagents.md#codebase_investigator
[`cli_help`]: ../core/subagents.md#cli_help
[subagents]: ../core/subagents.md
[custom subagents]: ../core/subagents.md#creating-custom-subagents
[policy engine]: ../reference/policy-engine.md
[`enter_plan_mode`]: ../tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: ../tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: ../tools/ask-user.md
[YOLO mode]: ../reference/configuration.md#command-line-arguments
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
[`google_web_search`]: /docs/tools/web-search.md
[`replace`]: /docs/tools/file-system.md#6-replace-edit
[MCP tools]: /docs/tools/mcp-server.md
[`save_memory`]: /docs/tools/memory.md
[`activate_skill`]: /docs/cli/skills.md
[`codebase_investigator`]: /docs/core/subagents.md#codebase-investigator
[`cli_help`]: /docs/core/subagents.md#cli-help-agent
[subagents]: /docs/core/subagents.md
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
[policy engine]: /docs/reference/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[auto model]: ../reference/configuration.md#model-settings
[model routing]: ./telemetry.md#model-routing
[preferred external editor]: ../reference/configuration.md#general
[session retention]: ./session-management.md#session-retention
[extensions]: ../extensions/index.md
[auto model]: /docs/reference/configuration.md#model
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention
[extensions]: /docs/extensions/index.md
[Conductor]: https://github.com/gemini-cli-extensions/conductor
[open an issue]: https://github.com/google-gemini/gemini-cli/issues
[Agent Skills]: ./skills.md
[Agent Skills]: /docs/cli/skills.md
+26 -2
View File
@@ -50,7 +50,31 @@ Cross-platform sandboxing with complete process isolation.
**Note**: Requires building the sandbox image locally or using a published image
from your organization's registry.
### 3. LXC/LXD (Linux only, experimental)
### 3. gVisor / runsc (Linux only)
Strongest isolation available: runs containers inside a user-space kernel via
[gVisor](https://github.com/google/gvisor). gVisor intercepts all container
system calls and handles them in a sandboxed kernel written in Go, providing a
strong security barrier between AI operations and the host OS.
**Prerequisites:**
- Linux (gVisor supports Linux only)
- Docker installed and running
- gVisor/runsc runtime configured
When you set `sandbox: "runsc"`, Gemini CLI runs
`docker run --runtime=runsc ...` to execute containers with gVisor isolation.
runsc is not auto-detected; you must specify it explicitly (e.g.
`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`).
To set up runsc:
1. Install the runsc binary.
2. Configure the Docker daemon to use the runsc runtime.
3. Verify the installation.
### 4. LXC/LXD (Linux only, experimental)
Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC
containers run a complete Linux system with `systemd`, `snapd`, and other system
@@ -133,7 +157,7 @@ gemini -p "run the test suite"
1. **Command flag**: `-s` or `--sandbox`
2. **Environment variable**:
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|lxc`
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
3. **Settings file**: `"sandbox": true` in the `tools` object of your
`settings.json` file (e.g., `{"tools": {"sandbox": true}}`).
+4
View File
@@ -4,6 +4,10 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you
quickly find the best way to sign in based on your account type and how you're
using the CLI.
> **Note:** Looking for a high-level comparison of all available subscriptions?
> To compare features and find the right quota for your needs, see our
> [Plans page](/plans/).
For most users, we recommend starting Gemini CLI and logging in with your
personal Google account.
+4
View File
@@ -39,6 +39,10 @@ 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.
+43 -36
View File
@@ -1,14 +1,13 @@
# Gemini CLI: Quotas and pricing
Gemini CLI offers a generous free tier that covers many individual developers'
use cases. For enterprise or professional usage, or if you need higher limits,
use cases. For enterprise or professional usage, or if you need increased quota,
several options are available depending on your authentication account type.
See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and
Terms of Service.
For a high-level comparison of available subscriptions and to select the right
quota for your needs, see the [Plans page](/plans/).
> **Note:** Published prices are list price; additional negotiated commercial
> discounting may apply.
## Overview
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
@@ -23,10 +22,11 @@ Generally, there are three categories to choose from:
## Free usage
Your journey begins with a generous free tier, perfect for experimentation and
light use.
Access to Gemini CLI begins with a generous free tier, perfect for
experimentation and light use.
Your free usage limits depend on your authorization type.
Your free usage is governed by the following limits, which depend on your
authorization type.
### Log in with Google (Gemini Code Assist for individuals)
@@ -78,14 +78,12 @@ Gemini CLI by upgrading to one of the following subscriptions:
Learn more at
[Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview)
by signing up in the Google Cloud console. Learn more at
[Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini).
- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview).
Quotas and pricing are based on a fixed price subscription with assigned
license seats. For predictable costs, you can sign in with Google.
This includes:
This includes the following request limits:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
@@ -106,18 +104,27 @@ recommended path for uninterrupted access.
To do this, log in using a Gemini API key or Vertex AI.
- Vertex AI (Regular Mode):
- Quota: Governed by a dynamic shared quota system or pre-purchased
provisioned throughput.
- Cost: Based on model and token usage.
### Vertex AI (regular mode)
An enterprise-grade platform for building, deploying, and managing AI models,
including Gemini. It offers enhanced security, data governance, and integration
with other Google Cloud services.
- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned
throughput.
- Cost: Based on model and token usage.
Learn more at
[Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota)
and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing).
- Gemini API key:
- Quota: Varies by pricing tier.
- Cost: Varies by pricing tier and model/token usage.
### Gemini API key
Ideal for developers who want to quickly build applications with the Gemini
models. This is the most direct way to use the models.
- Quota: Varies by pricing tier.
- Cost: Varies by pricing tier and model/token usage.
Learn more at
[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits),
@@ -125,7 +132,8 @@ Learn more at
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 quota limits.
only way to ensure your workflow isn't interrupted by reaching a limit on your
quota.
## Gemini for workspace plans
@@ -135,12 +143,12 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
## Check usage and quota
## Check usage and limits
You can check your current token usage and quota information using the
You can check your current token usage and applicable limits using the
`/stats model` command. This command provides a snapshot of your current
session's token usage, as well as your overall quota and usage for the supported
models.
session's token usage, as well as information about the limits associated with
your current quota.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
@@ -149,17 +157,16 @@ A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected
costs.
- Don't blindly accept every suggestion, especially for computationally
intensive tasks like refactoring large codebases.
- Be intentional with your prompts and commands. You are paying per call, so
think about the most efficient way to get the job done.
## Gemini API vs. Vertex
- Gemini API (gemini developer api): This is the fastest way to use the Gemini
models directly.
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
managing Gemini models with specific security and control requirements.
- **Be selective with suggestions**: Before accepting a suggestion, especially
for a computationally intensive task like refactoring a large codebase,
consider if it's the most cost-effective approach.
- **Use precise prompts**: You are paying per call, so think about the most
efficient way to get your desired result. A well-crafted prompt can often get
you the answer you need in a single call, rather than multiple back-and-forth
interactions.
- **Monitor your usage**: Use the `/stats model` command to track your token
usage during a session. This can help you stay aware of your spending in real
time.
+2 -2
View File
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
+1 -1
View File
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
'--experimental-acp',
'--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
@@ -0,0 +1,2 @@
{"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}}]}
@@ -0,0 +1,2 @@
{"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}}]}
@@ -0,0 +1,2 @@
{"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}}]}
@@ -0,0 +1,2 @@
{"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}}]}
@@ -0,0 +1,2 @@
{"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
@@ -0,0 +1,206 @@
/**
* @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);
});
});
+2 -1
View File
@@ -348,7 +348,8 @@ export class Task {
} else if (isSubagentProgress(outputChunk)) {
outputAsText = JSON.stringify(outputChunk);
} else if (Array.isArray(outputChunk)) {
const ansiOutput: AnsiOutput = outputChunk;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const ansiOutput = outputChunk as AnsiOutput;
outputAsText = ansiOutput
.map((line: AnsiLine) =>
line.map((token: AnsiToken) => token.text).join(''),
@@ -14,7 +14,7 @@ import {
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent, Session } from './zedIntegration.js';
import { GeminiAgent, Session } from './acpClient.js';
import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
@@ -495,7 +495,7 @@ describe('GeminiAgent', () => {
});
expect(session.prompt).toHaveBeenCalled();
expect(result).toEqual({ stopReason: 'end_turn' });
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should delegate setMode to session', async () => {
@@ -687,50 +687,110 @@ describe('Session', () => {
content: { type: 'text', text: 'Hello' },
},
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should accumulate token counts into _meta.quota.token_count and model_usage', async () => {
mockConfig.getModel.mockReturnValue('gemini-1.5-pro-test');
const stream = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'Answer 1' }] } }],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20 },
},
},
]);
mockChat.sendMessageStream.mockResolvedValue(stream);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toEqual({
stopReason: 'end_turn',
_meta: {
quota: {
token_count: { input_tokens: 10, output_tokens: 20 },
model_usage: [
{
model: 'gemini-1.5-pro-test',
token_count: { input_tokens: 10, output_tokens: 20 },
},
],
},
},
});
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
vi.spyOn(
(session as unknown as { commandHandler: CommandHandler }).commandHandler,
'handleCommand',
).mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
}),
}),
);
expect(result).toEqual({ stopReason: 'end_turn' });
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /status command directly', async () => {
mockConfig.getActiveModel.mockReturnValue('gemini-1.5-pro-test');
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/status' }],
});
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({
type: 'text',
text: expect.stringContaining('gemini-1.5-pro-test'),
}),
}),
}),
);
expect(result).toEqual({ stopReason: 'end_turn' });
// Chat should not be called
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
vi.spyOn(
(session as unknown as { commandHandler: CommandHandler }).commandHandler,
'handleCommand',
).mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions list' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions list',
expect.any(Object),
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({
type: 'text',
text: expect.stringContaining('`test-mcp`'),
}),
}),
}),
);
expect(result).toEqual({ stopReason: 'end_turn' });
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
@@ -843,7 +903,7 @@ describe('Session', () => {
}),
}),
);
expect(result).toEqual({ stopReason: 'end_turn' });
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle tool call permission request', async () => {
@@ -70,7 +70,7 @@ import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { CommandHandler } from './commandHandler.js';
export async function runZedIntegration(
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
@@ -646,6 +646,10 @@ export class Session {
let nextMessage: Content | null = { role: 'user', parts };
let totalInputTokens = 0;
let totalOutputTokens = 0;
const modelUsageMap = new Map<string, { input: number; output: number }>();
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
@@ -668,11 +672,20 @@ export class Session {
);
nextMessage = null;
let turnInputTokens = 0;
let turnOutputTokens = 0;
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
turnInputTokens = resp.value.usageMetadata.promptTokenCount ?? 0;
turnOutputTokens =
resp.value.usageMetadata.candidatesTokenCount ?? 0;
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
@@ -704,6 +717,19 @@ export class Session {
}
}
totalInputTokens += turnInputTokens;
totalOutputTokens += turnOutputTokens;
if (turnInputTokens > 0 || turnOutputTokens > 0) {
const existingModelUsage = modelUsageMap.get(model) || {
input: 0,
output: 0,
};
existingModelUsage.input += turnInputTokens;
existingModelUsage.output += turnOutputTokens;
modelUsageMap.set(model, existingModelUsage);
}
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
@@ -740,7 +766,28 @@ export class Session {
}
}
return { stopReason: 'end_turn' };
const modelUsageArray = Array.from(modelUsageMap.entries()).map(
([m, usage]) => ({
model: m,
token_count: {
input_tokens: usage.input,
output_tokens: usage.output,
},
}),
);
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
},
model_usage: modelUsageArray,
},
},
};
}
private async handleCommand(
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
+11 -3
View File
@@ -76,7 +76,8 @@ export interface CliArgs {
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
acp?: boolean;
experimentalAcp?: boolean;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -172,10 +173,15 @@ export async function parseArguments(
.filter(Boolean),
),
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -597,6 +603,7 @@ export async function loadCliConfig(
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
@@ -688,6 +695,7 @@ export async function loadCliConfig(
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -751,7 +759,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ApprovalMode,
PolicyDecision,
@@ -29,6 +29,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
describe('Policy Engine Integration Tests', () => {
beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', ''));
afterEach(() => vi.unstubAllEnvs());
describe('Policy configuration produces valid PolicyEngine config', () => {
it('should create a working PolicyEngine from basic settings', async () => {
const settings: Settings = {
+90 -2
View File
@@ -97,7 +97,7 @@ describe('loadSandboxConfig', () => {
it('should throw if GEMINI_SANDBOX is an invalid command', async () => {
process.env['GEMINI_SANDBOX'] = 'invalid-command';
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, lxc",
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc",
);
});
@@ -194,7 +194,7 @@ describe('loadSandboxConfig', () => {
await expect(
loadSandboxConfig({}, { sandbox: 'invalid-command' }),
).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc",
);
});
});
@@ -247,4 +247,92 @@ describe('loadSandboxConfig', () => {
},
);
});
describe('with sandbox: runsc (gVisor)', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockReturnValue(true);
});
it('should use runsc via CLI argument on Linux', async () => {
const config = await loadSandboxConfig({}, { sandbox: 'runsc' });
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
it('should use runsc via GEMINI_SANDBOX environment variable', async () => {
process.env['GEMINI_SANDBOX'] = 'runsc';
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
it('should use runsc via settings file', async () => {
const config = await loadSandboxConfig(
{ tools: { sandbox: 'runsc' } },
{},
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => {
process.env['GEMINI_SANDBOX'] = 'runsc';
const config = await loadSandboxConfig(
{ tools: { sandbox: 'docker' } },
{ sandbox: 'podman' },
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
});
it('should reject runsc on macOS (Linux-only)', async () => {
mockedOsPlatform.mockReturnValue('darwin');
await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
'gVisor (runsc) sandboxing is only supported on Linux',
);
});
it('should reject runsc on Windows (Linux-only)', async () => {
mockedOsPlatform.mockReturnValue('win32');
await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
'gVisor (runsc) sandboxing is only supported on Linux',
);
});
it('should throw if runsc binary not found', async () => {
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
"Missing sandbox command 'runsc' (from GEMINI_SANDBOX)",
);
});
it('should throw if Docker not available (runsc requires Docker)', async () => {
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc');
await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
"runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.",
);
});
it('should NOT auto-detect runsc when both runsc and docker available', async () => {
mockedCommandExistsSync.mockImplementation(
(cmd) => cmd === 'runsc' || cmd === 'docker',
);
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('docker');
expect(config?.command).not.toBe('runsc');
});
});
});
+20 -6
View File
@@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
'podman',
'sandbox-exec',
'runsc',
'lxc',
];
@@ -64,17 +65,30 @@ function getSandboxCommand(
)}`,
);
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
return sandbox;
// runsc (gVisor) is only supported on Linux
if (sandbox === 'runsc' && os.platform() !== 'linux') {
throw new FatalSandboxError(
'gVisor (runsc) sandboxing is only supported on Linux',
);
}
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
// confirm that specified command exists
if (!commandExists.sync(sandbox)) {
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
}
// runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen)
if (sandbox === 'runsc' && !commandExists.sync('docker')) {
throw new FatalSandboxError(
"runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.",
);
}
return sandbox;
}
// look for seatbelt, docker, or podman, in that order
// for container-based sandboxing, require sandbox to be enabled explicitly
// note: runsc is NOT auto-detected, it must be explicitly specified
if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
return 'sandbox-exec';
} else if (commandExists.sync('docker') && sandbox === true) {
+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/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
+3 -3
View File
@@ -79,7 +79,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -672,8 +672,8 @@ export async function main() {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, argv);
if (config.getAcpMode()) {
return runAcpClient(config, settings, argv);
}
let input = config.getQuestion();
+2 -2
View File
@@ -179,7 +179,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.restoreAllMocks();
});
it('should log error when cleanupExpiredSessions fails', async () => {
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
@@ -216,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getAcpMode: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
+1 -1
View File
@@ -42,7 +42,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
+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/default-light.js';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
@@ -37,6 +37,7 @@ import {
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
@@ -494,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.insert(textToInsert, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Press Ctrl+O to expand pasted text',
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
type: TransientMessageType.Hint,
});
}
@@ -730,7 +731,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.handleInput(key);
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Press Ctrl+O to expand pasted text',
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
type: TransientMessageType.Hint,
});
}
@@ -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,6 +137,14 @@ describe('<ShellToolMessage />', () => {
{ status: CoreToolCallStatus.Error, resultDisplay: 'Error output' },
undefined,
],
[
'renders in Cancelled state with partial output',
{
status: CoreToolCallStatus.Cancelled,
resultDisplay: 'Partial output before cancellation',
},
undefined,
],
[
'renders in Alternate Buffer mode while focused',
{
@@ -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,6 +309,14 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Cancelled state with partial output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ - Shell Command A shell command │
│ │
│ Partial output before cancellation │
"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
@@ -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,6 +25,7 @@ import {
} from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
/**
* Represents a single item in the settings dialog.
@@ -625,7 +626,7 @@ export function BaseSettingsDialog({
{/* Help text */}
<Box marginX={1}>
<Text color={theme.text.secondary}>
(Use Enter to select, Ctrl+L to reset
(Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
</Text>
</Box>
@@ -786,6 +786,8 @@ 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/holiday.js', () => ({
vi.mock('../themes/builtin/dark/holiday-dark.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/holiday.js';
import { Holiday } from '../themes/builtin/dark/holiday-dark.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/default-light.js', () => ({
vi.mock('../themes/builtin/light/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/default-light.js';
import { DefaultLight } from '../themes/builtin/light/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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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,11 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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,10 +1,10 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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,11 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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 2025 Google LLC
* Copyright 2026 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,12 +1,12 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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',
@@ -1,11 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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',
@@ -1,12 +1,12 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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',
+18 -18
View File
@@ -1,23 +1,23 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 { 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 * 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 './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.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 process from 'node:process';
import { debugLogger, homedir } from '@google/gemini-cli-core';
@@ -98,6 +98,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -141,6 +142,8 @@ describe('TerminalCapabilityManager', () => {
// Should resolve without waiting for timeout
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
});
@@ -156,6 +159,7 @@ describe('TerminalCapabilityManager', () => {
vi.advanceTimersByTime(1000);
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -167,6 +171,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
});
@@ -181,6 +186,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -196,6 +202,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -210,6 +218,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
@@ -224,6 +234,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(enableKittyKeyboardProtocol).toHaveBeenCalled();
@@ -241,6 +252,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -257,6 +270,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -272,6 +287,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.getTerminalBackgroundColor()).toBe('#1a1a1a');
expect(manager.getTerminalName()).toBe('tmux');
@@ -287,6 +304,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
@@ -138,9 +138,6 @@ export class TerminalCapabilityManager {
process.stdin.setRawMode(false);
}
this.detectionComplete = true;
this.enableSupportedModes();
resolve();
};
@@ -246,9 +243,11 @@ 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.
+53
View File
@@ -573,4 +573,57 @@ describe('sandbox', () => {
});
});
});
describe('gVisor (runsc)', () => {
it('should use docker with --runtime=runsc on Linux', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
const config: SandboxConfig = {
command: 'runsc',
image: 'gemini-cli-sandbox',
};
// Mock image check
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
// Mock docker run
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config, [], undefined, ['arg1']);
// Verify docker (not runsc) is called for image check
expect(spawn).toHaveBeenNthCalledWith(
1,
'docker',
expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']),
);
// Verify docker run includes --runtime=runsc
expect(spawn).toHaveBeenNthCalledWith(
2,
'docker',
expect.arrayContaining(['run', '--runtime=runsc']),
expect.objectContaining({ stdio: 'inherit' }),
);
});
});
});
+45 -16
View File
@@ -215,7 +215,10 @@ export async function start_sandbox(
return await start_lxc_sandbox(config, nodeArgs, cliArgs);
}
debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`);
// runsc uses docker with --runtime=runsc
const command = config.command === 'runsc' ? 'docker' : config.command;
debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
// determine full path for gemini-cli to distinguish linked vs installed setting
const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
@@ -258,7 +261,7 @@ export async function start_sandbox(
stdio: 'inherit',
env: {
...process.env,
GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package)
GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package)
},
},
);
@@ -266,9 +269,7 @@ export async function start_sandbox(
}
// stop if image is missing
if (
!(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
) {
if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
const remedy =
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
@@ -282,11 +283,17 @@ export async function start_sandbox(
// run init binary inside container to forward signals & reap zombies
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
// add runsc runtime if using runsc
if (config.command === 'runsc') {
args.push('--runtime=runsc');
}
// add custom flags from SANDBOX_FLAGS
if (process.env['SANDBOX_FLAGS']) {
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
(f): f is string => typeof f === 'string',
);
args.push(...flags);
}
@@ -422,7 +429,7 @@ export async function start_sandbox(
// if using proxy, switch to internal networking through proxy
if (proxy) {
execSync(
`${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
`${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create --internal ${SANDBOX_NETWORK_NAME}`,
);
args.push('--network', SANDBOX_NETWORK_NAME);
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
@@ -430,7 +437,7 @@ export async function start_sandbox(
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
if (proxyCommand) {
execSync(
`${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
`${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
);
}
}
@@ -449,7 +456,7 @@ export async function start_sandbox(
} else {
let index = 0;
const containerNameCheck = (
await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
await execAsync(`${command} ps -a --format "{{.Names}}"`)
).stdout.trim();
while (containerNameCheck.includes(`${imageName}-${index}`)) {
index++;
@@ -599,7 +606,7 @@ export async function start_sandbox(
args.push('--env', `SANDBOX=${containerName}`);
// for podman only, use empty --authfile to skip unnecessary auth refresh overhead
if (config.command === 'podman') {
if (command === 'podman') {
const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
args.push('--authfile', emptyAuthFilePath);
@@ -663,16 +670,38 @@ export async function start_sandbox(
if (proxyCommand) {
// run proxyCommand in its own container
const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
proxyProcess = spawn(proxyContainerCommand, {
// build args array to prevent command injection
const proxyContainerArgs = [
'run',
'--rm',
'--init',
...(userFlag ? userFlag.split(' ') : []),
'--name',
SANDBOX_PROXY_NAME,
'--network',
SANDBOX_PROXY_NAME,
'-p',
'8877:8877',
'-v',
`${process.cwd()}:${workdir}`,
'--workdir',
workdir,
image,
// proxyCommand may be a shell string, so parse it into tokens safely
...parse(proxyCommand, process.env).filter(
(f): f is string => typeof f === 'string',
),
];
proxyProcess = spawn(command, proxyContainerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
shell: false, // <-- no shell; args are passed directly
detached: true,
});
// install handlers to stop proxy on exit/signal
const stopProxy = () => {
debugLogger.log('stopping proxy container ...');
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
@@ -693,7 +722,7 @@ export async function start_sandbox(
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
`Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
`Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
@@ -703,13 +732,13 @@ export async function start_sandbox(
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await execAsync(
`${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
`${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
sandboxProcess = spawn(command, args, {
stdio: 'inherit',
});
@@ -147,7 +147,7 @@ describe('BrowserManager', () => {
// Verify StdioClientTransport was created with correct args
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'npx',
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: expect.arrayContaining([
'-y',
expect.stringMatching(/chrome-devtools-mcp@/),
@@ -185,7 +185,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'npx',
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: expect.arrayContaining(['--headless']),
}),
);
@@ -210,7 +210,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'npx',
command: process.platform === 'win32' ? 'npx.cmd' : '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: 'npx',
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: mcpArgs,
stderr: 'pipe',
});
+1 -1
View File
@@ -109,7 +109,7 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
getAcpMode: () => false,
isInteractive: () => true,
} as unknown as Config;
+2 -2
View File
@@ -280,8 +280,8 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
// In Zed integration, we skip the interactive consent and directly open the browser
if (!config.getExperimentalZedIntegration()) {
// In ACP mode, we skip the interactive consent and directly open the browser
if (!config.getAcpMode()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
+7 -8
View File
@@ -446,7 +446,7 @@ export enum AuthProviderType {
}
export interface SandboxConfig {
command: 'docker' | 'podman' | 'sandbox-exec' | 'lxc';
command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
image: string;
}
@@ -515,7 +515,7 @@ export interface ConfigParameters {
model: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
experimentalZedIntegration?: boolean;
acpMode?: boolean;
listSessions?: boolean;
deleteSession?: string;
listExtensions?: boolean;
@@ -713,7 +713,7 @@ export class Config implements McpContext {
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly experimentalZedIntegration: boolean = false;
private readonly acpMode: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly includeDirectoryTree: boolean = true;
private readonly importFormat: 'tree' | 'flat';
@@ -910,8 +910,7 @@ export class Config implements McpContext {
DEFAULT_PROTECT_LATEST_TURN,
};
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.acpMode = params.acpMode ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.listExtensions = params.listExtensions ?? false;
@@ -1164,7 +1163,7 @@ export class Config implements McpContext {
}
});
if (!this.interactive || this.experimentalZedIntegration) {
if (!this.interactive || this.acpMode) {
await this.mcpInitializationPromise;
}
@@ -2230,8 +2229,8 @@ export class Config implements McpContext {
return this.usageStatisticsEnabled;
}
getExperimentalZedIntegration(): boolean {
return this.experimentalZedIntegration;
getAcpMode(): boolean {
return this.acpMode;
}
async waitForMcpInit(): Promise<void> {
@@ -159,7 +159,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -333,7 +333,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -439,8 +439,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -614,7 +614,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -765,7 +765,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1132,8 +1132,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1245,8 +1245,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1366,8 +1366,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1492,8 +1492,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1663,7 +1663,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1814,7 +1814,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1969,7 +1969,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2124,7 +2124,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2275,7 +2275,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2418,7 +2418,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2568,7 +2568,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2719,7 +2719,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2825,8 +2825,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -2939,8 +2939,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -3111,7 +3111,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3262,7 +3262,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3525,7 +3525,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3676,7 +3676,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3782,8 +3782,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
+49
View File
@@ -28,6 +28,7 @@ import {
GeminiEventType,
Turn,
type ChatCompressionInfo,
type ServerGeminiStreamEvent,
} from './turn.js';
import { getCoreSystemPrompt } from './prompts.js';
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
@@ -1118,6 +1119,54 @@ ${JSON.stringify(
// The actual token calculation is unit tested in tokenCalculation.test.ts
});
it('should cleanly abort and return Turn on LoopDetected without unhandled promise rejections', async () => {
// Arrange
const mockStream = (async function* () {
// Yield an event that will trigger the loop detector
yield { type: 'content', value: 'Looping content' };
})();
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
// Mock loop detector to return count > 1 on the first event (loop detected)
vi.spyOn(client['loopDetector'], 'addAndCheck').mockReturnValue({
count: 2,
});
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-1',
);
const events: ServerGeminiStreamEvent[] = [];
let finalResult: Turn | undefined;
while (true) {
const result = await stream.next();
if (result.done) {
finalResult = result.value;
break;
}
events.push(result.value);
}
// Assert
expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
expect(abortSpy).toHaveBeenCalled();
expect(finalResult).toBeInstanceOf(Turn);
});
it('should return the turn instance after the stream is complete', async () => {
// Arrange
const mockStream = (async function* () {
+25 -13
View File
@@ -708,27 +708,22 @@ export class GeminiClient {
let isError = false;
let isInvalidStream = false;
let loopDetectedAbort = false;
let loopRecoverResult: { detail?: string } | undefined;
for await (const event of resultStream) {
const loopResult = this.loopDetector.addAndCheck(event);
if (loopResult.count > 1) {
yield { type: GeminiEventType.LoopDetected };
controller.abort();
return turn;
loopDetectedAbort = true;
break;
} else if (loopResult.count === 1) {
if (boundedTurns <= 1) {
yield { type: GeminiEventType.MaxSessionTurns };
controller.abort();
return turn;
loopDetectedAbort = true;
break;
}
return yield* this._recoverFromLoop(
loopResult,
signal,
prompt_id,
boundedTurns,
isInvalidStreamRetry,
displayContent,
controller,
);
loopRecoverResult = loopResult;
break;
}
yield event;
@@ -742,6 +737,23 @@ export class GeminiClient {
}
}
if (loopDetectedAbort) {
controller.abort();
return turn;
}
if (loopRecoverResult) {
return yield* this._recoverFromLoop(
loopRecoverResult,
signal,
prompt_id,
boundedTurns,
isInvalidStreamRetry,
displayContent,
controller,
);
}
if (isError) {
return turn;
}
+4 -4
View File
@@ -599,24 +599,24 @@ describe('Core System Prompt (prompts.ts)', () => {
expect(prompt).not.toContain('via `&`');
});
it("should include 'ctrl + f' instructions when interactive shell is enabled", () => {
it("should include 'tab' instructions when interactive shell is enabled", () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(true);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('ctrl + f');
expect(prompt).toContain('tab');
});
it("should NOT include 'ctrl + f' instructions when interactive shell is disabled", () => {
it("should NOT include 'tab' instructions when interactive shell is disabled", () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(false);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('ctrl + f');
expect(prompt).not.toContain('`tab`');
});
});
+58 -513
View File
@@ -4,9 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import type * as net from 'node:net';
import { URL } from 'node:url';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import type { OAuthToken } from './token-storage/types.js';
@@ -16,6 +14,23 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getConsentForOauth } from '../utils/authConsent.js';
import {
generatePKCEParams,
startCallbackServer,
getPortFromUrl,
buildAuthorizationUrl,
exchangeCodeForToken,
refreshAccessToken as refreshAccessTokenShared,
REDIRECT_PATH,
type OAuthFlowConfig,
type OAuthTokenResponse,
} from '../utils/oauth-flow.js';
// Re-export types that were moved to oauth-flow.ts for backward compatibility.
export type {
OAuthAuthorizationResponse,
OAuthTokenResponse,
} from '../utils/oauth-flow.js';
/**
* OAuth configuration for an MCP server.
@@ -34,25 +49,6 @@ export interface MCPOAuthConfig {
registrationUrl?: string;
}
/**
* OAuth authorization response.
*/
export interface OAuthAuthorizationResponse {
code: string;
state: string;
}
/**
* OAuth token response from the authorization server.
*/
export interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
}
/**
* Dynamic client registration request (RFC 7591).
*/
@@ -80,18 +76,6 @@ export interface OAuthClientRegistrationResponse {
scope?: string;
}
/**
* PKCE (Proof Key for Code Exchange) parameters.
*/
interface PKCEParams {
codeVerifier: string;
codeChallenge: string;
state: string;
}
const REDIRECT_PATH = '/oauth/callback';
const HTTP_OK = 200;
/**
* Provider for handling OAuth authentication for MCP servers.
*/
@@ -239,375 +223,18 @@ export class MCPOAuthProvider {
}
/**
* Generate PKCE parameters for OAuth flow.
*
* @returns PKCE parameters including code verifier, challenge, and state
* Build the OAuth resource parameter from an MCP server URL, if available.
* Returns undefined if the URL is not provided or cannot be processed.
*/
private generatePKCEParams(): PKCEParams {
// Generate code verifier (43-128 characters)
// using 64 bytes results in ~86 characters, safely above the minimum of 43
const codeVerifier = crypto.randomBytes(64).toString('base64url');
// Generate code challenge using SHA256
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Generate state for CSRF protection
const state = crypto.randomBytes(16).toString('base64url');
return { codeVerifier, codeChallenge, state };
}
/**
* Start a local HTTP server to handle OAuth callback.
* The server will listen on the specified port (or port 0 for OS assignment).
*
* @param expectedState The state parameter to validate
* @returns Object containing the port (available immediately) and a promise for the auth response
*/
private startCallbackServer(
expectedState: string,
port?: number,
): {
port: Promise<number>;
response: Promise<OAuthAuthorizationResponse>;
} {
let portResolve: (port: number) => void;
let portReject: (error: Error) => void;
const portPromise = new Promise<number>((resolve, reject) => {
portResolve = resolve;
portReject = reject;
});
const responsePromise = new Promise<OAuthAuthorizationResponse>(
(resolve, reject) => {
let serverPort: number;
const server = http.createServer(
async (req: http.IncomingMessage, res: http.ServerResponse) => {
try {
const url = new URL(req.url!, `http://localhost:${serverPort}`);
if (url.pathname !== REDIRECT_PATH) {
res.writeHead(404);
res.end('Not found');
return;
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>You can close this window.</p>
</body>
</html>
`);
server.close();
reject(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.writeHead(400);
res.end('Missing code or state parameter');
return;
}
if (state !== expectedState) {
res.writeHead(400);
res.end('Invalid state parameter');
server.close();
reject(new Error('State mismatch - possible CSRF attack'));
return;
}
// Send success response to browser
res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authentication Successful!</h1>
<p>You can close this window and return to Gemini CLI.</p>
<script>window.close();</script>
</body>
</html>
`);
server.close();
resolve({ code, state });
} catch (error) {
server.close();
reject(error);
}
},
);
server.on('error', (error) => {
portReject(error);
reject(error);
});
// Determine which port to use (env var, argument, or OS-assigned)
let listenPort = 0; // Default to OS-assigned port
const portStr = process.env['OAUTH_CALLBACK_PORT'];
if (portStr) {
const envPort = parseInt(portStr, 10);
if (isNaN(envPort) || envPort <= 0 || envPort > 65535) {
const error = new Error(
`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`,
);
portReject(error);
reject(error);
return;
}
listenPort = envPort;
} else if (port !== undefined) {
listenPort = port;
}
server.listen(listenPort, () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const address = server.address() as net.AddressInfo;
serverPort = address.port;
debugLogger.log(
`OAuth callback server listening on port ${serverPort}`,
);
portResolve(serverPort); // Resolve port promise immediately
});
// Timeout after 5 minutes
setTimeout(
() => {
server.close();
reject(new Error('OAuth callback timeout'));
},
5 * 60 * 1000,
);
},
);
return { port: portPromise, response: responsePromise };
}
/**
* Extract the port number from a URL string if available and valid.
*
* @param urlString The URL string to parse
* @returns The port number or undefined if not found or invalid
*/
private getPortFromUrl(urlString?: string): number | undefined {
if (!urlString) {
return undefined;
}
private buildResourceParam(mcpServerUrl?: string): string | undefined {
if (!mcpServerUrl) return undefined;
try {
const url = new URL(urlString);
if (url.port) {
const parsedPort = parseInt(url.port, 10);
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
return parsedPort;
}
}
} catch {
// Ignore invalid URL
}
return undefined;
}
/**
* Build the authorization URL for the OAuth flow.
*
* @param config OAuth configuration
* @param pkceParams PKCE parameters
* @param redirectPort The port to use for the redirect URI
* @param mcpServerUrl The MCP server URL to use as the resource parameter
* @returns The authorization URL
*/
private buildAuthorizationUrl(
config: MCPOAuthConfig,
pkceParams: PKCEParams,
redirectPort: number,
mcpServerUrl?: string,
): string {
const redirectUri =
config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;
const params = new URLSearchParams({
client_id: config.clientId!,
response_type: 'code',
redirect_uri: redirectUri,
state: pkceParams.state,
code_challenge: pkceParams.codeChallenge,
code_challenge_method: 'S256',
});
if (config.scopes && config.scopes.length > 0) {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
if (mcpServerUrl) {
try {
params.append(
'resource',
OAuthUtils.buildResourceParameter(mcpServerUrl),
);
} catch (error) {
debugLogger.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
}
const url = new URL(config.authorizationUrl!);
params.forEach((value, key) => {
url.searchParams.append(key, value);
});
return url.toString();
}
/**
* Exchange authorization code for tokens.
*
* @param config OAuth configuration
* @param code Authorization code
* @param codeVerifier PKCE code verifier
* @param redirectPort The port to use for the redirect URI
* @param mcpServerUrl The MCP server URL to use as the resource parameter
* @returns The token response
*/
private async exchangeCodeForToken(
config: MCPOAuthConfig,
code: string,
codeVerifier: string,
redirectPort: number,
mcpServerUrl?: string,
): Promise<OAuthTokenResponse> {
const redirectUri =
config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: config.clientId!,
});
if (config.clientSecret) {
params.append('client_secret', config.clientSecret);
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
if (mcpServerUrl) {
const resourceUrl = mcpServerUrl;
try {
params.append(
'resource',
OAuthUtils.buildResourceParameter(resourceUrl),
);
} catch (error) {
debugLogger.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
}
const response = await fetch(config.tokenUrl!, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
},
body: params.toString(),
});
const responseText = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
// Try to parse error from form-urlencoded response
let errorMessage: string | null = null;
try {
const errorParams = new URLSearchParams(responseText);
const error = errorParams.get('error');
const errorDescription = errorParams.get('error_description');
if (error) {
errorMessage = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`;
}
} catch {
// Fall back to raw error
}
throw new Error(
errorMessage ||
`Token exchange failed: ${response.status} - ${responseText}`,
);
}
// Log unexpected content types for debugging
if (
!contentType.includes('application/json') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
return OAuthUtils.buildResourceParameter(mcpServerUrl);
} catch (error) {
debugLogger.warn(
`Token endpoint returned unexpected content-type: ${contentType}. ` +
`Expected application/json or application/x-www-form-urlencoded. ` +
`Will attempt to parse response.`,
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
// Try to parse as JSON first, fall back to form-urlencoded
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(responseText) as OAuthTokenResponse;
} catch {
// Parse form-urlencoded response
const tokenParams = new URLSearchParams(responseText);
const accessToken = tokenParams.get('access_token');
const tokenType = tokenParams.get('token_type') || 'Bearer';
const expiresIn = tokenParams.get('expires_in');
const refreshToken = tokenParams.get('refresh_token');
const scope = tokenParams.get('scope');
if (!accessToken) {
// Check for error in response
const error = tokenParams.get('error');
const errorDescription = tokenParams.get('error_description');
throw new Error(
`Token exchange failed: ${error || 'no_access_token'} - ${errorDescription || responseText}`,
);
}
return {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
refresh_token: refreshToken || undefined,
scope: scope || undefined,
} as OAuthTokenResponse;
return undefined;
}
}
@@ -626,112 +253,21 @@ export class MCPOAuthProvider {
tokenUrl: string,
mcpServerUrl?: string,
): Promise<OAuthTokenResponse> {
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId!,
});
if (config.clientSecret) {
params.append('client_secret', config.clientSecret);
if (!config.clientId) {
throw new Error('Missing required clientId for token refresh');
}
if (config.scopes && config.scopes.length > 0) {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
if (mcpServerUrl) {
try {
params.append(
'resource',
OAuthUtils.buildResourceParameter(mcpServerUrl),
);
} catch (error) {
debugLogger.warn(
`Could not add resource parameter: ${getErrorMessage(error)}`,
);
}
}
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
return refreshAccessTokenShared(
{
clientId: config.clientId,
clientSecret: config.clientSecret,
scopes: config.scopes,
audiences: config.audiences,
},
body: params.toString(),
});
const responseText = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
// Try to parse error from form-urlencoded response
let errorMessage: string | null = null;
try {
const errorParams = new URLSearchParams(responseText);
const error = errorParams.get('error');
const errorDescription = errorParams.get('error_description');
if (error) {
errorMessage = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`;
}
} catch {
// Fall back to raw error
}
throw new Error(
errorMessage ||
`Token refresh failed: ${response.status} - ${responseText}`,
);
}
// Log unexpected content types for debugging
if (
!contentType.includes('application/json') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
debugLogger.warn(
`Token refresh endpoint returned unexpected content-type: ${contentType}. ` +
`Expected application/json or application/x-www-form-urlencoded. ` +
`Will attempt to parse response.`,
);
}
// Try to parse as JSON first, fall back to form-urlencoded
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(responseText) as OAuthTokenResponse;
} catch {
// Parse form-urlencoded response
const tokenParams = new URLSearchParams(responseText);
const accessToken = tokenParams.get('access_token');
const tokenType = tokenParams.get('token_type') || 'Bearer';
const expiresIn = tokenParams.get('expires_in');
const refreshToken = tokenParams.get('refresh_token');
const scope = tokenParams.get('scope');
if (!accessToken) {
// Check for error in response
const error = tokenParams.get('error');
const errorDescription = tokenParams.get('error_description');
throw new Error(
`Token refresh failed: ${error || 'unknown_error'} - ${errorDescription || responseText}`,
);
}
return {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
refresh_token: refreshToken || undefined,
scope: scope || undefined,
} as OAuthTokenResponse;
}
refreshToken,
tokenUrl,
this.buildResourceParam(mcpServerUrl),
);
}
/**
@@ -830,17 +366,14 @@ export class MCPOAuthProvider {
}
// Generate PKCE parameters
const pkceParams = this.generatePKCEParams();
const pkceParams = generatePKCEParams();
// Determine preferred port from redirectUri if available
const preferredPort = this.getPortFromUrl(config.redirectUri);
const preferredPort = getPortFromUrl(config.redirectUri);
// Start callback server first to allocate port
// This ensures we only create one server and eliminates race conditions
const callbackServer = this.startCallbackServer(
pkceParams.state,
preferredPort,
);
const callbackServer = startCallbackServer(pkceParams.state, preferredPort);
// Wait for server to start and get the allocated port
// We need this port for client registration and auth URL building
@@ -892,12 +425,24 @@ export class MCPOAuthProvider {
);
}
// Build flow config for shared utilities
const flowConfig: OAuthFlowConfig = {
clientId: config.clientId,
clientSecret: config.clientSecret,
authorizationUrl: config.authorizationUrl,
tokenUrl: config.tokenUrl,
scopes: config.scopes,
audiences: config.audiences,
redirectUri: config.redirectUri,
};
// Build authorization URL
const authUrl = this.buildAuthorizationUrl(
config,
const resource = this.buildResourceParam(mcpServerUrl);
const authUrl = buildAuthorizationUrl(
flowConfig,
pkceParams,
redirectPort,
mcpServerUrl,
resource,
);
const userConsent = await getConsentForOauth(
@@ -933,12 +478,12 @@ ${authUrl}
);
// Exchange code for tokens
const tokenResponse = await this.exchangeCodeForToken(
config,
const tokenResponse = await exchangeCodeForToken(
flowConfig,
code,
pkceParams.codeVerifier,
redirectPort,
mcpServerUrl,
resource,
);
// Convert to our token format
@@ -51,3 +51,8 @@ priority = 50
toolName = "google_web_search"
decision = "allow"
priority = 50
[[rule]]
toolName = ["codebase_investigator", "cli_help"]
decision = "allow"
priority = 50
+5 -4
View File
@@ -15,6 +15,7 @@ import {
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_PARAM_IS_BACKGROUND,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -599,12 +600,12 @@ function toolUsageInteractive(
interactiveShellEnabled: boolean,
): string {
if (interactive) {
const ctrlF = interactiveShellEnabled
? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.'
const focusHint = interactiveShellEnabled
? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.'
: '';
return `
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${ctrlF}`;
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`;
}
return `
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+3 -3
View File
@@ -665,12 +665,12 @@ function toolUsageInteractive(
interactiveShellEnabled: boolean,
): string {
if (interactive) {
const ctrlF = interactiveShellEnabled
? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.'
const focusHint = interactiveShellEnabled
? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.'
: '';
return `
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${ctrlF}`;
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`;
}
return `
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true.
+13 -2
View File
@@ -477,13 +477,24 @@ export class SchedulerStateManager {
}
}
// Capture any existing live output so it isn't lost when forcing cancellation.
let existingOutput: ToolResultDisplay | undefined = undefined;
if (call.status === CoreToolCallStatus.Executing && call.liveOutput) {
existingOutput = call.liveOutput;
}
if (isToolCallResponseInfo(reason)) {
const finalResponse = { ...reason };
if (!finalResponse.resultDisplay) {
finalResponse.resultDisplay = resultDisplay ?? existingOutput;
}
return {
request: call.request,
tool: call.tool,
invocation: call.invocation,
status: CoreToolCallStatus.Cancelled,
response: reason,
response: finalResponse,
durationMs: startTime ? Date.now() - startTime : undefined,
outcome: call.outcome,
schedulerId: call.schedulerId,
@@ -508,7 +519,7 @@ export class SchedulerStateManager {
},
},
],
resultDisplay,
resultDisplay: resultDisplay ?? existingOutput,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
@@ -206,6 +206,48 @@ describe('sanitizeEnvironment', () => {
});
});
describe('value-first security: secret values must be caught even for allowed variable names', () => {
it('should redact ALWAYS_ALLOWED variables whose values contain a GitHub token', () => {
const env = {
HOME: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
PATH: '/usr/bin',
};
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
expect(sanitized).toEqual({ PATH: '/usr/bin' });
});
it('should redact ALWAYS_ALLOWED variables whose values contain a certificate', () => {
const env = {
SHELL:
'-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----',
USER: 'alice',
};
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
expect(sanitized).toEqual({ USER: 'alice' });
});
it('should redact user-allowlisted variables whose values contain a secret', () => {
const env = {
MY_SAFE_VAR: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
OTHER: 'fine',
};
const sanitized = sanitizeEnvironment(env, {
allowedEnvironmentVariables: ['MY_SAFE_VAR'],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: true,
});
expect(sanitized).toEqual({ OTHER: 'fine' });
});
it('should NOT redact GEMINI_CLI_ variables even if their value looks like a secret (fully trusted)', () => {
const env = {
GEMINI_CLI_INTERNAL: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
};
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
expect(sanitized).toEqual(env);
});
});
it('should ensure all names in the sets are capitalized', () => {
for (const name of ALWAYS_ALLOWED_ENVIRONMENT_VARIABLES) {
expect(name).toBe(name.toUpperCase());
@@ -14,11 +14,9 @@ export function sanitizeEnvironment(
processEnv: NodeJS.ProcessEnv,
config: EnvironmentSanitizationConfig,
): NodeJS.ProcessEnv {
// Enable strict sanitization in GitHub actions.
const isStrictSanitization =
!!processEnv['GITHUB_SHA'] || processEnv['SURFACE'] === 'Github';
// Always sanitize when in GitHub actions.
if (!config.enableEnvironmentVariableRedaction && !isStrictSanitization) {
return { ...processEnv };
}
@@ -148,7 +146,18 @@ function shouldRedactEnvironmentVariable(
key = key.toUpperCase();
value = value?.toUpperCase();
// User overrides take precedence.
if (key.startsWith('GEMINI_CLI_')) {
return false;
}
if (value) {
for (const pattern of NEVER_ALLOWED_VALUE_PATTERNS) {
if (pattern.test(value)) {
return true;
}
}
}
if (allowedSet?.has(key)) {
return false;
}
@@ -156,20 +165,14 @@ function shouldRedactEnvironmentVariable(
return true;
}
// These are never redacted.
if (
ALWAYS_ALLOWED_ENVIRONMENT_VARIABLES.has(key) ||
key.startsWith('GEMINI_CLI_')
) {
if (ALWAYS_ALLOWED_ENVIRONMENT_VARIABLES.has(key)) {
return false;
}
// These are always redacted.
if (NEVER_ALLOWED_ENVIRONMENT_VARIABLES.has(key)) {
return true;
}
// If in strict mode (e.g. GitHub Action), and not explicitly allowed, redact it.
if (isStrictSanitization) {
return true;
}
@@ -180,14 +183,5 @@ function shouldRedactEnvironmentVariable(
}
}
// Redact if the value looks like a key/cert.
if (value) {
for (const pattern of NEVER_ALLOWED_VALUE_PATTERNS) {
if (pattern.test(value)) {
return true;
}
}
}
return false;
}
@@ -49,6 +49,7 @@ import type {
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
StartupStatsEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
@@ -117,6 +118,7 @@ export enum EventNames {
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
CONSECA_VERDICT = 'conseca_verdict',
STARTUP_STATS = 'startup_stats',
}
export interface LogResponse {
@@ -1691,6 +1693,30 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logStartupStatsEvent(event: StartupStatsEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_PHASES,
value: JSON.stringify(event.phases),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_OS_PLATFORM,
value: event.os_platform,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_OS_RELEASE,
value: event.os_release,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_STARTUP_IS_DOCKER,
value: JSON.stringify(event.is_docker),
},
];
this.enqueueLogEvent(this.createLogEvent(EventNames.STARTUP_STATS, data));
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 172
// Next ID: 176
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -54,6 +54,22 @@ export enum EventMetadataKey {
// Logs the output format of the session.
GEMINI_CLI_START_SESSION_OUTPUT_FORMAT = 94,
// ==========================================================================
// Startup Stats Event Keys
// ==========================================================================
// Logs the array of startup phases.
GEMINI_CLI_STARTUP_PHASES = 172,
// Logs the OS platform for startup stats.
GEMINI_CLI_STARTUP_OS_PLATFORM = 173,
// Logs the OS release for startup stats.
GEMINI_CLI_STARTUP_OS_RELEASE = 174,
// Logs whether the CLI is running in docker for startup stats.
GEMINI_CLI_STARTUP_IS_DOCKER = 175,
// ==========================================================================
// User Prompt Event Keys
// ===========================================================================
+1
View File
@@ -791,6 +791,7 @@ export function logStartupStats(
config: Config,
event: StartupStatsEvent,
): void {
ClearcutLogger.getInstance(config)?.logStartupStatsEvent(event);
bufferTelemetryEvent(() => {
// Wait for experiments to load before emitting so we capture experimentIds
void config
+635
View File
@@ -0,0 +1,635 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type {
OAuthFlowConfig,
OAuthRefreshConfig,
PKCEParams,
} from './oauth-flow.js';
import {
generatePKCEParams,
getPortFromUrl,
buildAuthorizationUrl,
startCallbackServer,
exchangeCodeForToken,
refreshAccessToken,
REDIRECT_PATH,
} from './oauth-flow.js';
// Save real fetch for startCallbackServer tests (which hit a real local server)
const realFetch = global.fetch;
// Mock fetch globally for token exchange/refresh tests
const mockFetch = vi.fn();
global.fetch = mockFetch;
/**
* Helper to create a mock Response object.
*/
function createMockResponse(
body: string,
options: { status?: number; contentType?: string } = {},
): Response {
const { status = 200, contentType = 'application/json' } = options;
return {
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(body),
headers: new Headers({ 'content-type': contentType }),
} as Response;
}
const baseConfig: OAuthFlowConfig = {
clientId: 'test-client-id',
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
};
const basePkceParams: PKCEParams = {
codeVerifier: 'test-verifier',
codeChallenge: 'test-challenge',
state: 'test-state',
};
describe('oauth-flow', () => {
beforeEach(() => {
vi.stubEnv('OAUTH_CALLBACK_PORT', '');
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe('generatePKCEParams', () => {
it('should return codeVerifier, codeChallenge, and state', () => {
const params = generatePKCEParams();
expect(params).toHaveProperty('codeVerifier');
expect(params).toHaveProperty('codeChallenge');
expect(params).toHaveProperty('state');
});
it('should generate a code verifier of at least 43 characters', () => {
const params = generatePKCEParams();
expect(params.codeVerifier.length).toBeGreaterThanOrEqual(43);
});
it('should generate unique values on each call', () => {
const params1 = generatePKCEParams();
const params2 = generatePKCEParams();
expect(params1.codeVerifier).not.toBe(params2.codeVerifier);
expect(params1.state).not.toBe(params2.state);
});
it('should generate base64url-encoded values', () => {
const params = generatePKCEParams();
const base64urlRegex = /^[A-Za-z0-9_-]+$/;
expect(params.codeVerifier).toMatch(base64urlRegex);
expect(params.codeChallenge).toMatch(base64urlRegex);
expect(params.state).toMatch(base64urlRegex);
});
});
describe('getPortFromUrl', () => {
it('should return undefined for undefined input', () => {
expect(getPortFromUrl(undefined)).toBeUndefined();
});
it('should return undefined for empty string', () => {
expect(getPortFromUrl('')).toBeUndefined();
});
it('should return undefined for invalid URL', () => {
expect(getPortFromUrl('not-a-url')).toBeUndefined();
});
it('should return the port number from a URL with an explicit port', () => {
expect(getPortFromUrl('http://localhost:8080/callback')).toBe(8080);
});
it('should return undefined for a URL without an explicit port', () => {
expect(getPortFromUrl('https://example.com/callback')).toBeUndefined();
});
it('should return port for edge case port 1', () => {
expect(getPortFromUrl('http://localhost:1')).toBe(1);
});
it('should return port for edge case port 65535', () => {
expect(getPortFromUrl('http://localhost:65535')).toBe(65535);
});
});
describe('buildAuthorizationUrl', () => {
it('should build a valid authorization URL with required parameters', () => {
const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.origin).toBe('https://auth.example.com');
expect(parsed.pathname).toBe('/authorize');
expect(parsed.searchParams.get('client_id')).toBe('test-client-id');
expect(parsed.searchParams.get('response_type')).toBe('code');
expect(parsed.searchParams.get('state')).toBe('test-state');
expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
});
it('should use the default redirect URI based on port', () => {
const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.get('redirect_uri')).toBe(
`http://localhost:3000${REDIRECT_PATH}`,
);
});
it('should use a custom redirectUri from config when provided', () => {
const config: OAuthFlowConfig = {
...baseConfig,
redirectUri: 'https://custom.example.com/callback',
};
const url = buildAuthorizationUrl(config, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.get('redirect_uri')).toBe(
'https://custom.example.com/callback',
);
});
it('should include scopes when provided', () => {
const config: OAuthFlowConfig = {
...baseConfig,
scopes: ['read', 'write'],
};
const url = buildAuthorizationUrl(config, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.get('scope')).toBe('read write');
});
it('should not include scope param when scopes array is empty', () => {
const config: OAuthFlowConfig = {
...baseConfig,
scopes: [],
};
const url = buildAuthorizationUrl(config, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.has('scope')).toBe(false);
});
it('should include audiences when provided', () => {
const config: OAuthFlowConfig = {
...baseConfig,
audiences: ['https://api.example.com'],
};
const url = buildAuthorizationUrl(config, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.get('audience')).toBe(
'https://api.example.com',
);
});
it('should include resource parameter when provided', () => {
const url = buildAuthorizationUrl(
baseConfig,
basePkceParams,
3000,
'https://mcp.example.com',
);
const parsed = new URL(url);
expect(parsed.searchParams.get('resource')).toBe(
'https://mcp.example.com',
);
});
it('should not include resource parameter when not provided', () => {
const url = buildAuthorizationUrl(baseConfig, basePkceParams, 3000);
const parsed = new URL(url);
expect(parsed.searchParams.has('resource')).toBe(false);
});
});
describe('startCallbackServer', () => {
it('should start a server and resolve port', async () => {
const server = startCallbackServer('test-state');
const port = await server.port;
expect(port).toBeGreaterThan(0);
// Make a successful callback request to close the server
const res = await realFetch(
`http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`,
);
expect(res.status).toBe(200);
await server.response;
});
it('should resolve response with code and state on valid callback', async () => {
const server = startCallbackServer('my-state');
const port = await server.port;
await realFetch(
`http://localhost:${port}${REDIRECT_PATH}?code=auth-code-123&state=my-state`,
);
const response = await server.response;
expect(response.code).toBe('auth-code-123');
expect(response.state).toBe('my-state');
});
it('should reject on state mismatch', async () => {
const server = startCallbackServer('expected-state');
const port = await server.port;
// Attach rejection handler BEFORE triggering the callback to prevent
// unhandled rejection race with Vitest's detection.
const responseResult = server.response.then(
() => new Error('Expected rejection'),
(e: Error) => e,
);
await realFetch(
`http://localhost:${port}${REDIRECT_PATH}?code=abc&state=wrong-state`,
).catch(() => {
// Connection may be reset by server closing — expected
});
const error = await responseResult;
expect(error.message).toContain('State mismatch - possible CSRF attack');
});
it('should reject on OAuth error in callback', async () => {
const server = startCallbackServer('test-state');
const port = await server.port;
// Attach rejection handler BEFORE triggering the callback
const responseResult = server.response.then(
() => new Error('Expected rejection'),
(e: Error) => e,
);
await realFetch(
`http://localhost:${port}${REDIRECT_PATH}?error=access_denied&error_description=User+denied`,
).catch(() => {
// Connection may be reset by server closing — expected
});
const error = await responseResult;
expect(error.message).toContain('OAuth error: access_denied');
});
it('should return 404 for non-callback paths', async () => {
const server = startCallbackServer('test-state');
const port = await server.port;
const res = await realFetch(`http://localhost:${port}/other-path`);
expect(res.status).toBe(404);
// Clean up: send valid callback to close the server
await realFetch(
`http://localhost:${port}${REDIRECT_PATH}?code=abc&state=test-state`,
);
await server.response;
});
it('should reject when OAUTH_CALLBACK_PORT env var is invalid', async () => {
vi.stubEnv('OAUTH_CALLBACK_PORT', 'not-a-number');
const server = startCallbackServer('test-state');
await expect(server.port).rejects.toThrow(
'Invalid value for OAUTH_CALLBACK_PORT',
);
await expect(server.response).rejects.toThrow(
'Invalid value for OAUTH_CALLBACK_PORT',
);
});
});
describe('exchangeCodeForToken', () => {
it('should exchange code for token with JSON response', async () => {
const tokenResponse = {
access_token: 'test-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'test-refresh-token',
};
mockFetch.mockResolvedValueOnce(
createMockResponse(JSON.stringify(tokenResponse)),
);
const result = await exchangeCodeForToken(
baseConfig,
'auth-code',
'code-verifier',
3000,
);
expect(result.access_token).toBe('test-access-token');
expect(result.token_type).toBe('Bearer');
expect(result.expires_in).toBe(3600);
expect(result.refresh_token).toBe('test-refresh-token');
});
it('should send correct parameters in the request body', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await exchangeCodeForToken(baseConfig, 'my-code', 'my-verifier', 4000);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://auth.example.com/token');
const body = new URLSearchParams(options.body as string);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('my-code');
expect(body.get('code_verifier')).toBe('my-verifier');
expect(body.get('client_id')).toBe('test-client-id');
expect(body.get('redirect_uri')).toBe(
`http://localhost:4000${REDIRECT_PATH}`,
);
});
it('should include client_secret when provided', async () => {
const config: OAuthFlowConfig = {
...baseConfig,
clientSecret: 'my-secret',
};
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await exchangeCodeForToken(config, 'code', 'verifier', 3000);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('client_secret')).toBe('my-secret');
});
it('should include resource parameter when provided', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await exchangeCodeForToken(
baseConfig,
'code',
'verifier',
3000,
'https://mcp.example.com',
);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('resource')).toBe('https://mcp.example.com');
});
it('should handle form-urlencoded token response', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
'access_token=form-token&token_type=Bearer&expires_in=7200',
{ contentType: 'application/x-www-form-urlencoded' },
),
);
const result = await exchangeCodeForToken(
baseConfig,
'code',
'verifier',
3000,
);
expect(result.access_token).toBe('form-token');
expect(result.token_type).toBe('Bearer');
expect(result.expires_in).toBe(7200);
});
it('should throw on non-ok response', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse('Bad request', { status: 400 }),
);
await expect(
exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000),
).rejects.toThrow('Token exchange failed');
});
it('should throw on non-ok response with form-urlencoded error', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
'error=invalid_grant&error_description=Code+expired',
{
status: 400,
contentType: 'application/x-www-form-urlencoded',
},
),
);
await expect(
exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000),
).rejects.toThrow('invalid_grant');
});
it('should throw when JSON response has no access_token and form-urlencoded fallback also fails', async () => {
// JSON that parses but has no access_token — falls through to form-urlencoded
// which also has no access_token
mockFetch.mockResolvedValueOnce(
createMockResponse(JSON.stringify({ error: 'server_error' })),
);
await expect(
exchangeCodeForToken(baseConfig, 'code', 'verifier', 3000),
).rejects.toThrow('Token exchange failed');
});
it('should use custom redirectUri from config', async () => {
const config: OAuthFlowConfig = {
...baseConfig,
redirectUri: 'https://custom.example.com/cb',
};
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await exchangeCodeForToken(config, 'code', 'verifier', 3000);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('redirect_uri')).toBe('https://custom.example.com/cb');
});
it('should default token_type to Bearer when missing from JSON response', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(JSON.stringify({ access_token: 'tok' })),
);
const result = await exchangeCodeForToken(
baseConfig,
'code',
'verifier',
3000,
);
expect(result.token_type).toBe('Bearer');
});
});
describe('refreshAccessToken', () => {
const refreshConfig: OAuthRefreshConfig = {
clientId: 'test-client-id',
};
it('should refresh a token with JSON response', async () => {
const tokenResponse = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
};
mockFetch.mockResolvedValueOnce(
createMockResponse(JSON.stringify(tokenResponse)),
);
const result = await refreshAccessToken(
refreshConfig,
'old-refresh-token',
'https://auth.example.com/token',
);
expect(result.access_token).toBe('new-access-token');
expect(result.expires_in).toBe(3600);
});
it('should send correct parameters in the request body', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await refreshAccessToken(
refreshConfig,
'my-refresh-token',
'https://auth.example.com/token',
);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('my-refresh-token');
expect(body.get('client_id')).toBe('test-client-id');
});
it('should include client_secret when provided', async () => {
const config: OAuthRefreshConfig = {
...refreshConfig,
clientSecret: 'secret',
};
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await refreshAccessToken(
config,
'refresh-token',
'https://auth.example.com/token',
);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('client_secret')).toBe('secret');
});
it('should include scopes and audiences when provided', async () => {
const config: OAuthRefreshConfig = {
...refreshConfig,
scopes: ['read', 'write'],
audiences: ['https://api.example.com'],
};
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await refreshAccessToken(
config,
'refresh-token',
'https://auth.example.com/token',
);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('scope')).toBe('read write');
expect(body.get('audience')).toBe('https://api.example.com');
});
it('should include resource parameter when provided', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
JSON.stringify({ access_token: 'tok', token_type: 'Bearer' }),
),
);
await refreshAccessToken(
refreshConfig,
'refresh-token',
'https://auth.example.com/token',
'https://mcp.example.com',
);
const body = new URLSearchParams(
(mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
);
expect(body.get('resource')).toBe('https://mcp.example.com');
});
it('should throw on non-ok response', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse('Unauthorized', { status: 401 }),
);
await expect(
refreshAccessToken(
refreshConfig,
'bad-token',
'https://auth.example.com/token',
),
).rejects.toThrow('Token refresh failed');
});
it('should handle form-urlencoded token response', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse(
'access_token=refreshed-token&token_type=Bearer&expires_in=1800',
{ contentType: 'application/x-www-form-urlencoded' },
),
);
const result = await refreshAccessToken(
refreshConfig,
'refresh-token',
'https://auth.example.com/token',
);
expect(result.access_token).toBe('refreshed-token');
expect(result.expires_in).toBe(1800);
});
});
});
+515
View File
@@ -0,0 +1,515 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Shared OAuth 2.0 Authorization Code flow primitives with PKCE support.
*
* These utilities are protocol-agnostic and can be used by both MCP OAuth
* and A2A OAuth authentication providers.
*/
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import type * as net from 'node:net';
import { URL } from 'node:url';
import { debugLogger } from './debugLogger.js';
/**
* Configuration for an OAuth 2.0 Authorization Code flow.
* Contains only the fields needed by the shared flow utilities.
*/
export interface OAuthFlowConfig {
clientId: string;
clientSecret?: string;
authorizationUrl: string;
tokenUrl: string;
scopes?: string[];
audiences?: string[];
redirectUri?: string;
}
/**
* Configuration subset needed for token refresh operations.
*/
export type OAuthRefreshConfig = Pick<
OAuthFlowConfig,
'clientId' | 'clientSecret' | 'scopes' | 'audiences'
>;
/**
* PKCE (Proof Key for Code Exchange) parameters.
*/
export interface PKCEParams {
codeVerifier: string;
codeChallenge: string;
state: string;
}
/**
* OAuth authorization response from the callback server.
*/
export interface OAuthAuthorizationResponse {
code: string;
state: string;
}
/**
* OAuth token response from the authorization server.
*/
export interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
}
/** The path the local callback server listens on. */
export const REDIRECT_PATH = '/oauth/callback';
const HTTP_OK = 200;
/**
* Generate PKCE parameters for OAuth flow.
*
* @returns PKCE parameters including code verifier, challenge, and state
*/
export function generatePKCEParams(): PKCEParams {
// Generate code verifier (43-128 characters)
// using 64 bytes results in ~86 characters, safely above the minimum of 43
const codeVerifier = crypto.randomBytes(64).toString('base64url');
// Generate code challenge using SHA256
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Generate state for CSRF protection
const state = crypto.randomBytes(16).toString('base64url');
return { codeVerifier, codeChallenge, state };
}
/**
* Start a local HTTP server to handle OAuth callback.
* The server will listen on the specified port (or port 0 for OS assignment).
*
* @param expectedState The state parameter to validate
* @param port Optional preferred port to listen on
* @returns Object containing the port (available immediately) and a promise for the auth response
*/
export function startCallbackServer(
expectedState: string,
port?: number,
): {
port: Promise<number>;
response: Promise<OAuthAuthorizationResponse>;
} {
let portResolve: (port: number) => void;
let portReject: (error: Error) => void;
const portPromise = new Promise<number>((resolve, reject) => {
portResolve = resolve;
portReject = reject;
});
const responsePromise = new Promise<OAuthAuthorizationResponse>(
(resolve, reject) => {
let serverPort: number;
const server = http.createServer(
async (req: http.IncomingMessage, res: http.ServerResponse) => {
try {
const url = new URL(req.url!, `http://localhost:${serverPort}`);
if (url.pathname !== REDIRECT_PATH) {
res.writeHead(404);
res.end('Not found');
return;
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p>You can close this window.</p>
</body>
</html>
`);
server.close();
reject(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.writeHead(400);
res.end('Missing code or state parameter');
return;
}
if (state !== expectedState) {
res.writeHead(400);
res.end('Invalid state parameter');
server.close();
reject(new Error('State mismatch - possible CSRF attack'));
return;
}
// Send success response to browser
res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authentication Successful!</h1>
<p>You can close this window and return to Gemini CLI.</p>
<script>window.close();</script>
</body>
</html>
`);
server.close();
resolve({ code, state });
} catch (error) {
server.close();
reject(error);
}
},
);
server.on('error', (error) => {
portReject(error);
reject(error);
});
// Determine which port to use (env var, argument, or OS-assigned)
let listenPort = 0; // Default to OS-assigned port
const portStr = process.env['OAUTH_CALLBACK_PORT'];
if (portStr) {
const envPort = parseInt(portStr, 10);
if (isNaN(envPort) || envPort <= 0 || envPort > 65535) {
const error = new Error(
`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`,
);
portReject(error);
reject(error);
return;
}
listenPort = envPort;
} else if (port !== undefined) {
listenPort = port;
}
server.listen(listenPort, () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const address = server.address() as net.AddressInfo;
serverPort = address.port;
debugLogger.log(
`OAuth callback server listening on port ${serverPort}`,
);
portResolve(serverPort); // Resolve port promise immediately
});
// Timeout after 5 minutes
setTimeout(
() => {
server.close();
reject(new Error('OAuth callback timeout'));
},
5 * 60 * 1000,
);
},
);
return { port: portPromise, response: responsePromise };
}
/**
* Extract the port number from a URL string if available and valid.
*
* @param urlString The URL string to parse
* @returns The port number or undefined if not found or invalid
*/
export function getPortFromUrl(urlString?: string): number | undefined {
if (!urlString) {
return undefined;
}
try {
const url = new URL(urlString);
if (url.port) {
const parsedPort = parseInt(url.port, 10);
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
return parsedPort;
}
}
} catch {
// Ignore invalid URL
}
return undefined;
}
/**
* Build the authorization URL for the OAuth flow.
*
* @param config OAuth flow configuration
* @param pkceParams PKCE parameters
* @param redirectPort The port to use for the redirect URI
* @param resource Optional resource parameter value (RFC 8707)
* @returns The authorization URL
*/
export function buildAuthorizationUrl(
config: OAuthFlowConfig,
pkceParams: PKCEParams,
redirectPort: number,
resource?: string,
): string {
const redirectUri =
config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;
const params = new URLSearchParams({
client_id: config.clientId,
response_type: 'code',
redirect_uri: redirectUri,
state: pkceParams.state,
code_challenge: pkceParams.codeChallenge,
code_challenge_method: 'S256',
});
if (config.scopes && config.scopes.length > 0) {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
if (resource) {
params.append('resource', resource);
}
const url = new URL(config.authorizationUrl);
params.forEach((value, key) => {
url.searchParams.append(key, value);
});
return url.toString();
}
/**
* Parse a token endpoint response, handling both JSON and form-urlencoded formats.
*
* @param response The HTTP response from the token endpoint
* @param operationName Human-readable operation name for error messages (e.g., "Token exchange", "Token refresh")
* @param defaultErrorCode Default error code when access_token is missing (e.g., "no_access_token", "unknown_error")
* @returns The parsed token response
*/
async function parseTokenEndpointResponse(
response: Response,
operationName: string,
defaultErrorCode: string,
): Promise<OAuthTokenResponse> {
const responseText = await response.text();
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
// Try to parse error from form-urlencoded response
let errorMessage: string | null = null;
try {
const errorParams = new URLSearchParams(responseText);
const error = errorParams.get('error');
const errorDescription = errorParams.get('error_description');
if (error) {
errorMessage = `${operationName} failed: ${error} - ${errorDescription || 'No description'}`;
}
} catch {
// Fall back to raw error
}
throw new Error(
errorMessage ||
`${operationName} failed: ${response.status} - ${responseText}`,
);
}
// Log unexpected content types for debugging
if (
!contentType.includes('application/json') &&
!contentType.includes('application/x-www-form-urlencoded')
) {
debugLogger.warn(
`${operationName} endpoint returned unexpected content-type: ${contentType}. ` +
`Expected application/json or application/x-www-form-urlencoded. ` +
`Will attempt to parse response.`,
);
}
// Try to parse as JSON first, fall back to form-urlencoded
try {
const data: unknown = JSON.parse(responseText);
if (
data &&
typeof data === 'object' &&
'access_token' in data &&
typeof (data as Record<string, unknown>)['access_token'] === 'string'
) {
const obj = data as Record<string, unknown>;
const result: OAuthTokenResponse = {
access_token: String(obj['access_token']),
token_type:
typeof obj['token_type'] === 'string' ? obj['token_type'] : 'Bearer',
expires_in:
typeof obj['expires_in'] === 'number' ? obj['expires_in'] : undefined,
refresh_token:
typeof obj['refresh_token'] === 'string'
? obj['refresh_token']
: undefined,
scope: typeof obj['scope'] === 'string' ? obj['scope'] : undefined,
};
return result;
}
// JSON parsed but doesn't look like a token response — fall through
} catch {
// Not JSON — fall through to form-urlencoded parsing
}
// Parse form-urlencoded response
const tokenParams = new URLSearchParams(responseText);
const accessToken = tokenParams.get('access_token');
const tokenType = tokenParams.get('token_type') || 'Bearer';
const expiresIn = tokenParams.get('expires_in');
const refreshToken = tokenParams.get('refresh_token');
const scope = tokenParams.get('scope');
if (!accessToken) {
// Check for error in response
const error = tokenParams.get('error');
const errorDescription = tokenParams.get('error_description');
throw new Error(
`${operationName} failed: ${error || defaultErrorCode} - ${errorDescription || responseText}`,
);
}
return {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
refresh_token: refreshToken || undefined,
scope: scope || undefined,
} as OAuthTokenResponse;
}
/**
* Exchange an authorization code for tokens.
*
* @param config OAuth flow configuration
* @param code Authorization code
* @param codeVerifier PKCE code verifier
* @param redirectPort The port to use for the redirect URI
* @param resource Optional resource parameter value (RFC 8707)
* @returns The token response
*/
export async function exchangeCodeForToken(
config: OAuthFlowConfig,
code: string,
codeVerifier: string,
redirectPort: number,
resource?: string,
): Promise<OAuthTokenResponse> {
const redirectUri =
config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: config.clientId,
});
if (config.clientSecret) {
params.append('client_secret', config.clientSecret);
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
if (resource) {
params.append('resource', resource);
}
const response = await fetch(config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
},
body: params.toString(),
});
return parseTokenEndpointResponse(
response,
'Token exchange',
'no_access_token',
);
}
/**
* Refresh an access token using a refresh token.
*
* @param config OAuth configuration subset needed for refresh
* @param refreshToken The refresh token
* @param tokenUrl The token endpoint URL
* @param resource Optional resource parameter value (RFC 8707)
* @returns The new token response
*/
export async function refreshAccessToken(
config: OAuthRefreshConfig,
refreshToken: string,
tokenUrl: string,
resource?: string,
): Promise<OAuthTokenResponse> {
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId,
});
if (config.clientSecret) {
params.append('client_secret', config.clientSecret);
}
if (config.scopes && config.scopes.length > 0) {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
if (resource) {
params.append('resource', resource);
}
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, application/x-www-form-urlencoded',
},
body: params.toString(),
});
return parseTokenEndpointResponse(response, 'Token refresh', 'unknown_error');
}