mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bbc97bbf | |||
| 3bbd1da6a1 | |||
| 6510347d5b | |||
| 4dd940f8ce | |||
| 87f7f0beb7 | |||
| 8e9ce3f4c3 | |||
| 812794a471 | |||
| 3c6539a32c | |||
| e4204d5939 | |||
| baccda969d | |||
| 9e95b8b3c5 | |||
| 182c858e67 | |||
| c0b76af442 | |||
| 4efdbe9089 | |||
| 15f6c8b8da | |||
| ee2e947435 | |||
| 6676546a4b | |||
| d143a83d5b | |||
| e69e23e4a0 | |||
| 81cd2561dc | |||
| b0ceb74462 | |||
| ee5eb70070 | |||
| dde844dbe1 | |||
| 05bc0399f3 | |||
| 3409de774c | |||
| 175ffc452b | |||
| 544df749af | |||
| 56c8d7e985 | |||
| 2ff7738b5d | |||
| 1ad26adb2b | |||
| af5aec69da | |||
| dae67983a8 | |||
| 70856d5a6e | |||
| cec45a1ebc | |||
| 767d80e768 | |||
| 3e5e608a22 | |||
| 0bc2d3ab16 | |||
| 31960c3388 | |||
| 0cc4f09595 | |||
| 70336e73b1 |
@@ -77,6 +77,14 @@ runs:
|
||||
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
|
||||
--output-file final_image_uri.txt
|
||||
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
|
||||
- name: 'verify'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
|
||||
set -e
|
||||
node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
|
||||
/usr/local/share/npm-global/bin/gemini --version >/dev/null
|
||||
'
|
||||
- name: 'publish'
|
||||
shell: 'bash'
|
||||
if: "${{ inputs.dry-run != 'true' }}"
|
||||
|
||||
@@ -22,7 +22,7 @@ get_issue_labels() {
|
||||
# Check cache
|
||||
case "${ISSUE_LABELS_CACHE_FLAT}" in
|
||||
*"|${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
|
||||
echo "${suffix%%|*}"
|
||||
return
|
||||
;;
|
||||
|
||||
@@ -224,8 +224,6 @@ jobs:
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -315,6 +313,7 @@ jobs:
|
||||
needs:
|
||||
- 'e2e_linux'
|
||||
- 'e2e_mac'
|
||||
- 'e2e_windows'
|
||||
- 'evals'
|
||||
- 'merge_queue_skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -323,6 +322,7 @@ jobs:
|
||||
run: |
|
||||
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
|
||||
${{ needs.e2e_mac.result }} != 'success' || \
|
||||
${{ needs.e2e_windows.result }} != 'success' || \
|
||||
${{ needs.evals.result }} != 'success' ]]; then
|
||||
echo "One or more E2E jobs failed."
|
||||
exit 1
|
||||
|
||||
@@ -360,7 +360,6 @@ jobs:
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -458,6 +457,7 @@ jobs:
|
||||
- 'link_checker'
|
||||
- 'test_linux'
|
||||
- 'test_mac'
|
||||
- 'test_windows'
|
||||
- 'codeql'
|
||||
- 'bundle_size'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -468,6 +468,7 @@ jobs:
|
||||
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
|
||||
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
|
||||
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
|
||||
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
|
||||
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
|
||||
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
|
||||
echo "One or more CI jobs failed."
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
model:
|
||||
- 'gemini-3.1-pro-preview-customtools'
|
||||
- 'gemini-3-pro-preview'
|
||||
- 'gemini-3-flash-preview'
|
||||
- 'gemini-2.5-pro'
|
||||
|
||||
@@ -48,6 +48,24 @@ jobs:
|
||||
const repo = context.repo.repo;
|
||||
const MAX_ISSUES_ASSIGNED = 3;
|
||||
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted');
|
||||
|
||||
if (!hasHelpWantedLabel) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: issueNumber,
|
||||
body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for open issues already assigned to the commenter in this repo
|
||||
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
|
||||
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
|
||||
@@ -64,13 +82,6 @@ jobs:
|
||||
return; // exit
|
||||
}
|
||||
|
||||
// Check if the issue is already assigned
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
if (issue.data.assignees.length > 0) {
|
||||
// Comment that it's already assigned
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'PR rate limiter'
|
||||
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
|
||||
jobs:
|
||||
limit:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Limit open pull requests per user'
|
||||
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
|
||||
with:
|
||||
except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
|
||||
comment-limit: 8
|
||||
comment: >
|
||||
You already have 7 pull requests open. Please work on getting
|
||||
existing PRs merged before opening more.
|
||||
close-limit: 8
|
||||
close: true
|
||||
+4
-1
@@ -42,7 +42,10 @@ USER node
|
||||
# install gemini-cli and clean up
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
&& gemini --version > /dev/null \
|
||||
&& npm cache clean --force \
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.30.0-preview.3
|
||||
# Preview release: v0.30.0-preview.5
|
||||
|
||||
Released: February 19, 2026
|
||||
Released: February 24, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -25,6 +25,10 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch
|
||||
version v0.30.0-preview.4 and create version 0.30.0-preview.5 by
|
||||
@gemini-cli-robot in
|
||||
[#20086](https://github.com/google-gemini/gemini-cli/pull/20086)
|
||||
- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
|
||||
version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
@@ -311,4 +315,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5
|
||||
|
||||
+22
-6
@@ -143,13 +143,27 @@ based on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
Plan Mode's default tool restrictions are managed by the [policy engine] and
|
||||
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
|
||||
enforces the read-only state, but you can customize these rules by creating your
|
||||
own policies in your `~/.gemini/policies/` directory (Tier 2).
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
#### Example: Automatically approve read-only MCP tools
|
||||
|
||||
By default, read-only MCP tools require user confirmation in Plan Mode. You can
|
||||
use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
|
||||
your specific environment.
|
||||
|
||||
`~/.gemini/policies/mcp-read-only.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Allow git commands in Plan Mode
|
||||
|
||||
@@ -243,3 +257,5 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
|
||||
[`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
|
||||
|
||||
+11
-8
@@ -29,6 +29,7 @@ they appear in the UI.
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
|
||||
@@ -111,14 +112,15 @@ they appear in the UI.
|
||||
|
||||
### Security
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
| Enable Context-Aware Security | `security.enableConseca` | Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions. | `false` |
|
||||
|
||||
### Advanced
|
||||
|
||||
@@ -135,6 +137,7 @@ they appear in the UI.
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -80,6 +80,122 @@ Gemini CLI comes with the following built-in subagents:
|
||||
invoked by the user.
|
||||
- **Configuration:** Enabled by default. No specific configuration options.
|
||||
|
||||
### Browser Agent (experimental)
|
||||
|
||||
- **Name:** `browser_agent`
|
||||
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
|
||||
clicking buttons, and extracting information from web pages — using the
|
||||
accessibility tree.
|
||||
- **When to use:** "Go to example.com and fill out the contact form," "Extract
|
||||
the pricing table from this page," "Click the login button and enter my
|
||||
credentials."
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
The browser agent requires:
|
||||
|
||||
- **Chrome** version 144 or later (any recent stable release will work).
|
||||
- **Node.js** with `npx` available (used to launch the
|
||||
[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
|
||||
server).
|
||||
|
||||
#### Enabling the browser agent
|
||||
|
||||
The browser agent is disabled by default. Enable it in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Session modes
|
||||
|
||||
The `sessionMode` setting controls how Chrome is launched and managed. Set it
|
||||
under `agents.browser`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"sessionMode": "persistent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The available modes are:
|
||||
|
||||
| Mode | Description |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
|
||||
| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
|
||||
| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
|
||||
|
||||
#### Configuration reference
|
||||
|
||||
All browser-specific settings go under `agents.browser` in your `settings.json`.
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
|
||||
| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
|
||||
| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
|
||||
| `profilePath` | `string` | — | Custom path to a browser profile directory. |
|
||||
| `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
|
||||
|
||||
#### Security
|
||||
|
||||
The browser agent enforces the following security restrictions:
|
||||
|
||||
- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
|
||||
`chrome://extensions`, and `chrome://settings/passwords` are always blocked.
|
||||
- **Sensitive action confirmation:** Actions like form filling, file uploads,
|
||||
and form submissions require user confirmation through the standard policy
|
||||
engine.
|
||||
|
||||
#### Visual agent
|
||||
|
||||
By default, the browser agent interacts with pages through the accessibility
|
||||
tree using element `uid` values. For tasks that require visual identification
|
||||
(for example, "click the yellow button" or "find the red error message"), you
|
||||
can enable the visual agent by setting a `visualModel`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"visualModel": "gemini-2.5-computer-use-preview-10-2025"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the agent gains access to the `analyze_screenshot` tool, which
|
||||
captures a screenshot and sends it to the vision model for analysis. The model
|
||||
returns coordinates and element descriptions that the browser agent uses with
|
||||
the `click_at` tool for precise, coordinate-based interactions.
|
||||
|
||||
> **Note:** The visual agent requires API key or Vertex AI authentication. It is
|
||||
> not available when using Google Login.
|
||||
|
||||
## Creating custom subagents
|
||||
|
||||
You can create your own subagents to automate specific workflows or enforce
|
||||
|
||||
@@ -116,7 +116,9 @@ The manifest file defines the extension's behavior and configuration.
|
||||
"description": "My awesome extension",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}/my-server.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
@@ -124,19 +126,41 @@ The manifest file defines the extension's behavior and configuration.
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
|
||||
and dashes. This name must match the extension's directory name.
|
||||
- `version`: The current version of the extension.
|
||||
- `description`: A short summary shown in the extension gallery.
|
||||
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
|
||||
servers. Extension servers follow the same format as standard
|
||||
[CLI configuration](../reference/configuration.md).
|
||||
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
|
||||
also be an array of strings to load multiple context files.
|
||||
- `excludeTools`: An array of tools to block from the model. You can restrict
|
||||
specific arguments, such as `run_shell_command(rm -rf)`.
|
||||
- `themes`: An optional list of themes provided by the extension. See
|
||||
[Themes](../cli/themes.md) for more information.
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `description`: A short description of the extension. This will be displayed on
|
||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers defined in a
|
||||
[`settings.json` file](../reference/configuration.md). If both an extension
|
||||
and a `settings.json` file define an MCP server with the same name, the server
|
||||
defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- For portability, you should use `${extensionPath}` to refer to files within
|
||||
your extension directory.
|
||||
- Separate your executable and its arguments using `command` and `args`
|
||||
instead of putting them both in `command`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
precedence.
|
||||
|
||||
### Extension settings
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@ and parameter rewriting.
|
||||
- `tool_name`: (`string`) The name of the tool being called.
|
||||
- `tool_input`: (`object`) The raw arguments generated by the model.
|
||||
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
|
||||
- `original_request_name`: (`string`) The original name of the tool being
|
||||
called, if this is a tail tool call.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
|
||||
executing.
|
||||
@@ -120,12 +122,18 @@ hiding sensitive output from the agent.
|
||||
- `tool_response`: (`object`) The result containing `llmContent`,
|
||||
`returnDisplay`, and optional `error`.
|
||||
- `mcp_context`: (`object`)
|
||||
- `original_request_name`: (`string`) The original name of the tool being
|
||||
called, if this is a tail tool call.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
|
||||
- `reason`: Required if denied. This text **replaces** the tool result sent
|
||||
back to the model.
|
||||
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||
tool result for the agent.
|
||||
- `hookSpecificOutput.tailToolCallRequest`: (`{ name: string, args: object }`)
|
||||
A request to execute another tool immediately after this one. The result of
|
||||
this "tail call" will replace the original tool's response. Ideal for
|
||||
programmatic tool routing.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
|
||||
replacement content sent to the agent. **The turn continues.**
|
||||
|
||||
@@ -170,6 +170,20 @@ messages and how to resolve them.
|
||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||
continues, open a new terminal window or restart your IDE.
|
||||
|
||||
### Manual PID override
|
||||
|
||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||
standalone terminal and want to manually associate it with a specific IDE
|
||||
instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
|
||||
process ID (PID) of your IDE.
|
||||
|
||||
```bash
|
||||
export GEMINI_CLI_IDE_PID=12345
|
||||
```
|
||||
|
||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||
to connect using the provided PID.
|
||||
|
||||
### Configuration errors
|
||||
|
||||
- **Message:**
|
||||
|
||||
@@ -142,6 +142,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
request" errors.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.maxAttempts`** (number):
|
||||
- **Description:** Maximum number of attempts for requests to the main chat
|
||||
model. Cannot exceed 10.
|
||||
- **Default:** `10`
|
||||
|
||||
- **`general.debugKeystrokeLogging`** (boolean):
|
||||
- **Description:** Enable debug logging of keystrokes to the console.
|
||||
- **Default:** `false`
|
||||
@@ -641,6 +646,27 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `{}`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.sessionMode`** (enum):
|
||||
- **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
|
||||
- **Default:** `"persistent"`
|
||||
- **Values:** `"persistent"`, `"isolated"`, `"existing"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.headless`** (boolean):
|
||||
- **Description:** Run browser in headless mode.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.profilePath`** (string):
|
||||
- **Description:** Path to browser profile directory for session persistence.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.visualModel`** (string):
|
||||
- **Description:** Model override for the visual agent.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `context`
|
||||
|
||||
- **`context.fileName`** (string | string[]):
|
||||
@@ -868,6 +894,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.enableConseca`** (boolean):
|
||||
- **Description:** Enable the context-aware security checker. This feature
|
||||
uses an LLM to dynamically generate and enforce security policies for tool
|
||||
use based on your prompt, providing an additional layer of protection
|
||||
against unintended actions.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `advanced`
|
||||
|
||||
- **`advanced.autoConfigureMemory`** (boolean):
|
||||
@@ -969,6 +1003,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
during tool execution.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.directWebFetch`** (boolean):
|
||||
- **Description:** Enable web fetch behavior that bypasses LLM summarization.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.enabled`** (boolean):
|
||||
@@ -1256,6 +1295,11 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Specifies the default Gemini model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
|
||||
- **`GEMINI_CLI_IDE_PID`**:
|
||||
- Manually specifies the PID of the IDE process to use for integration. This
|
||||
is useful when running Gemini CLI in a standalone terminal while still
|
||||
wanting to associate it with a specific IDE instance.
|
||||
- Overrides the automatic IDE detection logic.
|
||||
- **`GEMINI_CLI_HOME`**:
|
||||
- Specifies the root directory for Gemini CLI's user-level configuration and
|
||||
storage.
|
||||
|
||||
@@ -205,6 +205,10 @@ toolName = "run_shell_command"
|
||||
# to form a composite name like "mcpName__toolName".
|
||||
mcpName = "my-custom-server"
|
||||
|
||||
# (Optional) Metadata hints provided by the tool. A rule matches if all
|
||||
# key-value pairs provided here are present in the tool's annotations.
|
||||
toolAnnotations = { readOnlyHint = true }
|
||||
|
||||
# (Optional) A regex to match against the tool's arguments.
|
||||
argsPattern = '"command":"(git|npm)'
|
||||
|
||||
|
||||
@@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
## 6. `replace` (Edit)
|
||||
|
||||
`replace` replaces text within a file. By default, replaces a single occurrence,
|
||||
but can replace multiple occurrences when `expected_replacements` is specified.
|
||||
This tool is designed for precise, targeted changes and requires significant
|
||||
context around the `old_string` to ensure it modifies the correct location.
|
||||
`replace` replaces text within a file. By default, the tool expects to find and
|
||||
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
|
||||
occurrences of the exact same string, set `allow_multiple` to `true`. This tool
|
||||
is designed for precise, targeted changes and requires significant context
|
||||
around the `old_string` to ensure it modifies the correct location.
|
||||
|
||||
- **Tool name:** `replace`
|
||||
- **Arguments:**
|
||||
@@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location.
|
||||
- `instruction` (string, required): Semantic description of the change.
|
||||
- `old_string` (string, required): Exact literal text to find.
|
||||
- `new_string` (string, required): Exact literal text to replace with.
|
||||
- `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
|
||||
If `false` (default), only succeeds if exactly one occurrence is found.
|
||||
- **Confirmation:** Requires manual user approval.
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -52,6 +52,9 @@ These tools help the model manage its plan and interact with you.
|
||||
complex plans.
|
||||
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
|
||||
procedural expertise when needed.
|
||||
- **[Browser agent](../core/subagents.md#browser-agent-experimental)
|
||||
(`browser_agent`):** Automates web browser tasks through the accessibility
|
||||
tree.
|
||||
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
|
||||
documentation to help answer your questions.
|
||||
|
||||
|
||||
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
|
||||
- **`args`** (string[]): Command-line arguments for Stdio transport
|
||||
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
|
||||
- **`env`** (object): Environment variables for the server process. Values can
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
|
||||
platforms), or `%VAR_NAME%` (Windows only).
|
||||
- **`cwd`** (string): Working directory for Stdio transport
|
||||
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
|
||||
10 minutes)
|
||||
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
|
||||
Service Account to impersonate. Used with
|
||||
`authProviderType: 'service_account_impersonation'`.
|
||||
|
||||
### Environment variable expansion
|
||||
|
||||
Gemini CLI automatically expands environment variables in the `env` block of
|
||||
your MCP server configuration. This allows you to securely reference variables
|
||||
defined in your shell or environment without hardcoding sensitive information
|
||||
directly in your `settings.json` file.
|
||||
|
||||
The expansion utility supports:
|
||||
|
||||
- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
|
||||
all platforms)
|
||||
- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
|
||||
|
||||
If a variable is not defined in the current environment, it resolves to an empty
|
||||
string.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
"env": {
|
||||
"API_KEY": "$MY_EXTERNAL_TOKEN",
|
||||
"LOG_LEVEL": "$LOG_LEVEL",
|
||||
"TEMP_DIR": "%TEMP%"
|
||||
}
|
||||
```
|
||||
|
||||
### Security and environment sanitization
|
||||
|
||||
To protect your credentials, Gemini CLI performs environment sanitization when
|
||||
spawning MCP server processes.
|
||||
|
||||
#### Automatic redaction
|
||||
|
||||
By default, the CLI redacts sensitive environment variables from the base
|
||||
environment (inherited from the host process) to prevent unintended exposure to
|
||||
third-party MCP servers. This includes:
|
||||
|
||||
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
|
||||
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
|
||||
`*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
|
||||
- Certificates and private key patterns.
|
||||
|
||||
#### Explicit overrides
|
||||
|
||||
If an environment variable must be passed to an MCP server, you must explicitly
|
||||
state it in the `env` property of the server configuration in `settings.json`.
|
||||
Explicitly defined variables (including those from extensions) are trusted and
|
||||
are **not** subjected to the automatic redaction process.
|
||||
|
||||
This follows the security principle that if a variable is explicitly configured
|
||||
by the user for a specific server, it constitutes informed consent to share that
|
||||
specific data with that server.
|
||||
|
||||
> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
|
||||
> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
|
||||
> securely pull the value from your host environment at runtime.
|
||||
|
||||
### OAuth support for remote MCP servers
|
||||
|
||||
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
|
||||
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
containing API keys or tokens. See
|
||||
[Security and environment sanitization](#security-and-environment-sanitization)
|
||||
for details on how Gemini CLI protects your credentials.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
|
||||
+13
-12
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
|
||||
).toBe(true);
|
||||
|
||||
let totalLinesRead = 0;
|
||||
const readRanges: { offset: number; limit: number }[] = [];
|
||||
const readRanges: { start_line: number; end_line: number }[] = [];
|
||||
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent read the entire file (missing limit) instead of using ranged read',
|
||||
args.end_line,
|
||||
'Agent read the entire file (missing end_line) instead of using ranged read',
|
||||
).toBeDefined();
|
||||
|
||||
const limit = args.limit;
|
||||
const offset = args.offset ?? 0;
|
||||
totalLinesRead += limit;
|
||||
readRanges.push({ offset, limit });
|
||||
const end_line = args.end_line;
|
||||
const start_line = args.start_line ?? 1;
|
||||
const linesRead = end_line - start_line + 1;
|
||||
totalLinesRead += linesRead;
|
||||
readRanges.push({ start_line, end_line });
|
||||
|
||||
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
|
||||
expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
|
||||
1001,
|
||||
);
|
||||
}
|
||||
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
|
||||
const errorLines = [500, 510, 520];
|
||||
for (const line of errorLines) {
|
||||
const covered = readRanges.some(
|
||||
(range) => line >= range.offset && line < range.offset + range.limit,
|
||||
(range) => line >= range.start_line && line <= range.end_line,
|
||||
);
|
||||
expect(covered, `Agent should have read around line ${line}`).toBe(
|
||||
true,
|
||||
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent should have used ranged read (limit) to save tokens',
|
||||
args.end_line,
|
||||
'Agent should have used ranged read (end_line) to save tokens',
|
||||
).toBeDefined();
|
||||
}
|
||||
},
|
||||
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
|
||||
// and just read the whole file to be efficient with tool calls.
|
||||
const readEntireFile = targetFileReads.some((call) => {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
return args.limit === undefined;
|
||||
return args.end_line === undefined;
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
|
||||
const args = getParams(call);
|
||||
return (
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
(args.limit === undefined || args.limit === null)
|
||||
(args.end_line === undefined || args.end_line === null)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
|
||||
if (
|
||||
call.toolRequest.name === 'read_file' &&
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
args.limit !== undefined
|
||||
args.end_line !== undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('interactive_commands', () => {
|
||||
const scaffoldCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
/npm (init|create)|npx create-|yarn create|pnpm create/.test(
|
||||
/npm (init|create)|npx (.*)?create-|yarn create|pnpm create/.test(
|
||||
l.toolRequest.args,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -286,6 +286,113 @@ describe('Hooks System Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Hooks - Tail Tool Calls', () => {
|
||||
it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
|
||||
// Create a script that acts as the hook.
|
||||
// It will trigger on "read_file" and issue a tail call to "write_file".
|
||||
rig.setup('should execute a tail tool call from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.tail-tool-call.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const hookOutput = {
|
||||
decision: 'allow',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'AfterTool',
|
||||
tailToolCallRequest: {
|
||||
name: 'write_file',
|
||||
args: {
|
||||
file_path: 'tail-called-file.txt',
|
||||
content: 'Content from tail call',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)})); process.exit(0);`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
rig.setup('should execute a tail tool call from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.tail-tool-call.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'read_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a test file to trigger the read_file tool
|
||||
rig.createFile('original.txt', 'Original content');
|
||||
|
||||
const cliOutput = await rig.run({
|
||||
args: 'Read original.txt', // Fake responses should trigger read_file on this
|
||||
});
|
||||
|
||||
// 1. Verify that write_file was called (as a tail call replacing read_file)
|
||||
// Since read_file was replaced before finalizing, it will not appear in the tool logs.
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// Ensure hook logs are flushed and the final LLM response is received.
|
||||
// The mock LLM is configured to respond with "Tail call completed successfully."
|
||||
expect(cliOutput).toContain('Tail call completed successfully.');
|
||||
|
||||
// Ensure telemetry is written to disk
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
// Read hook logs to debug
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const relevantHookLog = hookLogs.find(
|
||||
(l) => l.hookCall.hook_event_name === 'AfterTool',
|
||||
);
|
||||
|
||||
expect(relevantHookLog).toBeDefined();
|
||||
|
||||
// 2. Verify write_file was executed.
|
||||
// In non-interactive mode, the CLI deduplicates tool execution logs by callId.
|
||||
// Since a tail call reuses the original callId, "Tool: write_file" is not printed.
|
||||
// Instead, we verify the side-effect (file creation) and the telemetry log.
|
||||
|
||||
// 3. Verify the tail-called tool actually wrote the file
|
||||
const modifiedContent = rig.readFile('tail-called-file.txt');
|
||||
expect(modifiedContent).toBe('Content from tail call');
|
||||
|
||||
// 4. Verify telemetry for the final tool call.
|
||||
// The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
|
||||
expect(
|
||||
successfulTools.some((t) => t.toolRequest.name === 'write_file'),
|
||||
).toBeTruthy();
|
||||
// The original request name should be preserved in the log payload if possible,
|
||||
// but the executed tool name is 'write_file'.
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeModel Hooks - LLM Request Modification', () => {
|
||||
it('should modify LLM requests with BeforeModel hooks', async () => {
|
||||
// Create a hook script that replaces the LLM request with a modified version
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, InteractiveRun } from './test-helper.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
const extension = `{
|
||||
"name": "test-symlink-extension",
|
||||
"version": "0.0.1"
|
||||
}`;
|
||||
|
||||
const otherExtension = `{
|
||||
"name": "malicious-extension",
|
||||
"version": "6.6.6"
|
||||
}`;
|
||||
|
||||
describe('extension symlink install spoofing protection', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
|
||||
// Enable folder trust for this test
|
||||
rig.setup('symlink spoofing test', {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const realExtPath = join(rig.testDir!, 'real-extension');
|
||||
mkdirSync(realExtPath);
|
||||
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
|
||||
|
||||
const maliciousExtPath = join(
|
||||
os.tmpdir(),
|
||||
`malicious-extension-${Date.now()}`,
|
||||
);
|
||||
mkdirSync(maliciousExtPath);
|
||||
writeFileSync(
|
||||
join(maliciousExtPath, 'gemini-extension.json'),
|
||||
otherExtension,
|
||||
);
|
||||
|
||||
const symlinkPath = join(rig.testDir!, 'symlink-extension');
|
||||
symlinkSync(realExtPath, symlinkPath);
|
||||
|
||||
// Function to run a command with a PTY to avoid headless mode
|
||||
const runPty = (args: string[]) => {
|
||||
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 80,
|
||||
cwd: rig.testDir!,
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
GEMINI_PTY_INFO: 'node-pty',
|
||||
},
|
||||
});
|
||||
return new InteractiveRun(ptyProcess);
|
||||
};
|
||||
|
||||
// 1. Install via symlink, trust it
|
||||
const run1 = runPty(['extensions', 'install', symlinkPath]);
|
||||
await run1.expectText('Do you want to trust this folder', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('trust this workspace', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('Do you want to continue', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('installed successfully', 30000);
|
||||
await run1.kill();
|
||||
|
||||
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
|
||||
const trustedFoldersPath = join(
|
||||
rig.homeDir!,
|
||||
GEMINI_DIR,
|
||||
'trustedFolders.json',
|
||||
);
|
||||
// Wait for file to be written
|
||||
let attempts = 0;
|
||||
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const trustedFolders = JSON.parse(
|
||||
readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
const trustedPaths = Object.keys(trustedFolders);
|
||||
const canonicalRealExtPath = fs.realpathSync(realExtPath);
|
||||
|
||||
expect(trustedPaths).toContain(canonicalRealExtPath);
|
||||
expect(trustedPaths).not.toContain(symlinkPath);
|
||||
|
||||
// 3. Swap the symlink to point to the malicious extension
|
||||
unlinkSync(symlinkPath);
|
||||
symlinkSync(maliciousExtPath, symlinkPath);
|
||||
|
||||
// 4. Try to install again via the same symlink path.
|
||||
// It should NOT be trusted because the real path changed.
|
||||
const run2 = runPty(['extensions', 'install', symlinkPath]);
|
||||
await run2.expectText('Do you want to trust this folder', 30000);
|
||||
await run2.type('n\r');
|
||||
await run2.expectText('Installation aborted', 30000);
|
||||
await run2.kill();
|
||||
}, 60000);
|
||||
});
|
||||
Generated
+422
-671
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -70,7 +70,8 @@
|
||||
"wrap-ansi": "7.0.0"
|
||||
},
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "^1.0.0"
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -97,6 +98,7 @@
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"depcheck": "^1.4.7",
|
||||
"domexception": "^4.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-wasm": "^1.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
@@ -130,6 +132,7 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"punycode": "^2.3.1",
|
||||
"simple-git": "^3.28.0"
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
CoderAgentEvent,
|
||||
getPersistedState,
|
||||
setPersistedState,
|
||||
getContextIdFromMetadata,
|
||||
getAgentSettingsFromMetadata,
|
||||
} from '../types.js';
|
||||
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
const config = await this.getConfig(agentSettings, sdkTask.id);
|
||||
const contextId: string =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(metadata['_contextId'] as string) || sdkTask.contextId;
|
||||
getContextIdFromMetadata(metadata) || sdkTask.contextId;
|
||||
const runtimeTask = await Task.create(
|
||||
sdkTask.id,
|
||||
contextId,
|
||||
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettingsInput?: AgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = agentSettingsInput || ({} as AgentSettings);
|
||||
const agentSettings: AgentSettings = agentSettingsInput || {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent,
|
||||
workspacePath: process.cwd(),
|
||||
};
|
||||
const config = await this.getConfig(agentSettings, taskId);
|
||||
const runtimeTask = await Task.create(
|
||||
taskId,
|
||||
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const contextId: string =
|
||||
userMessage.contextId ||
|
||||
sdkTask?.contextId ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(sdkTask?.metadata?.['_contextId'] as string) ||
|
||||
getContextIdFromMetadata(sdkTask?.metadata) ||
|
||||
uuidv4();
|
||||
|
||||
logger.info(
|
||||
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
}
|
||||
} else {
|
||||
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = userMessage.metadata?.[
|
||||
'coderAgent'
|
||||
] as AgentSettings;
|
||||
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
|
||||
try {
|
||||
wrapper = await this.createTask(
|
||||
taskId,
|
||||
|
||||
@@ -513,7 +513,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
@@ -533,7 +536,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
|
||||
@@ -59,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||
|
||||
type UnionKeys<T> = T extends T ? keyof T : never;
|
||||
|
||||
type ConfirmationType = ToolCallConfirmationDetails['type'];
|
||||
|
||||
const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
|
||||
'edit',
|
||||
'exec',
|
||||
'mcp',
|
||||
'info',
|
||||
'ask_user',
|
||||
'exit_plan_mode',
|
||||
] as const;
|
||||
|
||||
function isToolCallConfirmationDetails(
|
||||
value: unknown,
|
||||
): value is ToolCallConfirmationDetails {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('onConfirm' in value) ||
|
||||
typeof value.onConfirm !== 'function' ||
|
||||
!('type' in value) ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
|
||||
}
|
||||
|
||||
export class Task {
|
||||
id: string;
|
||||
contextId: string;
|
||||
@@ -376,11 +403,10 @@ export class Task {
|
||||
}
|
||||
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
this.pendingToolConfirmationDetails.set(
|
||||
tc.request.callId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
tc.confirmationDetails as ToolCallConfirmationDetails,
|
||||
);
|
||||
const details = tc.confirmationDetails;
|
||||
if (isToolCallConfirmationDetails(details)) {
|
||||
this.pendingToolConfirmationDetails.set(tc.request.callId, details);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send an update if the status has actually changed.
|
||||
@@ -412,11 +438,12 @@ export class Task {
|
||||
);
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
|
||||
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
this.pendingToolConfirmationDetails.delete(tc.request.callId);
|
||||
const details = tc.confirmationDetails;
|
||||
if (isToolCallConfirmationDetails(details)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
this.pendingToolConfirmationDetails.delete(tc.request.callId);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -466,15 +493,13 @@ export class Task {
|
||||
T extends ToolCall | AnyDeclarativeTool,
|
||||
K extends UnionKeys<T>,
|
||||
>(from: T, ...fields: K[]): Partial<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const ret = {} as Pick<T, K>;
|
||||
const ret: Partial<T> = {};
|
||||
for (const field of fields) {
|
||||
if (field in from) {
|
||||
if (field in from && from[field] !== undefined) {
|
||||
ret[field] = from[field];
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return ret as Partial<T>;
|
||||
return ret;
|
||||
}
|
||||
|
||||
private toolStatusMessage(
|
||||
@@ -485,8 +510,11 @@ export class Task {
|
||||
const messageParts: Part[] = [];
|
||||
|
||||
// Create a serializable version of the ToolCall (pick necessary
|
||||
// properties/avoid methods causing circular reference errors)
|
||||
const serializableToolCall: Partial<ToolCall> = this._pickFields(
|
||||
// properties/avoid methods causing circular reference errors).
|
||||
// Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
|
||||
const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
|
||||
tool?: Partial<AnyDeclarativeTool>;
|
||||
} = this._pickFields(
|
||||
tc,
|
||||
'request',
|
||||
'status',
|
||||
@@ -496,8 +524,7 @@ export class Task {
|
||||
);
|
||||
|
||||
if (tc.tool) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
serializableToolCall.tool = this._pickFields(
|
||||
const toolFields = this._pickFields(
|
||||
tc.tool,
|
||||
'name',
|
||||
'displayName',
|
||||
@@ -507,7 +534,8 @@ export class Task {
|
||||
'canUpdateOutput',
|
||||
'schema',
|
||||
'parameterSchema',
|
||||
) as AnyDeclarativeTool;
|
||||
);
|
||||
serializableToolCall.tool = toolFields;
|
||||
}
|
||||
|
||||
messageParts.push({
|
||||
@@ -530,8 +558,15 @@ export class Task {
|
||||
old_string: string,
|
||||
new_string: string,
|
||||
): Promise<string> {
|
||||
// Validate path to prevent path traversal vulnerabilities
|
||||
const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
|
||||
const pathError = this.config.validatePathAccess(resolvedPath, 'read');
|
||||
if (pathError) {
|
||||
throw new Error(`Path validation failed: ${pathError}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(file_path, 'utf8');
|
||||
const currentContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
return this._applyReplacement(
|
||||
currentContent,
|
||||
old_string,
|
||||
@@ -625,15 +660,32 @@ export class Task {
|
||||
request.args['old_string'] &&
|
||||
request.args['new_string']
|
||||
) {
|
||||
const newContent = await this.getProposedContent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['file_path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['old_string'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['new_string'] as string,
|
||||
);
|
||||
return { ...request, args: { ...request.args, newContent } };
|
||||
const filePath = request.args['file_path'];
|
||||
const oldString = request.args['old_string'];
|
||||
const newString = request.args['new_string'];
|
||||
if (
|
||||
typeof filePath === 'string' &&
|
||||
typeof oldString === 'string' &&
|
||||
typeof newString === 'string'
|
||||
) {
|
||||
// Resolve and validate path to prevent path traversal (user-controlled file_path).
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
filePath,
|
||||
);
|
||||
const pathError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
);
|
||||
if (!pathError) {
|
||||
const newContent = await this.getProposedContent(
|
||||
resolvedPath,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
return { ...request, args: { ...request.args, newContent } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}),
|
||||
@@ -725,10 +777,17 @@ export class Task {
|
||||
break;
|
||||
case GeminiEventType.Error:
|
||||
default: {
|
||||
// Block scope for lexical declaration
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
|
||||
const errorMessage = errorEvent.value?.error
|
||||
// Use type guard instead of unsafe type assertion
|
||||
let errorEvent: ServerGeminiErrorEvent | undefined;
|
||||
if (
|
||||
event.type === GeminiEventType.Error &&
|
||||
event.value &&
|
||||
typeof event.value === 'object' &&
|
||||
'error' in event.value
|
||||
) {
|
||||
errorEvent = event;
|
||||
}
|
||||
const errorMessage = errorEvent?.value?.error
|
||||
? getErrorMessage(errorEvent.value.error)
|
||||
: 'Unknown error from LLM stream';
|
||||
logger.error(
|
||||
@@ -737,7 +796,7 @@ export class Task {
|
||||
);
|
||||
|
||||
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
|
||||
if (errorEvent.value?.error) {
|
||||
if (errorEvent?.value?.error) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value.error);
|
||||
}
|
||||
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
|
||||
@@ -814,12 +873,11 @@ export class Task {
|
||||
|
||||
// If `edit` tool call, pass updated payload if presesent
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
const payload = part.data['newContent']
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
newContent: part.data['newContent'] as string,
|
||||
} as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
const newContent = part.data['newContent'];
|
||||
const payload =
|
||||
typeof newContent === 'string'
|
||||
? ({ newContent } as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
this.skipFinalTrueAfterInlineEdit = !!payload;
|
||||
try {
|
||||
await confirmationDetails.onConfirm(confirmationOutcome, payload);
|
||||
|
||||
@@ -267,4 +267,47 @@ describe('loadConfig', () => {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool configuration', () => {
|
||||
it('should pass V1 allowedTools to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['shell', 'edit'],
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'edit'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass V2 tools.allowed to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['shell', 'fetch'],
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'fetch'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-tool'],
|
||||
tools: {
|
||||
allowed: ['v2-tool'],
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['v1-tool'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,8 +68,9 @@ export async function loadConfig(
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
|
||||
coreTools: settings.coreTools || undefined,
|
||||
excludeTools: settings.excludeTools || undefined,
|
||||
coreTools: settings.coreTools || settings.tools?.core || undefined,
|
||||
excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
|
||||
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
|
||||
showMemoryUsage: settings.showMemoryUsage || false,
|
||||
approvalMode:
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface Settings {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
coreTools?: string[];
|
||||
excludeTools?: string[];
|
||||
allowedTools?: string[];
|
||||
tools?: {
|
||||
allowed?: string[];
|
||||
exclude?: string[];
|
||||
core?: string[];
|
||||
};
|
||||
telemetry?: TelemetrySettings;
|
||||
showMemoryUsage?: boolean;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
|
||||
|
||||
export const METADATA_KEY = '__persistedState';
|
||||
|
||||
function isAgentSettings(value: unknown): value is AgentSettings {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'kind' in value &&
|
||||
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
|
||||
'workspacePath' in value &&
|
||||
typeof value.workspacePath === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedStateMetadata(
|
||||
value: unknown,
|
||||
): value is PersistedStateMetadata {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'_agentSettings' in value &&
|
||||
'_taskState' in value &&
|
||||
isAgentSettings(value._agentSettings)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPersistedState(
|
||||
metadata: PersistedTaskMetadata,
|
||||
): PersistedStateMetadata | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
|
||||
const state = metadata?.[METADATA_KEY];
|
||||
if (isPersistedStateMetadata(state)) {
|
||||
return state;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getContextIdFromMetadata(
|
||||
metadata: PersistedTaskMetadata | undefined,
|
||||
): string | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const contextId = metadata['_contextId'];
|
||||
return typeof contextId === 'string' ? contextId : undefined;
|
||||
}
|
||||
|
||||
export function getAgentSettingsFromMetadata(
|
||||
metadata: PersistedTaskMetadata | undefined,
|
||||
): AgentSettings | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const coderAgent = metadata['coderAgent'];
|
||||
if (isAgentSettings(coderAgent)) {
|
||||
return coderAgent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function setPersistedState(
|
||||
|
||||
@@ -71,6 +71,7 @@ export function createMockConfig(
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
} from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as core from '@google/gemini-cli-core';
|
||||
import type { inferInstallMetadata } from '../../config/extension-manager.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import type {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
promptForConsentNonInteractive,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import type {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import type * as fs from 'node:fs/promises';
|
||||
import type { Stats } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const mockInstallOrUpdateExtension: Mock<
|
||||
typeof ExtensionManager.prototype.installOrUpdateExtension
|
||||
@@ -31,28 +37,54 @@ const mockInstallOrUpdateExtension: Mock<
|
||||
const mockRequestConsentNonInteractive: Mock<
|
||||
typeof requestConsentNonInteractive
|
||||
> = vi.hoisted(() => vi.fn());
|
||||
const mockPromptForConsentNonInteractive: Mock<
|
||||
typeof promptForConsentNonInteractive
|
||||
> = vi.hoisted(() => vi.fn());
|
||||
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
||||
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
||||
() => vi.fn(),
|
||||
);
|
||||
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
|
||||
vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
|
||||
INSTALL_WARNING_MESSAGE: 'warning',
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: mockIsWorkspaceTrusted,
|
||||
loadTrustedFolders: mockLoadTrustedFolders,
|
||||
TrustLevel: {
|
||||
TRUST_FOLDER: 'TRUST_FOLDER',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||
loadExtensions: vi.fn(),
|
||||
})),
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
FolderTrustDiscoveryService: {
|
||||
discover: mockDiscover,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import('../../config/extension-manager.js')
|
||||
>()),
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
@@ -83,12 +115,31 @@ describe('handleInstall', () => {
|
||||
let processSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
debugLogSpy = vi.spyOn(debugLogger, 'log');
|
||||
debugErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
|
||||
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'installOrUpdateExtension',
|
||||
).mockImplementation(mockInstallOrUpdateExtension);
|
||||
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
securityWarnings: [],
|
||||
discoveryErrors: [],
|
||||
});
|
||||
|
||||
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
@@ -114,12 +165,29 @@ describe('handleInstall', () => {
|
||||
mockStat.mockClear();
|
||||
mockInferInstallMetadata.mockClear();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockExtension(
|
||||
overrides: Partial<core.GeminiCLIExtension> = {},
|
||||
): core.GeminiCLIExtension {
|
||||
return {
|
||||
name: 'mock-extension',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/mock/path',
|
||||
contextFiles: [],
|
||||
id: 'mock-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('should install an extension from a http source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'http-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'http-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
@@ -131,9 +199,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a https source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'https-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'https-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://google.com',
|
||||
@@ -145,9 +215,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a git source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'git-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'git-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'git@some-url',
|
||||
@@ -171,9 +243,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a sso source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'sso-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'sso-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'sso://google.com',
|
||||
@@ -185,12 +259,14 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'local-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
await handleInstall({
|
||||
source: '/some/path',
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
@@ -208,4 +284,144 @@ describe('handleInstall', () => {
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should proceed if local path is already trusted', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and proceed if user accepts trust', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
const mockSetValue = vi.fn();
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: mockSetValue,
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'untrusted', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join('untrusted', 'path')),
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and abort if user denies trust', async () => {
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(false);
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'evil', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installation aborted: Folder'),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should include discovery results in trust prompt', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: ['custom-cmd'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: ['cool-skill'],
|
||||
settings: [],
|
||||
securityWarnings: ['Security risk!'],
|
||||
discoveryErrors: ['Read error'],
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: vi.fn(),
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: '/untrusted/path',
|
||||
});
|
||||
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('This folder contains:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('custom-cmd'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cool-skill'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security Warnings:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security risk!'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Discovery Errors:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Read error'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Implementation completed.
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import {
|
||||
@@ -16,6 +22,11 @@ import {
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
TrustLevel,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
@@ -36,6 +47,95 @@ export async function handleInstall(args: InstallArgs) {
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
});
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||
const resolvedPath = getRealPath(source);
|
||||
installMetadata.source = resolvedPath;
|
||||
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
|
||||
if (trustResult.isTrusted !== true) {
|
||||
const discoveryResults =
|
||||
await FolderTrustDiscoveryService.discover(resolvedPath);
|
||||
|
||||
const hasDiscovery =
|
||||
discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0;
|
||||
|
||||
const promptLines = [
|
||||
'',
|
||||
chalk.bold('Do you trust the files in this folder?'),
|
||||
'',
|
||||
`The extension source at "${resolvedPath}" is not trusted.`,
|
||||
'',
|
||||
'Trusting a folder allows Gemini CLI to load its local configurations,',
|
||||
'including custom commands, hooks, MCP servers, agent skills, and',
|
||||
'settings. These configurations could execute code on your behalf or',
|
||||
'change the behavior of the CLI.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (discoveryResults.discoveryErrors.length > 0) {
|
||||
promptLines.push(chalk.red('❌ Discovery Errors:'));
|
||||
for (const error of discoveryResults.discoveryErrors) {
|
||||
promptLines.push(chalk.red(` • ${error}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (discoveryResults.securityWarnings.length > 0) {
|
||||
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
|
||||
for (const warning of discoveryResults.securityWarnings) {
|
||||
promptLines.push(chalk.yellow(` • ${warning}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (hasDiscovery) {
|
||||
promptLines.push(chalk.bold('This folder contains:'));
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults.commands },
|
||||
{ label: 'MCP Servers', items: discoveryResults.mcps },
|
||||
{ label: 'Hooks', items: discoveryResults.hooks },
|
||||
{ label: 'Skills', items: discoveryResults.skills },
|
||||
{ label: 'Setting overrides', items: discoveryResults.settings },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
for (const group of groups) {
|
||||
promptLines.push(
|
||||
` • ${chalk.bold(group.label)} (${group.items.length}):`,
|
||||
);
|
||||
for (const item of group.items) {
|
||||
promptLines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
promptLines.push(
|
||||
chalk.yellow(
|
||||
'Do you want to trust this folder and continue with the installation? [y/N]: ',
|
||||
),
|
||||
);
|
||||
|
||||
const confirmed = await promptForConsentNonInteractive(
|
||||
promptLines.join('\n'),
|
||||
false,
|
||||
);
|
||||
if (confirmed) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve(true)
|
||||
: requestConsentNonInteractive;
|
||||
@@ -44,12 +144,11 @@ export async function handleInstall(args: InstallArgs) {
|
||||
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
settings,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
const extension =
|
||||
|
||||
@@ -2016,6 +2016,40 @@ describe('loadCliConfig useRipgrep', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig directWebFetch', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be false by default when directWebFetch is not set in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getDirectWebFetch()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when directWebFetch is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
directWebFetch: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getDirectWebFetch()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screenReader configuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -826,7 +826,8 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
planSettings: settings.general.plan,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
@@ -877,6 +878,7 @@ export async function loadCliConfig(
|
||||
agents: refreshedSettings.merged.agents,
|
||||
};
|
||||
},
|
||||
enableConseca: settings.security?.enableConseca,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionUpdateEvent,
|
||||
getErrorMessage,
|
||||
getRealPath,
|
||||
logExtensionDisable,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
@@ -202,13 +203,11 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(
|
||||
this.workspaceDir,
|
||||
installMetadata.source,
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||
installMetadata.source = getRealPath(
|
||||
path.isAbsolute(installMetadata.source)
|
||||
? installMetadata.source
|
||||
: path.resolve(this.workspaceDir, installMetadata.source),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
KeychainTokenStorage,
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -186,11 +187,11 @@ describe('extension tests', () => {
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
tempHomeDir = getRealPath(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
tempWorkspaceDir = getRealPath(
|
||||
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
|
||||
);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
mockRequestConsent = vi.fn();
|
||||
@@ -329,12 +330,14 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
@@ -361,18 +364,20 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension-with-path',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['${extensionPath}${/}server${/}index.js'],
|
||||
cwd: '${extensionPath}${/}server',
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension-with-path',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['${extensionPath}${/}server${/}index.js'],
|
||||
cwd: '${extensionPath}${/}server',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
@@ -844,11 +849,13 @@ describe('extension tests', () => {
|
||||
|
||||
it('should generate id from the original source for linked extensions', async () => {
|
||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||
const actualExtensionDir = createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const actualExtensionDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'link',
|
||||
@@ -994,11 +1001,13 @@ describe('extension tests', () => {
|
||||
|
||||
describe('installExtension', () => {
|
||||
it('should install an extension from a local path', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
@@ -1040,7 +1049,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
|
||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
@@ -1056,7 +1065,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
|
||||
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
|
||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
||||
@@ -1066,22 +1075,17 @@ describe('extension tests', () => {
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^Failed to load extension config from ${configPath.replace(
|
||||
/\\/g,
|
||||
'\\\\',
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
|
||||
});
|
||||
|
||||
it('should throw an error for missing name in gemini-extension.json', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'missing-name-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'missing-name-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
// Overwrite with invalid config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
||||
@@ -1134,11 +1138,13 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should install a linked extension', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
@@ -1439,11 +1445,13 @@ ${INSTALL_WARNING_MESSAGE}`,
|
||||
});
|
||||
|
||||
it('should save the autoUpdate flag to the install metadata', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('consent', () => {
|
||||
{ input: '', expected: true },
|
||||
{ input: 'n', expected: false },
|
||||
{ input: 'N', expected: false },
|
||||
{ input: 'yes', expected: false },
|
||||
{ input: 'yes', expected: true },
|
||||
])(
|
||||
'should return $expected for input "$input"',
|
||||
async ({ input, expected }) => {
|
||||
|
||||
@@ -91,10 +91,12 @@ export async function requestConsentInteractive(
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
* @param defaultValue Whether to resolve as true or false on enter.
|
||||
* @returns Whether or not the user answers 'y' (yes).
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
export async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
defaultValue = true,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
@@ -105,7 +107,12 @@ async function promptForConsentNonInteractive(
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
const trimmedAnswer = answer.trim().toLowerCase();
|
||||
if (trimmedAnswer === '') {
|
||||
resolve(defaultValue);
|
||||
} else {
|
||||
resolve(['y', 'yes'].includes(trimmedAnswer));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { ExtensionStorage } from './storage.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as fs from 'node:fs';
|
||||
import { KeychainTokenStorage } from '@google/gemini-cli-core';
|
||||
import { HybridSecretStorage } from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
|
||||
vi.mock('prompts');
|
||||
@@ -38,7 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
KeychainTokenStorage: vi.fn(),
|
||||
HybridSecretStorage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -51,33 +51,29 @@ describe('extensionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockKeychainData = {};
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
(serviceName: string) => {
|
||||
if (!mockKeychainData[serviceName]) {
|
||||
mockKeychainData[serviceName] = {};
|
||||
}
|
||||
const keychainData = mockKeychainData[serviceName];
|
||||
return {
|
||||
getSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (key: string) => keychainData[key] || null,
|
||||
),
|
||||
setSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
keychainData[key] = value;
|
||||
}),
|
||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||
delete keychainData[key];
|
||||
vi.mocked(HybridSecretStorage).mockImplementation((serviceName: string) => {
|
||||
if (!mockKeychainData[serviceName]) {
|
||||
mockKeychainData[serviceName] = {};
|
||||
}
|
||||
const keychainData = mockKeychainData[serviceName];
|
||||
return {
|
||||
getSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string) => keychainData[key] || null),
|
||||
setSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
keychainData[key] = value;
|
||||
}),
|
||||
listSecrets: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.keys(keychainData)),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as KeychainTokenStorage;
|
||||
},
|
||||
);
|
||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||
delete keychainData[key];
|
||||
}),
|
||||
listSecrets: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.keys(keychainData)),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as HybridSecretStorage;
|
||||
});
|
||||
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
|
||||
tempWorkspaceDir = path.join(
|
||||
os.tmpdir(),
|
||||
@@ -215,7 +211,7 @@ describe('extensionSettings', () => {
|
||||
VAR1: 'previous-VAR1',
|
||||
SENSITIVE_VAR: 'secret',
|
||||
};
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||
@@ -255,7 +251,7 @@ describe('extensionSettings', () => {
|
||||
settings: [],
|
||||
};
|
||||
const previousSettings = { SENSITIVE_VAR: 'secret' };
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||
@@ -421,53 +417,11 @@ describe('extensionSettings', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
||||
// Arrange
|
||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
||||
const mockListSecrets = vi.fn();
|
||||
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: mockIsAvailable,
|
||||
listSecrets: mockListSecrets,
|
||||
deleteSecret: vi.fn(),
|
||||
getSecret: vi.fn(),
|
||||
setSecret: vi.fn(),
|
||||
}) as unknown as KeychainTokenStorage,
|
||||
);
|
||||
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [], // Empty settings triggers clearSettings
|
||||
};
|
||||
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
|
||||
// Act
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
previousConfig,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockIsAvailable).toHaveBeenCalled();
|
||||
expect(mockListSecrets).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSetting', () => {
|
||||
@@ -549,7 +503,7 @@ describe('extensionSettings', () => {
|
||||
it('should return combined contents from user .env and keychain for USER scope', async () => {
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
|
||||
@@ -573,7 +527,7 @@ describe('extensionSettings', () => {
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
const workspaceKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||
@@ -590,6 +544,29 @@ describe('extensionSettings', () => {
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore .env if it is a directory', async () => {
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceEnvPath);
|
||||
const workspaceKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||
|
||||
const contents = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvContents (merged)', () => {
|
||||
@@ -611,7 +588,7 @@ describe('extensionSettings', () => {
|
||||
userEnvPath,
|
||||
'VAR1=user-value1\nVAR3=user-value3',
|
||||
);
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId}`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'user-secret2');
|
||||
@@ -622,7 +599,7 @@ describe('extensionSettings', () => {
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
const workspaceKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
|
||||
@@ -655,7 +632,7 @@ describe('extensionSettings', () => {
|
||||
beforeEach(async () => {
|
||||
const userEnvPath = path.join(extensionDir, '.env');
|
||||
await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'value2');
|
||||
@@ -696,6 +673,26 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toContain('VAR1=new-workspace-value');
|
||||
});
|
||||
|
||||
it('should throw an error when trying to write to a workspace with a .env directory', async () => {
|
||||
const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
|
||||
fs.mkdirSync(workspaceEnvPath);
|
||||
|
||||
mockRequestSetting.mockResolvedValue('new-workspace-value');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Cannot write extension settings to .* because it is a directory./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a sensitive setting in USER scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value2');
|
||||
|
||||
@@ -708,7 +705,7 @@ describe('extensionSettings', () => {
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
|
||||
@@ -726,7 +723,7 @@ describe('extensionSettings', () => {
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
const workspaceKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
expect(await workspaceKeychain.getSecret('VAR2')).toBe(
|
||||
@@ -780,7 +777,7 @@ describe('extensionSettings', () => {
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
@@ -806,7 +803,7 @@ describe('extensionSettings', () => {
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
const userKeychain = new HybridSecretStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.deleteSecret('VAR2');
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ExtensionStorage } from './storage.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
import prompts from 'prompts';
|
||||
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
|
||||
import { debugLogger, HybridSecretStorage } from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
|
||||
export enum ExtensionSettingScope {
|
||||
@@ -78,7 +78,7 @@ export async function maybePromptForSettings(
|
||||
// The user can change the scope later using the `settings set` command.
|
||||
const scope = ExtensionSettingScope.USER;
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
);
|
||||
|
||||
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
|
||||
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
@@ -167,14 +176,17 @@ export async function getScopedEnvContents(
|
||||
workspaceDir?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const { name: extensionName } = extensionConfig;
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (!stat.isDirectory()) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionConfig.settings) {
|
||||
@@ -238,7 +250,7 @@ export async function updateSetting(
|
||||
}
|
||||
|
||||
const newValue = await requestSetting(settingToUpdate);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
|
||||
@@ -260,6 +272,12 @@ export async function updateSetting(
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let envContent = '';
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
|
||||
);
|
||||
}
|
||||
envContent = await fs.readFile(envFilePath, 'utf-8');
|
||||
}
|
||||
|
||||
@@ -321,13 +339,13 @@ function getSettingsChanges(
|
||||
|
||||
async function clearSettings(
|
||||
envFilePath: string,
|
||||
keychain: KeychainTokenStorage,
|
||||
keychain: HybridSecretStorage,
|
||||
) {
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
if (!(await keychain.isAvailable())) {
|
||||
return;
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (!stat.isDirectory()) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
}
|
||||
const secrets = await keychain.listSecrets();
|
||||
for (const secret of secrets) {
|
||||
|
||||
@@ -352,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should correctly match tool annotations', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
// Add a manual rule with annotations to the config
|
||||
config.rules = config.rules || [];
|
||||
config.rules.push({
|
||||
toolAnnotations: { readOnlyHint: true },
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// A tool with readOnlyHint=true should be ALLOWED
|
||||
const roCall = { name: 'some_tool', args: {} };
|
||||
const roMeta = { readOnlyHint: true };
|
||||
expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// A tool without the hint (or with false) should follow default decision (ASK_USER)
|
||||
const rwMeta = { readOnlyHint: false };
|
||||
expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(['write_file', 'replace'])(
|
||||
'Plan Mode policy for %s',
|
||||
(toolName) => {
|
||||
|
||||
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Run from HOME directory (tempDir is mocked as HOME in beforeEach)
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: tempDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Create a symlink to the home directory
|
||||
const symlinkDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-cli-symlink-${Date.now()}`,
|
||||
);
|
||||
fs.symlinkSync(tempDir, symlinkDir, 'dir');
|
||||
|
||||
try {
|
||||
// Run from symlink to HOME directory
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: symlinkDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
} finally {
|
||||
// Clean up symlink
|
||||
fs.unlinkSync(symlinkDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
const potentialWorkspacePoliciesDir = new Storage(
|
||||
cwd,
|
||||
).getWorkspacePoliciesDir();
|
||||
const storage = new Storage(cwd);
|
||||
|
||||
// If we are in the home directory (or rather, our target Gemini dir is the global one),
|
||||
// don't treat it as a workspace to avoid loading global policies twice.
|
||||
if (storage.isWorkspaceHomeDir()) {
|
||||
return { workspacePoliciesDir: undefined };
|
||||
}
|
||||
|
||||
const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
const integrityResult = await integrityManager.checkIntegrity(
|
||||
'workspace',
|
||||
|
||||
@@ -75,10 +75,12 @@ import {
|
||||
SettingScope,
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -126,6 +128,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
const pathMod = await import('node:path');
|
||||
const fsMod = await import('node:fs');
|
||||
|
||||
// Helper to resolve paths using the test's mocked environment
|
||||
const testResolve = (p: string | undefined) => {
|
||||
if (!p) return '';
|
||||
try {
|
||||
// Use the mocked fs.realpathSync if available, otherwise fallback
|
||||
return fsMod.realpathSync(pathMod.resolve(p));
|
||||
} catch {
|
||||
return pathMod.resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a smarter mock for isWorkspaceHomeDir
|
||||
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
|
||||
function (this: Storage) {
|
||||
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
|
||||
// Pick up the mocked home directory specifically from the 'os' mock
|
||||
const home = testResolve(os.homedir());
|
||||
return actual.normalizePath(target) === actual.normalizePath(home);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
@@ -1491,20 +1517,29 @@ describe('Settings Loading and Merging', () => {
|
||||
return pStr;
|
||||
});
|
||||
|
||||
// Force the storage check to return true for this specific test
|
||||
const isWorkspaceHomeDirSpy = vi
|
||||
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
|
||||
.mockReturnValue(true);
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
try {
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
} finally {
|
||||
isWorkspaceHomeDirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1804,36 +1839,50 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files from untrusted spaces', () => {
|
||||
it('does not load env files from untrusted spaces when sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: true },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined', () => {
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: false },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
delete process.env['TESTTEST'];
|
||||
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: true },
|
||||
} as Settings;
|
||||
|
||||
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.merged.tools.sandbox = true;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { sandbox: true },
|
||||
});
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
@@ -1846,10 +1895,10 @@ describe('Settings Loading and Merging', () => {
|
||||
process.argv.push('-s');
|
||||
try {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure sandbox is NOT in settings to test argv sniffing
|
||||
settings.merged.tools.sandbox = undefined;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { sandbox: false },
|
||||
});
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
@@ -2748,7 +2797,7 @@ describe('Settings Loading and Merging', () => {
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||
});
|
||||
|
||||
it('should NOT be tricked by positional arguments that look like flags', () => {
|
||||
@@ -2767,7 +2816,7 @@ describe('Settings Loading and Merging', () => {
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -573,10 +573,6 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
if (trustResult.isTrusted !== true && !isSandboxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
@@ -637,24 +633,8 @@ export function loadSettings(
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
const systemDefaultsPath = getSystemDefaultsPath();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
try {
|
||||
// fs.realpathSync gets the "true" path, resolving any symlinks
|
||||
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
||||
} catch (_e) {
|
||||
// This is okay. The path might not exist yet, and that's a valid state.
|
||||
}
|
||||
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = new Storage(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
@@ -712,7 +692,7 @@ export function loadSettings(
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
@@ -800,11 +780,11 @@ export function loadSettings(
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
|
||||
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: realWorkspaceDir === realHomeDir,
|
||||
readOnly: storage.isWorkspaceHomeDir(),
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
|
||||
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Retry on "exception TypeError: fetch failed sending request" errors.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxAttempts: {
|
||||
type: 'number',
|
||||
label: 'Max Chat Model Attempts',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 10,
|
||||
description:
|
||||
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
|
||||
showInDialog: true,
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
@@ -964,6 +974,60 @@ const SETTINGS_SCHEMA = {
|
||||
ref: 'AgentOverride',
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
type: 'object',
|
||||
label: 'Browser Agent',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings specific to the browser agent.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
sessionMode: {
|
||||
type: 'enum',
|
||||
label: 'Browser Session Mode',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: 'persistent',
|
||||
description:
|
||||
"Session mode: 'persistent', 'isolated', or 'existing'.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'persistent', label: 'Persistent' },
|
||||
{ value: 'isolated', label: 'Isolated' },
|
||||
{ value: 'existing', label: 'Existing' },
|
||||
],
|
||||
},
|
||||
headless: {
|
||||
type: 'boolean',
|
||||
label: 'Browser Headless',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Run browser in headless mode.',
|
||||
showInDialog: false,
|
||||
},
|
||||
profilePath: {
|
||||
type: 'string',
|
||||
label: 'Browser Profile Path',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Path to browser profile directory for session persistence.',
|
||||
showInDialog: false,
|
||||
},
|
||||
visualModel: {
|
||||
type: 'string',
|
||||
label: 'Browser Visual Model',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Model override for the visual agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1483,6 +1547,16 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
enableConseca: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Context-Aware Security',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1693,6 +1767,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable model steering (user hints) to guide the model during tool execution.',
|
||||
showInDialog: true,
|
||||
},
|
||||
directWebFetch: {
|
||||
type: 'boolean',
|
||||
label: 'Direct Web Fetch',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable web fetch behavior that bypasses LLM summarization.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -393,9 +393,11 @@ export const render = (
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
if (isInkRenderMetrics(metrics)) {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
}
|
||||
const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
|
||||
const staticOutput = isInkRenderMetrics(metrics)
|
||||
? (metrics.staticOutput ?? '')
|
||||
: '';
|
||||
stdout.onRender(staticOutput, output);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,12 @@ export const Colors: ColorsTheme = {
|
||||
get DarkGray() {
|
||||
return themeManager.getColors().DarkGray;
|
||||
},
|
||||
get InputBackground() {
|
||||
return themeManager.getColors().InputBackground;
|
||||
},
|
||||
get MessageBackground() {
|
||||
return themeManager.getColors().MessageBackground;
|
||||
},
|
||||
get GradientColors() {
|
||||
return themeManager.getActiveTheme().colors.GradientColors;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,11 @@ import { policiesCommand } from './policiesCommand.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { type Config, PolicyDecision } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Config,
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
describe('policiesCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
@@ -106,6 +110,7 @@ describe('policiesCommand', () => {
|
||||
expect(content).toContain(
|
||||
'### Yolo Mode Policies (combined with normal mode policies)',
|
||||
);
|
||||
expect(content).toContain('### Plan Mode Policies');
|
||||
expect(content).toContain(
|
||||
'**DENY** tool: `dangerousTool` [Priority: 10]',
|
||||
);
|
||||
@@ -114,5 +119,45 @@ describe('policiesCommand', () => {
|
||||
);
|
||||
expect(content).toContain('**ASK_USER** all tools');
|
||||
});
|
||||
|
||||
it('should show plan-only rules in plan mode section', async () => {
|
||||
const mockRules = [
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'glob',
|
||||
priority: 70,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
{
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 60,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
{
|
||||
decision: PolicyDecision.ALLOW,
|
||||
toolName: 'shell',
|
||||
priority: 50,
|
||||
},
|
||||
];
|
||||
const mockPolicyEngine = {
|
||||
getRules: vi.fn().mockReturnValue(mockRules),
|
||||
};
|
||||
mockContext.services.config = {
|
||||
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
|
||||
} as unknown as Config;
|
||||
|
||||
const listCommand = policiesCommand.subCommands![0];
|
||||
await listCommand.action!(mockContext, '');
|
||||
|
||||
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
|
||||
const content = (call[0] as { text: string }).text;
|
||||
|
||||
// Plan-only rules appear under Plan Mode section
|
||||
expect(content).toContain('### Plan Mode Policies');
|
||||
// glob ALLOW is plan-only, should appear in plan section
|
||||
expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]');
|
||||
// shell ALLOW has no modes (applies to all), appears in normal section
|
||||
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CategorizedRules {
|
||||
normal: PolicyRule[];
|
||||
autoEdit: PolicyRule[];
|
||||
yolo: PolicyRule[];
|
||||
plan: PolicyRule[];
|
||||
}
|
||||
|
||||
const categorizeRulesByMode = (
|
||||
@@ -21,6 +22,7 @@ const categorizeRulesByMode = (
|
||||
normal: [],
|
||||
autoEdit: [],
|
||||
yolo: [],
|
||||
plan: [],
|
||||
};
|
||||
const ALL_MODES = Object.values(ApprovalMode);
|
||||
rules.forEach((rule) => {
|
||||
@@ -29,6 +31,7 @@ const categorizeRulesByMode = (
|
||||
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
|
||||
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
|
||||
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
|
||||
if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -82,6 +85,9 @@ const listPoliciesCommand: SlashCommand = {
|
||||
const uniqueYolo = categorized.yolo.filter(
|
||||
(rule) => !normalRulesSet.has(rule),
|
||||
);
|
||||
const uniquePlan = categorized.plan.filter(
|
||||
(rule) => !normalRulesSet.has(rule),
|
||||
);
|
||||
|
||||
let content = '**Active Policies**\n\n';
|
||||
content += formatSection('Normal Mode Policies', categorized.normal);
|
||||
@@ -93,6 +99,7 @@ const listPoliciesCommand: SlashCommand = {
|
||||
'Yolo Mode Policies (combined with normal mode policies)',
|
||||
uniqueYolo,
|
||||
);
|
||||
content += formatSection('Plan Mode Policies', uniquePlan);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
||||
@@ -96,6 +96,8 @@ describe('<Header />', () => {
|
||||
},
|
||||
background: {
|
||||
primary: '',
|
||||
message: '',
|
||||
input: '',
|
||||
diff: { added: '', removed: '' },
|
||||
},
|
||||
border: {
|
||||
|
||||
@@ -56,10 +56,6 @@ import {
|
||||
} from '../utils/commandUtils.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
} from '../constants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
@@ -226,7 +222,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
hintMode,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -1422,14 +1417,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={
|
||||
hintMode ? theme.text.accent : theme.text.secondary
|
||||
}
|
||||
backgroundOpacity={
|
||||
showCursor
|
||||
? DEFAULT_INPUT_BACKGROUND_OPACITY
|
||||
: DEFAULT_BACKGROUND_OPACITY
|
||||
}
|
||||
backgroundBaseColor={theme.background.input}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ShortcutsHelp: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<SectionHeader title="Shortcuts (for more, see /help)" />
|
||||
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
|
||||
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
|
||||
{itemsForDisplay.map((item, index) => (
|
||||
<Box
|
||||
|
||||
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
@@ -16,7 +17,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
@@ -31,7 +33,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
@@ -40,7 +43,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
|
||||
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
|
||||
isExpandable,
|
||||
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
|
||||
<FocusHint
|
||||
|
||||
@@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show MCP tool details expand hint for MCP confirmations', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {
|
||||
url: 'https://www.google.co.jp',
|
||||
},
|
||||
toolDescription: 'Navigates browser to a URL.',
|
||||
toolParameterSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Destination URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('https://www.google.co.jp');
|
||||
expect(output).not.toContain('Navigates browser to a URL.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should omit empty MCP invocation arguments from details', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {},
|
||||
toolDescription: 'No arguments required.',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import {
|
||||
REDIRECTION_WARNING_NOTE_LABEL,
|
||||
REDIRECTION_WARNING_NOTE_TEXT,
|
||||
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { confirm, isDiffingEnabled } = useToolActions();
|
||||
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
|
||||
callId: string;
|
||||
expanded: boolean;
|
||||
}>({
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
: false;
|
||||
|
||||
const settings = useSettings();
|
||||
const allowPermanentApproval =
|
||||
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[confirm, callId],
|
||||
);
|
||||
|
||||
const mcpToolDetailsText = useMemo(() => {
|
||||
if (confirmationDetails.type !== 'mcp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailsLines: string[] = [];
|
||||
const hasNonEmptyToolArgs =
|
||||
confirmationDetails.toolArgs !== undefined &&
|
||||
!(
|
||||
typeof confirmationDetails.toolArgs === 'object' &&
|
||||
confirmationDetails.toolArgs !== null &&
|
||||
Object.keys(confirmationDetails.toolArgs).length === 0
|
||||
);
|
||||
if (hasNonEmptyToolArgs) {
|
||||
let argsText: string;
|
||||
try {
|
||||
argsText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolArgs, null, 2),
|
||||
);
|
||||
} catch {
|
||||
argsText = '[unserializable arguments]';
|
||||
}
|
||||
detailsLines.push('Invocation Arguments:');
|
||||
detailsLines.push(argsText);
|
||||
}
|
||||
|
||||
const description = confirmationDetails.toolDescription?.trim();
|
||||
if (description) {
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Description:');
|
||||
detailsLines.push(stripUnsafeCharacters(description));
|
||||
}
|
||||
|
||||
if (confirmationDetails.toolParameterSchema !== undefined) {
|
||||
let schemaText: string;
|
||||
try {
|
||||
schemaText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
|
||||
);
|
||||
} catch {
|
||||
schemaText = '[unserializable schema]';
|
||||
}
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Input Schema:');
|
||||
detailsLines.push(schemaText);
|
||||
}
|
||||
|
||||
if (detailsLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return detailsLines.join('\n');
|
||||
}, [confirmationDetails]);
|
||||
|
||||
const hasMcpToolDetails = !!mcpToolDetailsText;
|
||||
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return false;
|
||||
if (
|
||||
confirmationDetails.type === 'mcp' &&
|
||||
hasMcpToolDetails &&
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key)
|
||||
) {
|
||||
setMcpDetailsExpansionState({
|
||||
callId,
|
||||
expanded: !isMcpToolDetailsExpanded,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
{ isActive: isFocused, priority: true },
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
<>
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
</>
|
||||
{hasMcpToolDetails && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>MCP Tool Details:</Text>
|
||||
{isMcpToolDetailsExpanded ? (
|
||||
<>
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to collapse MCP tool details)
|
||||
</Text>
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to expand MCP tool details)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
isMcpToolDetailsExpanded,
|
||||
hasMcpToolDetails,
|
||||
mcpToolDetailsText,
|
||||
expandDetailsHintKey,
|
||||
]);
|
||||
|
||||
const bodyOverflowDirection: 'top' | 'bottom' =
|
||||
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
|
||||
? 'bottom'
|
||||
: 'top';
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
return (
|
||||
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
overflowDirection={bodyOverflowDirection}
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
@@ -375,20 +375,25 @@ describe('<ToolMessage />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders progress information appended to description for executing tools', async () => {
|
||||
it('renders McpProgressIndicator with percentage and message for executing tools', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
progress={42}
|
||||
progressTotal={100}
|
||||
progressMessage="Working on it..."
|
||||
progressPercent={42}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(
|
||||
'A tool for testing (Working on it... - 42%)',
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('42%');
|
||||
expect(output).toContain('Working on it...');
|
||||
expect(output).toContain('\u2588');
|
||||
expect(output).toContain('\u2591');
|
||||
expect(output).not.toContain('A tool for testing (Working on it... - 42%)');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -397,12 +402,37 @@ describe('<ToolMessage />', () => {
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
progressPercent={75}
|
||||
progress={75}
|
||||
progressTotal={100}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('A tool for testing (75%)');
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('75%');
|
||||
expect(output).toContain('\u2588');
|
||||
expect(output).toContain('\u2591');
|
||||
expect(output).not.toContain('A tool for testing (75%)');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders indeterminate progress when total is missing', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
status={CoreToolCallStatus.Executing}
|
||||
progress={7}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('7');
|
||||
expect(output).toContain('\u2588');
|
||||
expect(output).toContain('\u2591');
|
||||
expect(output).not.toContain('%');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ToolStatusIndicator,
|
||||
ToolInfo,
|
||||
TrailingIndicator,
|
||||
McpProgressIndicator,
|
||||
type TextEmphasis,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
isThisShellFocusable as checkIsShellFocusable,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
useFocusHint,
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
|
||||
export type { TextEmphasis };
|
||||
@@ -56,7 +57,9 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
ptyId,
|
||||
config,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
progress,
|
||||
progressTotal,
|
||||
}) => {
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
@@ -91,8 +94,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
progressMessage={progressMessage}
|
||||
progressPercent={progressPercent}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
@@ -112,6 +114,14 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{status === CoreToolCallStatus.Executing && progress !== undefined && (
|
||||
<McpProgressIndicator
|
||||
progress={progress}
|
||||
total={progressTotal}
|
||||
message={progressMessage}
|
||||
barWidth={20}
|
||||
/>
|
||||
)}
|
||||
<ToolResultDisplay
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { Text } from 'ink';
|
||||
import { McpProgressIndicator } from './ToolShared.js';
|
||||
|
||||
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: () => <Text>MockSpinner</Text>,
|
||||
}));
|
||||
|
||||
describe('McpProgressIndicator', () => {
|
||||
it('renders determinate progress at 50%', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<McpProgressIndicator progress={50} total={100} barWidth={20} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('50%');
|
||||
});
|
||||
|
||||
it('renders complete progress at 100%', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<McpProgressIndicator progress={100} total={100} barWidth={20} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('100%');
|
||||
});
|
||||
|
||||
it('renders indeterminate progress with raw count', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<McpProgressIndicator progress={7} barWidth={20} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('7');
|
||||
expect(output).not.toContain('%');
|
||||
});
|
||||
|
||||
it('renders progress with a message', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<McpProgressIndicator
|
||||
progress={30}
|
||||
total={100}
|
||||
message="Downloading..."
|
||||
barWidth={20}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('Downloading...');
|
||||
});
|
||||
|
||||
it('clamps progress exceeding total to 100%', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<McpProgressIndicator progress={150} total={100} barWidth={20} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('100%');
|
||||
expect(output).not.toContain('150%');
|
||||
});
|
||||
});
|
||||
@@ -187,8 +187,7 @@ type ToolInfoProps = {
|
||||
description: string;
|
||||
status: CoreToolCallStatus;
|
||||
emphasis: TextEmphasis;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
@@ -196,8 +195,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
description,
|
||||
status: coreStatus,
|
||||
emphasis,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const status = mapCoreStatusToDisplayStatus(coreStatus);
|
||||
const nameColor = React.useMemo<string>(() => {
|
||||
@@ -218,34 +216,22 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
// Hide description for completed Ask User tools (the result display speaks for itself)
|
||||
const isCompletedAskUser = isCompletedAskUserTool(name, status);
|
||||
|
||||
let displayDescription = description;
|
||||
if (status === ToolCallStatus.Executing) {
|
||||
const parts: string[] = [];
|
||||
if (progressMessage) {
|
||||
parts.push(progressMessage);
|
||||
}
|
||||
if (progressPercent !== undefined) {
|
||||
parts.push(`${Math.round(progressPercent)}%`);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
const progressInfo = parts.join(' - ');
|
||||
displayDescription = description
|
||||
? `${description} (${progressInfo})`
|
||||
: progressInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>
|
||||
{originalRequestName && originalRequestName !== name && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(redirection from {originalRequestName})
|
||||
</Text>
|
||||
)}
|
||||
{!isCompletedAskUser && (
|
||||
<>
|
||||
{' '}
|
||||
<Text color={theme.text.secondary}>{displayDescription}</Text>
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
@@ -253,6 +239,54 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export interface McpProgressIndicatorProps {
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
barWidth: number;
|
||||
}
|
||||
|
||||
export const McpProgressIndicator: React.FC<McpProgressIndicatorProps> = ({
|
||||
progress,
|
||||
total,
|
||||
message,
|
||||
barWidth,
|
||||
}) => {
|
||||
const percentage =
|
||||
total && total > 0
|
||||
? Math.min(100, Math.round((progress / total) * 100))
|
||||
: null;
|
||||
|
||||
let rawFilled: number;
|
||||
if (total && total > 0) {
|
||||
rawFilled = Math.round((progress / total) * barWidth);
|
||||
} else {
|
||||
rawFilled = Math.floor(progress) % (barWidth + 1);
|
||||
}
|
||||
|
||||
const filled = Math.max(
|
||||
0,
|
||||
Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth),
|
||||
);
|
||||
const empty = Math.max(0, barWidth - filled);
|
||||
const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={theme.text.accent}>
|
||||
{progressBar} {percentage !== null ? `${percentage}%` : `${progress}`}
|
||||
</Text>
|
||||
</Box>
|
||||
{message && (
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrailingIndicator: React.FC = () => (
|
||||
<Text color={theme.text.primary} wrap="truncate">
|
||||
{' '}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
calculateTransformedLine,
|
||||
} from '../shared/text-buffer.js';
|
||||
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
|
||||
interface UserMessageProps {
|
||||
@@ -52,8 +51,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
backgroundBaseColor={theme.background.message}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -8,7 +8,6 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
|
||||
interface UserShellMessageProps {
|
||||
@@ -28,8 +27,8 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
backgroundBaseColor={theme.background.message}
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -92,6 +92,16 @@ exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||
│ │
|
||||
│ ████████░░░░░░░░░░░░ 42% │
|
||||
│ Working on it... │
|
||||
│ Test result │
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> > renders basic tool information 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool A tool for testing │
|
||||
@@ -115,3 +125,21 @@ exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
|
||||
│ Test result │
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> > renders indeterminate progress when total is missing 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||
│ │
|
||||
│ ███████░░░░░░░░░░░░░ 7 │
|
||||
│ Test result │
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> > renders only percentage when progressMessage is missing 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||
│ │
|
||||
│ ███████████████░░░░░ 75% │
|
||||
│ Test result │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`McpProgressIndicator > renders complete progress at 100% 1`] = `
|
||||
"████████████████████ 100%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpProgressIndicator > renders determinate progress at 50% 1`] = `
|
||||
"██████████░░░░░░░░░░ 50%
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpProgressIndicator > renders indeterminate progress with raw count 1`] = `
|
||||
"███████░░░░░░░░░░░░░ 7
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpProgressIndicator > renders progress with a message 1`] = `
|
||||
"██████░░░░░░░░░░░░░░ 30%
|
||||
Downloading...
|
||||
"
|
||||
`;
|
||||
@@ -30,9 +30,15 @@ describe('<SectionHeader />', () => {
|
||||
title: 'Narrow Container',
|
||||
width: 25,
|
||||
},
|
||||
])('$description', async ({ title, width }) => {
|
||||
{
|
||||
description: 'renders correctly with a subtitle',
|
||||
title: 'Shortcuts',
|
||||
subtitle: ' See /help for more',
|
||||
width: 40,
|
||||
},
|
||||
])('$description', async ({ title, subtitle, width }) => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<SectionHeader title={title} />,
|
||||
<SectionHeader title={title} subtitle={subtitle} />,
|
||||
{ width },
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -8,16 +8,13 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Box width="100%" flexDirection="row" overflow="hidden">
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{`── ${title}`}
|
||||
</Text>
|
||||
export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
|
||||
title,
|
||||
subtitle,
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" overflow="hidden">
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
minWidth={2}
|
||||
marginLeft={1}
|
||||
width="100%"
|
||||
borderStyle="single"
|
||||
borderTop
|
||||
borderBottom={false}
|
||||
@@ -25,5 +22,15 @@ export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
borderRight={false}
|
||||
borderColor={theme.text.secondary}
|
||||
/>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
|
||||
"── Narrow Container ─────
|
||||
"─────────────────────────
|
||||
Narrow Container
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
|
||||
"── Very Long Hea… ──
|
||||
"────────────────────
|
||||
Very Long Header Ti…
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
|
||||
"── My Header ───────────────────────────
|
||||
"────────────────────────────────────────
|
||||
My Header
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SectionHeader /> > 'renders correctly with a subtitle' 1`] = `
|
||||
"────────────────────────────────────────
|
||||
Shortcuts See /help for more
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
|
||||
|
||||
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
|
||||
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
|
||||
export const DEFAULT_BORDER_OPACITY = 0.2;
|
||||
export const DEFAULT_BORDER_OPACITY = 0.4;
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_URL =
|
||||
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
|
||||
|
||||
@@ -263,6 +263,41 @@ describe('toolMapping', () => {
|
||||
expect(result.borderBottom).toBe(false);
|
||||
});
|
||||
|
||||
it('maps raw progress and progressTotal from Executing calls', () => {
|
||||
const toolCall: ExecutingToolCall = {
|
||||
status: CoreToolCallStatus.Executing,
|
||||
request: mockRequest,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
progressMessage: 'Downloading...',
|
||||
progress: 5,
|
||||
progressTotal: 10,
|
||||
};
|
||||
|
||||
const result = mapToDisplay(toolCall);
|
||||
const displayTool = result.tools[0];
|
||||
|
||||
expect(displayTool.progress).toBe(5);
|
||||
expect(displayTool.progressTotal).toBe(10);
|
||||
expect(displayTool.progressMessage).toBe('Downloading...');
|
||||
});
|
||||
|
||||
it('leaves progress fields undefined for non-Executing calls', () => {
|
||||
const toolCall: SuccessfulToolCall = {
|
||||
status: CoreToolCallStatus.Success,
|
||||
request: mockRequest,
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
response: mockResponse,
|
||||
};
|
||||
|
||||
const result = mapToDisplay(toolCall);
|
||||
const displayTool = result.tools[0];
|
||||
|
||||
expect(displayTool.progress).toBeUndefined();
|
||||
expect(displayTool.progressTotal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets resultDisplay to undefined for pre-execution statuses', () => {
|
||||
const toolCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
@@ -275,5 +310,20 @@ describe('toolMapping', () => {
|
||||
expect(result.tools[0].resultDisplay).toBeUndefined();
|
||||
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
|
||||
});
|
||||
|
||||
it('propagates originalRequestName correctly', () => {
|
||||
const toolCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
...mockRequest,
|
||||
originalRequestName: 'original_tool',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
};
|
||||
|
||||
const result = mapToDisplay(toolCall);
|
||||
expect(result.tools[0].originalRequestName).toBe('original_tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,8 @@ export function mapToDisplay(
|
||||
let ptyId: number | undefined = undefined;
|
||||
let correlationId: string | undefined = undefined;
|
||||
let progressMessage: string | undefined = undefined;
|
||||
let progressPercent: number | undefined = undefined;
|
||||
let progress: number | undefined = undefined;
|
||||
let progressTotal: number | undefined = undefined;
|
||||
|
||||
switch (call.status) {
|
||||
case CoreToolCallStatus.Success:
|
||||
@@ -80,7 +81,8 @@ export function mapToDisplay(
|
||||
resultDisplay = call.liveOutput;
|
||||
ptyId = call.pid;
|
||||
progressMessage = call.progressMessage;
|
||||
progressPercent = call.progressPercent;
|
||||
progress = call.progress;
|
||||
progressTotal = call.progressTotal;
|
||||
break;
|
||||
case CoreToolCallStatus.Scheduled:
|
||||
case CoreToolCallStatus.Validating:
|
||||
@@ -105,8 +107,10 @@ export function mapToDisplay(
|
||||
ptyId,
|
||||
correlationId,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
progress,
|
||||
progressTotal,
|
||||
approvalMode: call.approvalMode,
|
||||
originalRequestName: call.request.originalRequestName,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
MCPDiscoveryState,
|
||||
getPlanModeExitMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -2079,6 +2080,34 @@ describe('useGeminiStream', () => {
|
||||
expect.objectContaining({ correlationId: 'corr-call2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject a notification message when manually exiting Plan Mode', async () => {
|
||||
// Setup mockConfig to return PLAN mode initially
|
||||
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.PLAN);
|
||||
|
||||
// Render the hook, which will initialize the previousApprovalModeRef with PLAN
|
||||
const { result, client } = renderTestHook([]);
|
||||
|
||||
// Update mockConfig to return DEFAULT mode (new mode)
|
||||
(mockConfig.getApprovalMode as Mock).mockReturnValue(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// Trigger manual exit from Plan Mode
|
||||
await result.current.handleApprovalModeChange(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
// Verify that addHistory was called with the notification message
|
||||
expect(client.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: getPlanModeExitMessage(ApprovalMode.DEFAULT, true),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFinishedEvent', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
CoreToolCallStatus,
|
||||
buildUserSteeringHintPrompt,
|
||||
generateSteeringAckMessage,
|
||||
getPlanModeExitMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -203,6 +204,9 @@ export const useGeminiStream = (
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const turnCancelledRef = useRef(false);
|
||||
const activeQueryIdRef = useRef<string | null>(null);
|
||||
const previousApprovalModeRef = useRef<ApprovalMode>(
|
||||
config.getApprovalMode(),
|
||||
);
|
||||
const [isResponding, setIsResponding] = useState<boolean>(false);
|
||||
const [thought, thoughtRef, setThought] =
|
||||
useStateAndRef<ThoughtSummary | null>(null);
|
||||
@@ -1435,6 +1439,34 @@ export const useGeminiStream = (
|
||||
|
||||
const handleApprovalModeChange = useCallback(
|
||||
async (newApprovalMode: ApprovalMode) => {
|
||||
if (
|
||||
previousApprovalModeRef.current === ApprovalMode.PLAN &&
|
||||
newApprovalMode !== ApprovalMode.PLAN &&
|
||||
streamingState === StreamingState.Idle
|
||||
) {
|
||||
if (geminiClient) {
|
||||
try {
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: getPlanModeExitMessage(newApprovalMode, true),
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
onDebugMessage(
|
||||
`Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
|
||||
);
|
||||
addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
previousApprovalModeRef.current = newApprovalMode;
|
||||
|
||||
// Auto-approve pending tool calls when switching to auto-approval modes
|
||||
if (
|
||||
newApprovalMode === ApprovalMode.YOLO ||
|
||||
@@ -1473,7 +1505,7 @@ export const useGeminiStream = (
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, toolCalls],
|
||||
[config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
|
||||
);
|
||||
|
||||
const handleCompletedTools = useCallback(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Scheduler,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type ExecutingToolCall,
|
||||
type CompletedToolCall,
|
||||
type ToolCallsUpdateMessage,
|
||||
type AnyDeclarativeTool,
|
||||
@@ -110,7 +111,7 @@ describe('useToolScheduler', () => {
|
||||
tool: createMockTool(),
|
||||
invocation: createMockInvocation(),
|
||||
liveOutput: 'Loading...',
|
||||
};
|
||||
} as ExecutingToolCall;
|
||||
|
||||
act(() => {
|
||||
void mockMessageBus.publish({
|
||||
@@ -405,4 +406,62 @@ describe('useToolScheduler', () => {
|
||||
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
|
||||
).toBe('subagent-1');
|
||||
});
|
||||
|
||||
it('adapts success/error status to executing when a tail call is present', () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() =>
|
||||
useToolScheduler(
|
||||
vi.fn().mockResolvedValue(undefined),
|
||||
mockConfig,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const mockToolCall = {
|
||||
status: CoreToolCallStatus.Success as const,
|
||||
request: {
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
tool: createMockTool(),
|
||||
invocation: createMockInvocation(),
|
||||
response: {
|
||||
callId: 'call-1',
|
||||
resultDisplay: 'OK',
|
||||
responseParts: [],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
tailToolCallRequest: {
|
||||
name: 'tail_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: '123',
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
void mockMessageBus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [mockToolCall],
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
} as ToolCallsUpdateMessage);
|
||||
});
|
||||
|
||||
const [toolCalls, , , , , lastOutputTime] = result.current;
|
||||
|
||||
// Check if status has been adapted to 'executing'
|
||||
expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
|
||||
|
||||
// Check if lastOutputTime was updated due to the transitional state
|
||||
expect(lastOutputTime).toBeGreaterThan(startTime);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Scheduler,
|
||||
type EditorType,
|
||||
type ToolCallsUpdateMessage,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
|
||||
|
||||
@@ -115,7 +116,16 @@ export function useToolScheduler(
|
||||
useEffect(() => {
|
||||
const handler = (event: ToolCallsUpdateMessage) => {
|
||||
// Update output timer for UI spinners (Side Effect)
|
||||
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
|
||||
const hasExecuting = event.toolCalls.some(
|
||||
(tc) =>
|
||||
tc.status === CoreToolCallStatus.Executing ||
|
||||
((tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in tc &&
|
||||
tc.tailToolCallRequest != null),
|
||||
);
|
||||
|
||||
if (hasExecuting) {
|
||||
setLastToolOutputTime(Date.now());
|
||||
}
|
||||
|
||||
@@ -238,9 +248,23 @@ function adaptToolCalls(
|
||||
const prev = prevMap.get(coreCall.request.callId);
|
||||
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
|
||||
|
||||
let status = coreCall.status;
|
||||
// If a tool call has completed but scheduled a tail call, it is in a transitional
|
||||
// state. Force the UI to render it as "executing".
|
||||
if (
|
||||
(status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in coreCall &&
|
||||
coreCall.tailToolCallRequest != null
|
||||
) {
|
||||
status = CoreToolCallStatus.Executing;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...coreCall,
|
||||
status,
|
||||
responseSubmittedToGemini,
|
||||
};
|
||||
} as TrackedToolCall;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
|
||||
createKey('l', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Shell commands
|
||||
{
|
||||
command: Command.REVERSE_SEARCH,
|
||||
|
||||
@@ -24,6 +24,8 @@ const noColorColorsTheme: ColorsTheme = {
|
||||
Comment: '',
|
||||
Gray: '',
|
||||
DarkGray: '',
|
||||
InputBackground: '',
|
||||
MessageBackground: '',
|
||||
};
|
||||
|
||||
const noColorSemanticColors: SemanticColors = {
|
||||
@@ -36,6 +38,8 @@ const noColorSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '',
|
||||
message: '',
|
||||
input: '',
|
||||
diff: {
|
||||
added: '',
|
||||
removed: '',
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SemanticColors {
|
||||
};
|
||||
background: {
|
||||
primary: string;
|
||||
message: string;
|
||||
input: string;
|
||||
diff: {
|
||||
added: string;
|
||||
removed: string;
|
||||
@@ -48,13 +50,15 @@ export const lightSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: lightTheme.Background,
|
||||
message: lightTheme.MessageBackground!,
|
||||
input: lightTheme.InputBackground!,
|
||||
diff: {
|
||||
added: lightTheme.DiffAdded,
|
||||
removed: lightTheme.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: lightTheme.Gray,
|
||||
default: lightTheme.DarkGray,
|
||||
focused: lightTheme.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
@@ -80,13 +84,15 @@ export const darkSemanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: darkTheme.Background,
|
||||
message: darkTheme.MessageBackground!,
|
||||
input: darkTheme.InputBackground!,
|
||||
diff: {
|
||||
added: darkTheme.DiffAdded,
|
||||
removed: darkTheme.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: darkTheme.Gray,
|
||||
default: darkTheme.DarkGray,
|
||||
focused: darkTheme.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '#002b36',
|
||||
message: '#073642',
|
||||
input: '#073642',
|
||||
diff: {
|
||||
added: '#00382f',
|
||||
removed: '#3d0115',
|
||||
|
||||
@@ -36,6 +36,8 @@ const semanticColors: SemanticColors = {
|
||||
},
|
||||
background: {
|
||||
primary: '#fdf6e3',
|
||||
message: '#eee8d5',
|
||||
input: '#eee8d5',
|
||||
diff: {
|
||||
added: '#d7f2d7',
|
||||
removed: '#f2d7d7',
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
getThemeTypeFromBackgroundColor,
|
||||
resolveColor,
|
||||
} from './color-utils.js';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
} from '../constants.js';
|
||||
import { ANSI } from './ansi.js';
|
||||
import { ANSILight } from './ansi-light.js';
|
||||
import { NoColorTheme } from './no-color.js';
|
||||
@@ -310,7 +314,21 @@ class ThemeManager {
|
||||
this.cachedColors = {
|
||||
...colors,
|
||||
Background: this.terminalBackground,
|
||||
DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
|
||||
DarkGray: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
InputBackground: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
this.terminalBackground,
|
||||
colors.Gray,
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
this.cachedColors = colors;
|
||||
@@ -336,27 +354,22 @@ class ThemeManager {
|
||||
this.terminalBackground &&
|
||||
this.isThemeCompatible(activeTheme, this.terminalBackground)
|
||||
) {
|
||||
const colors = this.getColors();
|
||||
this.cachedSemanticColors = {
|
||||
...semanticColors,
|
||||
background: {
|
||||
...semanticColors.background,
|
||||
primary: this.terminalBackground,
|
||||
message: colors.MessageBackground!,
|
||||
input: colors.InputBackground!,
|
||||
},
|
||||
border: {
|
||||
...semanticColors.border,
|
||||
default: interpolateColor(
|
||||
this.terminalBackground,
|
||||
activeTheme.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: colors.DarkGray,
|
||||
},
|
||||
ui: {
|
||||
...semanticColors.ui,
|
||||
dark: interpolateColor(
|
||||
activeTheme.colors.Gray,
|
||||
this.terminalBackground,
|
||||
0.5,
|
||||
),
|
||||
dark: colors.DarkGray,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
|
||||
|
||||
it('should interpolate DarkGray when not provided', () => {
|
||||
const theme = createCustomTheme(baseTheme);
|
||||
// Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
|
||||
// Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
|
||||
// #cccccc is RGB(204, 204, 204)
|
||||
// #000000 is RGB(0, 0, 0)
|
||||
// Midpoint is RGB(102, 102, 102) which is #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Result is RGB(82, 82, 82) which is #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
|
||||
it('should use provided DarkGray', () => {
|
||||
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
|
||||
},
|
||||
};
|
||||
const theme = createCustomTheme(customTheme);
|
||||
// Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
|
||||
it('should prefer text.secondary over Gray for interpolation', () => {
|
||||
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
|
||||
},
|
||||
};
|
||||
const theme = createCustomTheme(customTheme);
|
||||
// Interpolate between #cccccc and #000000 -> #666666
|
||||
expect(theme.colors.DarkGray).toBe('#666666');
|
||||
// Interpolate between #000000 and #cccccc -> #525252
|
||||
expect(theme.colors.DarkGray).toBe('#525252');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
} from './color-utils.js';
|
||||
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
} from '../constants.js';
|
||||
|
||||
export type { CustomTheme };
|
||||
|
||||
@@ -37,6 +41,8 @@ export interface ColorsTheme {
|
||||
Comment: string;
|
||||
Gray: string;
|
||||
DarkGray: string;
|
||||
InputBackground?: string;
|
||||
MessageBackground?: string;
|
||||
GradientColors?: string[];
|
||||
}
|
||||
|
||||
@@ -55,7 +61,17 @@ export const lightTheme: ColorsTheme = {
|
||||
DiffRemoved: '#FFCCCC',
|
||||
Comment: '#008000',
|
||||
Gray: '#97a0b0',
|
||||
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
|
||||
DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#FAFAFA',
|
||||
'#97a0b0',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
@@ -74,7 +90,17 @@ export const darkTheme: ColorsTheme = {
|
||||
DiffRemoved: '#430000',
|
||||
Comment: '#6C7086',
|
||||
Gray: '#6C7086',
|
||||
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
|
||||
DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
|
||||
InputBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
'#1E1E2E',
|
||||
'#6C7086',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
|
||||
};
|
||||
|
||||
@@ -94,6 +120,8 @@ export const ansiTheme: ColorsTheme = {
|
||||
Comment: 'gray',
|
||||
Gray: 'gray',
|
||||
DarkGray: 'gray',
|
||||
InputBackground: 'black',
|
||||
MessageBackground: 'black',
|
||||
};
|
||||
|
||||
export class Theme {
|
||||
@@ -131,17 +159,27 @@ export class Theme {
|
||||
},
|
||||
background: {
|
||||
primary: this.colors.Background,
|
||||
message:
|
||||
this.colors.MessageBackground ??
|
||||
interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
input:
|
||||
this.colors.InputBackground ??
|
||||
interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
diff: {
|
||||
added: this.colors.DiffAdded,
|
||||
removed: this.colors.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: this.colors.DarkGray,
|
||||
focused: this.colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
@@ -242,10 +280,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
DarkGray:
|
||||
customTheme.DarkGray ??
|
||||
interpolateColor(
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
0.5,
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
InputBackground: interpolateColor(
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
),
|
||||
MessageBackground: interpolateColor(
|
||||
customTheme.background?.primary ?? customTheme.Background ?? '',
|
||||
customTheme.text?.secondary ?? customTheme.Gray ?? '',
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
),
|
||||
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
|
||||
};
|
||||
|
||||
@@ -400,19 +448,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
},
|
||||
background: {
|
||||
primary: customTheme.background?.primary ?? colors.Background,
|
||||
message: colors.MessageBackground!,
|
||||
input: colors.InputBackground!,
|
||||
diff: {
|
||||
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
|
||||
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default:
|
||||
customTheme.border?.default ??
|
||||
interpolateColor(
|
||||
colors.Background,
|
||||
colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
default: colors.DarkGray,
|
||||
focused: customTheme.border?.focused ?? colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -109,7 +109,9 @@ export interface IndividualToolCallDisplay {
|
||||
correlationId?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
||||
@@ -22,13 +22,82 @@ import WebSocket from 'ws';
|
||||
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
|
||||
const MAX_BUFFER_SIZE = 100;
|
||||
|
||||
/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
|
||||
function isHeaderRecord(
|
||||
h: http.OutgoingHttpHeaders | readonly string[],
|
||||
): h is http.OutgoingHttpHeaders {
|
||||
return !Array.isArray(h);
|
||||
}
|
||||
|
||||
function isRequestOptions(value: unknown): value is http.RequestOptions {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!(value instanceof URL) &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isIncomingMessageCallback(
|
||||
value: unknown,
|
||||
): value is (res: http.IncomingMessage) => void {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
type HttpRequestArgs =
|
||||
| []
|
||||
| [
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
];
|
||||
|
||||
function callHttpRequest(
|
||||
originalFn: typeof http.request,
|
||||
args: HttpRequestArgs,
|
||||
): http.ClientRequest {
|
||||
if (args.length === 0) {
|
||||
return originalFn({});
|
||||
}
|
||||
if (args.length === 1) {
|
||||
const first = args[0];
|
||||
if (typeof first === 'string' || first instanceof URL) {
|
||||
return originalFn(first);
|
||||
}
|
||||
if (isRequestOptions(first)) {
|
||||
return originalFn(first);
|
||||
}
|
||||
return originalFn({});
|
||||
}
|
||||
if (args.length === 2) {
|
||||
const first = args[0];
|
||||
const second = args[1];
|
||||
if (typeof first === 'string' || first instanceof URL) {
|
||||
if (isIncomingMessageCallback(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
if (isRequestOptions(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
}
|
||||
if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
}
|
||||
if (args.length === 3) {
|
||||
const first = args[0];
|
||||
const second = args[1];
|
||||
const third = args[2];
|
||||
if (
|
||||
(typeof first === 'string' || first instanceof URL) &&
|
||||
isRequestOptions(second) &&
|
||||
isIncomingMessageCallback(third)
|
||||
) {
|
||||
return originalFn(first, second, third);
|
||||
}
|
||||
}
|
||||
return originalFn({});
|
||||
}
|
||||
|
||||
export interface NetworkLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
@@ -364,7 +433,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
const wrapRequest = (
|
||||
originalFn: typeof http.request,
|
||||
args: unknown[],
|
||||
args: HttpRequestArgs,
|
||||
protocol: string,
|
||||
) => {
|
||||
const firstArg = args[0];
|
||||
@@ -373,8 +442,10 @@ export class ActivityLogger extends EventEmitter {
|
||||
options = firstArg;
|
||||
} else if (firstArg instanceof URL) {
|
||||
options = firstArg;
|
||||
} else if (firstArg && typeof firstArg === 'object') {
|
||||
options = isRequestOptions(firstArg) ? firstArg : {};
|
||||
} else {
|
||||
options = (firstArg ?? {}) as http.RequestOptions;
|
||||
options = {};
|
||||
}
|
||||
|
||||
let url = '';
|
||||
@@ -393,9 +464,9 @@ export class ActivityLogger extends EventEmitter {
|
||||
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
|
||||
}
|
||||
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost'))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost')) {
|
||||
return callHttpRequest(originalFn, args);
|
||||
}
|
||||
|
||||
const rawHeaders =
|
||||
typeof options === 'object' &&
|
||||
@@ -410,24 +481,23 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
if (headers[ACTIVITY_ID_HEADER]) {
|
||||
delete headers[ACTIVITY_ID_HEADER];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
return callHttpRequest(originalFn, args);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
this.requestStartTimes.set(id, Date.now());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const req = originalFn.apply(http, args as any);
|
||||
const req = callHttpRequest(originalFn, args);
|
||||
const requestChunks: Buffer[] = [];
|
||||
|
||||
const oldWrite = req.write;
|
||||
const oldEnd = req.end;
|
||||
|
||||
req.write = function (chunk: unknown, ...etc: unknown[]) {
|
||||
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
|
||||
if (chunk) {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
@@ -438,19 +508,21 @@ export class ActivityLogger extends EventEmitter {
|
||||
),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return oldWrite.apply(this, [chunk, ...etc] as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
return (oldWrite as any).apply(this, [chunk, ...etc]);
|
||||
};
|
||||
|
||||
req.end = function (
|
||||
this: http.ClientRequest,
|
||||
chunk: unknown,
|
||||
chunkOrCb?: string | Uint8Array | (() => void),
|
||||
...etc: unknown[]
|
||||
) {
|
||||
if (chunk && typeof chunk !== 'function') {
|
||||
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
|
||||
if (chunk) {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
@@ -473,7 +545,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
pending: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
return (oldEnd as any).apply(this, [chunk, ...etc]);
|
||||
return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
|
||||
};
|
||||
|
||||
req.on('response', (res: http.IncomingMessage) => {
|
||||
@@ -545,12 +617,44 @@ export class ActivityLogger extends EventEmitter {
|
||||
return req;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(http as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalRequest, args, 'http:');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(https as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
|
||||
Object.defineProperty(http, 'request', {
|
||||
value: (
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
): http.ClientRequest => {
|
||||
const args: HttpRequestArgs =
|
||||
callback !== undefined
|
||||
? [url, options, callback]
|
||||
: options !== undefined
|
||||
? [url, options]
|
||||
: [url];
|
||||
return wrapRequest(originalRequest, args, 'http:');
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(https, 'request', {
|
||||
value: (
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
): http.ClientRequest => {
|
||||
const args: HttpRequestArgs =
|
||||
callback !== undefined
|
||||
? [url, options, callback]
|
||||
: options !== undefined
|
||||
? [url, options]
|
||||
: [url];
|
||||
return wrapRequest(
|
||||
originalHttpsRequest as typeof http.request,
|
||||
args,
|
||||
'https:',
|
||||
);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
logConsole(payload: ConsoleLogPayload) {
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
|
||||
import type { BrowserManager, McpToolCallResult } from './browserManager.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
|
||||
const mockMessageBus = {
|
||||
waitForConfirmation: vi.fn().mockResolvedValue({ approved: true }),
|
||||
} as unknown as MessageBus;
|
||||
|
||||
function createMockBrowserManager(
|
||||
callToolResult?: McpToolCallResult,
|
||||
): BrowserManager {
|
||||
return {
|
||||
callTool: vi.fn().mockResolvedValue(
|
||||
callToolResult ?? {
|
||||
content: [
|
||||
{ type: 'text', text: 'Screenshot captured' },
|
||||
{
|
||||
type: 'image',
|
||||
data: 'base64encodeddata',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
} as unknown as BrowserManager;
|
||||
}
|
||||
|
||||
function createMockConfig(
|
||||
generateContentResult?: unknown,
|
||||
generateContentError?: Error,
|
||||
): Config {
|
||||
const generateContent = generateContentError
|
||||
? vi.fn().mockRejectedValue(generateContentError)
|
||||
: vi.fn().mockResolvedValue(
|
||||
generateContentResult ?? {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: 'The blue submit button is at coordinates (250, 400).',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
getBrowserAgentConfig: vi.fn().mockReturnValue({
|
||||
customConfig: { visualModel: 'test-visual-model' },
|
||||
}),
|
||||
getContentGenerator: vi.fn().mockReturnValue({
|
||||
generateContent,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('analyzeScreenshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createAnalyzeScreenshotTool', () => {
|
||||
it('creates a tool with the correct name and schema', () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig();
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(tool.name).toBe('analyze_screenshot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnalyzeScreenshotInvocation', () => {
|
||||
it('captures a screenshot and returns visual analysis', async () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig();
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the blue submit button',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
// Verify screenshot was captured
|
||||
expect(browserManager.callTool).toHaveBeenCalledWith(
|
||||
'take_screenshot',
|
||||
{},
|
||||
);
|
||||
|
||||
// Verify the visual model was called
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
expect(contentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'test-visual-model',
|
||||
contents: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
parts: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'base64encodeddata',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
'visual-analysis',
|
||||
'utility_tool',
|
||||
);
|
||||
|
||||
// Verify result
|
||||
expect(result.llmContent).toContain('Visual Analysis Result');
|
||||
expect(result.llmContent).toContain(
|
||||
'The blue submit button is at coordinates (250, 400).',
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns an error when screenshot capture fails (no image)', async () => {
|
||||
const browserManager = createMockBrowserManager({
|
||||
content: [{ type: 'text', text: 'No screenshot available' }],
|
||||
});
|
||||
const config = createMockConfig();
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the button',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Failed to capture screenshot');
|
||||
// Should NOT call the visual model
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
expect(contentGenerator.generateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error when visual model returns empty response', async () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig({
|
||||
candidates: [{ content: { parts: [] } }],
|
||||
});
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Check the layout',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Visual model returned no analysis');
|
||||
});
|
||||
|
||||
it('returns a model-unavailability fallback for 404 errors', async () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig(
|
||||
undefined,
|
||||
new Error('Model not found: 404'),
|
||||
);
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the red error',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain(
|
||||
'Visual analysis model is not available',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a model-unavailability fallback for 403 errors', async () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig(
|
||||
undefined,
|
||||
new Error('permission denied: 403'),
|
||||
);
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Identify the element',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain(
|
||||
'Visual analysis model is not available',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a generic error for non-model errors', async () => {
|
||||
const browserManager = createMockBrowserManager();
|
||||
const config = createMockConfig(undefined, new Error('Network timeout'));
|
||||
const tool = createAnalyzeScreenshotTool(
|
||||
browserManager,
|
||||
config,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find something',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Visual analysis failed');
|
||||
expect(result.llmContent).toContain('Network timeout');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Tool for visual identification via a single model call.
|
||||
*
|
||||
* The semantic browser agent uses this tool when it needs to identify
|
||||
* elements by visual attributes not present in the accessibility tree
|
||||
* (e.g., color, layout, precise coordinates).
|
||||
*
|
||||
* Unlike the semantic agent which works with the accessibility tree,
|
||||
* this tool sends a screenshot to a computer-use model for visual analysis.
|
||||
* It returns the model's analysis (coordinates, element descriptions) back
|
||||
* to the browser agent, which retains full control of subsequent actions.
|
||||
*/
|
||||
|
||||
import {
|
||||
DeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ToolInvocation,
|
||||
} from '../../tools/tools.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { getVisualAgentModel } from './modelAvailability.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { LlmRole } from '../../telemetry/llmRole.js';
|
||||
|
||||
/**
|
||||
* System prompt for the visual analysis model call.
|
||||
*/
|
||||
const VISUAL_SYSTEM_PROMPT = `You are a Visual Analysis Agent. You receive a screenshot of a browser page and an instruction.
|
||||
|
||||
Your job is to ANALYZE the screenshot and provide precise information that a browser automation agent can act on.
|
||||
|
||||
COORDINATE SYSTEM:
|
||||
- Coordinates are pixel-based relative to the viewport
|
||||
- (0,0) is top-left of the visible area
|
||||
- Estimate element positions from the screenshot
|
||||
|
||||
RESPONSE FORMAT:
|
||||
- For coordinate identification: provide exact (x, y) pixel coordinates
|
||||
- For element identification: describe the element's visual location and appearance
|
||||
- For layout analysis: describe the spatial relationships between elements
|
||||
- Be concise and actionable — the browser agent will use your response to decide what action to take
|
||||
|
||||
IMPORTANT:
|
||||
- You are NOT performing actions — you are only providing visual analysis
|
||||
- Include coordinates when possible so the caller can use click_at(x, y)
|
||||
- If the element is not visible in the screenshot, say so explicitly`;
|
||||
|
||||
/**
|
||||
* Invocation for the analyze_screenshot tool.
|
||||
* Makes a single generateContent call with a screenshot.
|
||||
*/
|
||||
class AnalyzeScreenshotInvocation extends BaseToolInvocation<
|
||||
Record<string, unknown>,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
private readonly config: Config,
|
||||
params: Record<string, unknown>,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(params, messageBus, 'analyze_screenshot', 'Analyze Screenshot');
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const instruction = String(this.params['instruction'] ?? '');
|
||||
return `Visual analysis: "${instruction}"`;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const instruction = String(this.params['instruction'] ?? '');
|
||||
|
||||
debugLogger.log(`Visual analysis requested: ${instruction}`);
|
||||
|
||||
// Capture screenshot via MCP tool
|
||||
const screenshotResult = await this.browserManager.callTool(
|
||||
'take_screenshot',
|
||||
{},
|
||||
);
|
||||
|
||||
// Extract base64 image data from MCP response.
|
||||
// Search ALL content items for image type — MCP returns [text, image]
|
||||
// where content[0] is a text description and content[1] is the actual PNG.
|
||||
let screenshotBase64 = '';
|
||||
let mimeType = 'image/png';
|
||||
if (screenshotResult.content && Array.isArray(screenshotResult.content)) {
|
||||
for (const item of screenshotResult.content) {
|
||||
if (item.type === 'image' && item.data) {
|
||||
screenshotBase64 = item.data;
|
||||
mimeType = item.mimeType ?? 'image/png';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!screenshotBase64) {
|
||||
return {
|
||||
llmContent:
|
||||
'Failed to capture screenshot for visual analysis. Use accessibility tree elements instead.',
|
||||
returnDisplay: 'Screenshot capture failed',
|
||||
error: { message: 'Screenshot capture failed' },
|
||||
};
|
||||
}
|
||||
|
||||
// Make a single generateContent call with the visual model
|
||||
const visualModel = getVisualAgentModel(this.config);
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
|
||||
const response = await contentGenerator.generateContent(
|
||||
{
|
||||
model: visualModel,
|
||||
config: {
|
||||
temperature: 0,
|
||||
topP: 0.95,
|
||||
systemInstruction: VISUAL_SYSTEM_PROMPT,
|
||||
abortSignal: signal,
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `Analyze this screenshot and respond to the following instruction:\n\n${instruction}`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: screenshotBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'visual-analysis',
|
||||
LlmRole.UTILITY_TOOL,
|
||||
);
|
||||
|
||||
// Extract text from response
|
||||
const responseText =
|
||||
response.candidates?.[0]?.content?.parts
|
||||
?.filter((p) => p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n') ?? '';
|
||||
|
||||
if (!responseText) {
|
||||
return {
|
||||
llmContent:
|
||||
'Visual model returned no analysis. Use accessibility tree elements instead.',
|
||||
returnDisplay: 'Visual analysis returned empty response',
|
||||
error: { message: 'Empty visual analysis response' },
|
||||
};
|
||||
}
|
||||
|
||||
debugLogger.log(`Visual analysis complete: ${responseText}`);
|
||||
|
||||
return {
|
||||
llmContent: `Visual Analysis Result:\n${responseText}`,
|
||||
returnDisplay: `Visual Analysis Result:\n${responseText}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(`Visual analysis failed: ${errorMsg}`);
|
||||
|
||||
// Provide a graceful fallback message for model unavailability
|
||||
const isModelError =
|
||||
errorMsg.includes('404') ||
|
||||
errorMsg.includes('403') ||
|
||||
errorMsg.includes('not found') ||
|
||||
errorMsg.includes('permission');
|
||||
|
||||
const fallbackMsg = isModelError
|
||||
? 'Visual analysis model is not available. Use accessibility tree elements (uids from take_snapshot) for all interactions instead.'
|
||||
: `Visual analysis failed: ${errorMsg}. Use accessibility tree elements instead.`;
|
||||
|
||||
return {
|
||||
llmContent: fallbackMsg,
|
||||
returnDisplay: fallbackMsg,
|
||||
error: { message: errorMsg },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DeclarativeTool for screenshot-based visual analysis.
|
||||
*/
|
||||
class AnalyzeScreenshotTool extends DeclarativeTool<
|
||||
Record<string, unknown>,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly browserManager: BrowserManager,
|
||||
private readonly config: Config,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
'analyze_screenshot',
|
||||
'analyze_screenshot',
|
||||
'Analyze the current page visually using a screenshot. Use when you need to identify elements by visual attributes (color, layout, position) not available in the accessibility tree, or when you need precise pixel coordinates for click_at. Returns visual analysis — you perform the actions yourself.',
|
||||
Kind.Other,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
instruction: {
|
||||
type: 'string',
|
||||
description:
|
||||
'What to identify or analyze visually (e.g., "Find the coordinates of the blue submit button", "What is the layout of the navigation menu?").',
|
||||
},
|
||||
},
|
||||
required: ['instruction'],
|
||||
},
|
||||
messageBus,
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
}
|
||||
|
||||
build(
|
||||
params: Record<string, unknown>,
|
||||
): ToolInvocation<Record<string, unknown>, ToolResult> {
|
||||
return new AnalyzeScreenshotInvocation(
|
||||
this.browserManager,
|
||||
this.config,
|
||||
params,
|
||||
this.messageBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the analyze_screenshot tool for the browser agent.
|
||||
*/
|
||||
export function createAnalyzeScreenshotTool(
|
||||
browserManager: BrowserManager,
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
): AnalyzeScreenshotTool {
|
||||
return new AnalyzeScreenshotTool(browserManager, config, messageBus);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Browser Agent definition following the LocalAgentDefinition pattern.
|
||||
*
|
||||
* This agent uses LocalAgentExecutor for its reAct loop, like CodebaseInvestigatorAgent.
|
||||
* It is available ONLY via delegate_to_agent, NOT as a direct tool.
|
||||
*
|
||||
* Tools are configured dynamically at invocation time via browserAgentFactory.
|
||||
*/
|
||||
|
||||
import type { LocalAgentDefinition } from '../types.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
isPreviewModel,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from '../../config/models.js';
|
||||
|
||||
/** Canonical agent name — used for routing and configuration lookup. */
|
||||
export const BROWSER_AGENT_NAME = 'browser_agent';
|
||||
|
||||
/**
|
||||
* Output schema for browser agent results.
|
||||
*/
|
||||
export const BrowserTaskResultSchema = z.object({
|
||||
success: z.boolean().describe('Whether the task was completed successfully'),
|
||||
summary: z
|
||||
.string()
|
||||
.describe('A summary of what was accomplished or what went wrong'),
|
||||
data: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Optional extracted data from the task'),
|
||||
});
|
||||
|
||||
const VISUAL_SECTION = `
|
||||
VISUAL IDENTIFICATION (analyze_screenshot):
|
||||
When you need to identify elements by visual attributes not in the AX tree (e.g., "click the yellow button", "find the red error message"), or need precise pixel coordinates:
|
||||
1. Call analyze_screenshot with a clear instruction describing what to find
|
||||
2. It returns visual analysis with coordinates/descriptions — it does NOT perform actions
|
||||
3. Use the returned coordinates with click_at(x, y) or other tools yourself
|
||||
4. If the analysis is insufficient, call it again with a more specific instruction
|
||||
`;
|
||||
|
||||
/**
|
||||
* System prompt for the semantic browser agent.
|
||||
* Extracted from prototype (computer_use_subagent_cdt branch).
|
||||
*
|
||||
* @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
|
||||
*/
|
||||
export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
|
||||
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
|
||||
|
||||
IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
|
||||
Use these uid values directly with your tools:
|
||||
- click(uid="87_4") to click the Login button
|
||||
- fill(uid="87_2", value="john") to fill a text field
|
||||
- fill_form(elements=[{uid: "87_2", value: "john"}, {uid: "87_3", value: "pass"}]) to fill multiple fields at once
|
||||
|
||||
PARALLEL TOOL CALLS - CRITICAL:
|
||||
- Do NOT make parallel calls for actions that change page state (click, fill, press_key, etc.)
|
||||
- Each action changes the DOM and invalidates UIDs from the current snapshot
|
||||
- Make state-changing actions ONE AT A TIME, then observe the results
|
||||
|
||||
OVERLAY/POPUP HANDLING:
|
||||
Before interacting with page content, scan the accessibility tree for blocking overlays:
|
||||
- Tooltips, popups, modals, cookie banners, newsletter prompts, promo dialogs
|
||||
- These often have: close buttons (×, X, Close, Dismiss), "Got it", "Accept", "No thanks" buttons
|
||||
- Common patterns: elements with role="dialog", role="tooltip", role="alertdialog", or aria-modal="true"
|
||||
- If you see such elements, DISMISS THEM FIRST by clicking close/dismiss buttons before proceeding
|
||||
- If a click seems to have no effect, check if an overlay appeared or is blocking the target
|
||||
${visionEnabled ? VISUAL_SECTION : ''}
|
||||
|
||||
COMPLEX WEB APPS (spreadsheets, rich editors, canvas apps):
|
||||
Many web apps (Google Sheets/Docs, Notion, Figma, etc.) use custom rendering rather than standard HTML inputs.
|
||||
- fill does NOT work on these apps. Instead, click the target element, then use type_text to enter the value.
|
||||
- type_text supports a submitKey parameter to press a key after typing (e.g., submitKey="Enter" to submit, submitKey="Tab" to move to the next field). This is much faster than separate press_key calls.
|
||||
- Navigate cells/fields using keyboard shortcuts (Tab, Enter, ArrowDown) — more reliable than clicking UIDs.
|
||||
- Use the Name Box (cell reference input, usually showing "A1") to jump to specific cells.
|
||||
|
||||
TERMINAL FAILURES — STOP IMMEDIATELY:
|
||||
Some errors are unrecoverable and retrying will never help. When you see ANY of these, call complete_task immediately with success=false and include the EXACT error message (including any remediation steps it contains) in your summary:
|
||||
- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" — Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
|
||||
- "Browser closed" or "Target closed" or "Session closed" — The browser process has terminated. Include the error and tell the user to try again.
|
||||
- "net::ERR_" network errors on the SAME URL after 2 retries — the site is unreachable. Report the URL and error.
|
||||
- Any error that appears IDENTICALLY 3+ times in a row — it will not resolve by retrying.
|
||||
Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
|
||||
|
||||
CRITICAL: When you have fully completed the user's task, you MUST call the complete_task tool with a summary of what you accomplished. Do NOT just return text - you must explicitly call complete_task to exit the loop.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser Agent Definition Factory.
|
||||
*
|
||||
* Following the CodebaseInvestigatorAgent pattern:
|
||||
* - Returns a factory function that takes Config for dynamic model selection
|
||||
* - kind: 'local' for LocalAgentExecutor
|
||||
* - toolConfig is set dynamically by browserAgentFactory
|
||||
*/
|
||||
export const BrowserAgentDefinition = (
|
||||
config: Config,
|
||||
visionEnabled = false,
|
||||
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
|
||||
// Use Preview Flash model if the main model is any of the preview models.
|
||||
// If the main model is not a preview model, use the default flash model.
|
||||
const model = isPreviewModel(config.getModel())
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
return {
|
||||
name: BROWSER_AGENT_NAME,
|
||||
kind: 'local',
|
||||
experimental: true,
|
||||
displayName: 'Browser Agent',
|
||||
description: `Specialized autonomous agent for end-to-end web browser automation and objective-driven problem solving. Delegate complete, high-level tasks to this agent — it independently plans, executes multi-step interactions, interprets dynamic page feedback (e.g., game states, form validation errors, search results), and iterates until the goal is achieved. It perceives page structure through the Accessibility Tree, handles overlays and popups, and supports complex web apps.`,
|
||||
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'The task to perform in the browser.',
|
||||
},
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
},
|
||||
|
||||
outputConfig: {
|
||||
outputName: 'result',
|
||||
description: 'The result of the browser task.',
|
||||
schema: BrowserTaskResultSchema,
|
||||
},
|
||||
|
||||
processOutput: (output) => JSON.stringify(output, null, 2),
|
||||
|
||||
modelConfig: {
|
||||
// Dynamic model based on whether user is using preview models
|
||||
model,
|
||||
generateContentConfig: {
|
||||
temperature: 0.1,
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
|
||||
runConfig: {
|
||||
maxTimeMinutes: 10,
|
||||
maxTurns: 50,
|
||||
},
|
||||
|
||||
// Tools are set dynamically by browserAgentFactory after MCP connection
|
||||
// This is undefined here and will be set at invocation time
|
||||
toolConfig: undefined,
|
||||
|
||||
promptConfig: {
|
||||
query: `Your task is:
|
||||
<task>
|
||||
\${task}
|
||||
</task>
|
||||
|
||||
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
|
||||
systemPrompt: buildBrowserSystemPrompt(visionEnabled),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createBrowserAgentDefinition,
|
||||
cleanupBrowserAgent,
|
||||
} from './browserAgentFactory.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
|
||||
// Create mock browser manager
|
||||
const mockBrowserManager = {
|
||||
ensureConnection: vi.fn().mockResolvedValue(undefined),
|
||||
getDiscoveredTools: vi.fn().mockResolvedValue([
|
||||
// Semantic tools
|
||||
{ name: 'take_snapshot', description: 'Take snapshot' },
|
||||
{ name: 'click', description: 'Click element' },
|
||||
{ name: 'fill', description: 'Fill form field' },
|
||||
{ name: 'navigate_page', description: 'Navigate to URL' },
|
||||
// Visual tools (from --experimental-vision)
|
||||
{ name: 'click_at', description: 'Click at coordinates' },
|
||||
]),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./browserManager.js', () => ({
|
||||
BrowserManager: vi.fn(() => mockBrowserManager),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
buildBrowserSystemPrompt,
|
||||
BROWSER_AGENT_NAME,
|
||||
} from './browserAgentDefinition.js';
|
||||
|
||||
describe('browserAgentFactory', () => {
|
||||
let mockConfig: Config;
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
|
||||
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
|
||||
// Semantic tools
|
||||
{ name: 'take_snapshot', description: 'Take snapshot' },
|
||||
{ name: 'click', description: 'Click element' },
|
||||
{ name: 'fill', description: 'Fill form field' },
|
||||
{ name: 'navigate_page', description: 'Navigate to URL' },
|
||||
// Visual tools (from --experimental-vision)
|
||||
{ name: 'click_at', description: 'Click at coordinates' },
|
||||
]);
|
||||
mockBrowserManager.close.mockResolvedValue(undefined);
|
||||
|
||||
mockConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockMessageBus = {
|
||||
publish: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createBrowserAgentDefinition', () => {
|
||||
it('should ensure browser connection', async () => {
|
||||
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
|
||||
|
||||
expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return agent definition with discovered tools', async () => {
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(definition.name).toBe(BROWSER_AGENT_NAME);
|
||||
// 5 MCP tools + 1 type_text composite tool (no analyze_screenshot without visualModel)
|
||||
expect(definition.toolConfig?.tools).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should return browser manager for cleanup', async () => {
|
||||
const { browserManager } = await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(browserManager).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call printOutput when provided', async () => {
|
||||
const printOutput = vi.fn();
|
||||
|
||||
await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
printOutput,
|
||||
);
|
||||
|
||||
expect(printOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create definition with correct structure', async () => {
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
expect(definition.kind).toBe('local');
|
||||
expect(definition.inputConfig).toBeDefined();
|
||||
expect(definition.outputConfig).toBeDefined();
|
||||
expect(definition.promptConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude visual prompt section when visualModel is not configured', async () => {
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
|
||||
expect(systemPrompt).not.toContain('analyze_screenshot');
|
||||
expect(systemPrompt).not.toContain('VISUAL IDENTIFICATION');
|
||||
});
|
||||
|
||||
it('should include visual prompt section when visualModel is configured', async () => {
|
||||
const configWithVision = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
visualModel: 'gemini-2.5-flash-preview',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
configWithVision,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
|
||||
expect(systemPrompt).toContain('analyze_screenshot');
|
||||
expect(systemPrompt).toContain('VISUAL IDENTIFICATION');
|
||||
});
|
||||
|
||||
it('should include analyze_screenshot tool when visualModel is configured', async () => {
|
||||
const configWithVision = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
headless: false,
|
||||
visualModel: 'gemini-2.5-flash-preview',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { definition } = await createBrowserAgentDefinition(
|
||||
configWithVision,
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
// 5 MCP tools + 1 type_text + 1 analyze_screenshot
|
||||
expect(definition.toolConfig?.tools).toHaveLength(7);
|
||||
const toolNames =
|
||||
definition.toolConfig?.tools
|
||||
?.filter(
|
||||
(t): t is { name: string } => typeof t === 'object' && 'name' in t,
|
||||
)
|
||||
.map((t) => t.name) ?? [];
|
||||
expect(toolNames).toContain('analyze_screenshot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBrowserAgent', () => {
|
||||
it('should call close on browser manager', async () => {
|
||||
await cleanupBrowserAgent(
|
||||
mockBrowserManager as unknown as BrowserManager,
|
||||
);
|
||||
|
||||
expect(mockBrowserManager.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during cleanup gracefully', async () => {
|
||||
const errorManager = {
|
||||
close: vi.fn().mockRejectedValue(new Error('Close failed')),
|
||||
} as unknown as BrowserManager;
|
||||
|
||||
// Should not throw
|
||||
await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBrowserSystemPrompt', () => {
|
||||
it('should include visual section when vision is enabled', () => {
|
||||
const prompt = buildBrowserSystemPrompt(true);
|
||||
expect(prompt).toContain('VISUAL IDENTIFICATION');
|
||||
expect(prompt).toContain('analyze_screenshot');
|
||||
expect(prompt).toContain('click_at');
|
||||
});
|
||||
|
||||
it('should exclude visual section when vision is disabled', () => {
|
||||
const prompt = buildBrowserSystemPrompt(false);
|
||||
expect(prompt).not.toContain('VISUAL IDENTIFICATION');
|
||||
expect(prompt).not.toContain('analyze_screenshot');
|
||||
});
|
||||
|
||||
it('should always include core sections regardless of vision', () => {
|
||||
for (const visionEnabled of [true, false]) {
|
||||
const prompt = buildBrowserSystemPrompt(visionEnabled);
|
||||
expect(prompt).toContain('PARALLEL TOOL CALLS');
|
||||
expect(prompt).toContain('OVERLAY/POPUP HANDLING');
|
||||
expect(prompt).toContain('COMPLEX WEB APPS');
|
||||
expect(prompt).toContain('TERMINAL FAILURES');
|
||||
expect(prompt).toContain('complete_task');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Factory for creating browser agent definitions with configured tools.
|
||||
*
|
||||
* This factory is called when the browser agent is invoked via delegate_to_agent.
|
||||
* It creates a BrowserManager, connects the isolated MCP client, wraps tools,
|
||||
* and returns a fully configured LocalAgentDefinition.
|
||||
*
|
||||
* IMPORTANT: The MCP tools are ONLY available to the browser agent's isolated
|
||||
* registry. They are NOT registered in the main agent's ToolRegistry.
|
||||
*/
|
||||
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { AuthType } from '../../core/contentGenerator.js';
|
||||
import type { LocalAgentDefinition } from '../types.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { AnyDeclarativeTool } from '../../tools/tools.js';
|
||||
import { BrowserManager } from './browserManager.js';
|
||||
import {
|
||||
BrowserAgentDefinition,
|
||||
type BrowserTaskResultSchema,
|
||||
} from './browserAgentDefinition.js';
|
||||
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
|
||||
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Creates a browser agent definition with MCP tools configured.
|
||||
*
|
||||
* This is called when the browser agent is invoked via delegate_to_agent.
|
||||
* The MCP client is created fresh and tools are wrapped for the agent's
|
||||
* isolated registry - NOT registered with the main agent.
|
||||
*
|
||||
* @param config Runtime configuration
|
||||
* @param messageBus Message bus for tool invocations
|
||||
* @param printOutput Optional callback for progress messages
|
||||
* @returns Fully configured LocalAgentDefinition with MCP tools
|
||||
*/
|
||||
export async function createBrowserAgentDefinition(
|
||||
config: Config,
|
||||
messageBus: MessageBus,
|
||||
printOutput?: (msg: string) => void,
|
||||
): Promise<{
|
||||
definition: LocalAgentDefinition<typeof BrowserTaskResultSchema>;
|
||||
browserManager: BrowserManager;
|
||||
}> {
|
||||
debugLogger.log(
|
||||
'Creating browser agent definition with isolated MCP tools...',
|
||||
);
|
||||
|
||||
// Create and initialize browser manager with isolated MCP client
|
||||
const browserManager = new BrowserManager(config);
|
||||
await browserManager.ensureConnection();
|
||||
|
||||
if (printOutput) {
|
||||
printOutput('Browser connected with isolated MCP client.');
|
||||
}
|
||||
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
'fill',
|
||||
'navigate_page',
|
||||
'take_snapshot',
|
||||
];
|
||||
const missingSemanticTools = requiredSemanticTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
if (missingSemanticTools.length > 0) {
|
||||
debugLogger.warn(
|
||||
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
|
||||
'Some browser interactions may not work correctly.',
|
||||
);
|
||||
}
|
||||
|
||||
// Only click_at is strictly required — text input can use press_key or fill.
|
||||
const requiredVisualTools = ['click_at'];
|
||||
const missingVisualTools = requiredVisualTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
// Check whether vision can be enabled; returns undefined if all gates pass.
|
||||
function getVisionDisabledReason(): string | undefined {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig.customConfig.visualModel) {
|
||||
return 'No visualModel configured.';
|
||||
}
|
||||
if (missingVisualTools.length > 0) {
|
||||
return (
|
||||
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
|
||||
`The installed chrome-devtools-mcp version may be too old.`
|
||||
);
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const blockedAuthTypes = new Set([
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.LEGACY_CLOUD_SHELL,
|
||||
AuthType.COMPUTE_ADC,
|
||||
]);
|
||||
if (authType && blockedAuthTypes.has(authType)) {
|
||||
return 'Visual agent model not available for current auth type.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = [...mcpTools];
|
||||
const visionDisabledReason = getVisionDisabledReason();
|
||||
|
||||
if (visionDisabledReason) {
|
||||
debugLogger.log(`Vision disabled: ${visionDisabledReason}`);
|
||||
} else {
|
||||
allTools.push(
|
||||
createAnalyzeScreenshotTool(browserManager, config, messageBus),
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Created ${allTools.length} tools for browser agent: ` +
|
||||
allTools.map((t) => t.name).join(', '),
|
||||
);
|
||||
|
||||
// Create configured definition with tools
|
||||
// BrowserAgentDefinition is a factory function - call it with config
|
||||
const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
|
||||
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
|
||||
...baseDefinition,
|
||||
toolConfig: {
|
||||
tools: allTools,
|
||||
},
|
||||
};
|
||||
|
||||
return { definition, browserManager };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up browser resources after agent execution.
|
||||
*
|
||||
* @param browserManager The browser manager to clean up
|
||||
*/
|
||||
export async function cleanupBrowserAgent(
|
||||
browserManager: BrowserManager,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.close();
|
||||
debugLogger.log('Browser agent cleanup complete');
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user