Compare commits

...

27 Commits

Author SHA1 Message Date
Christine Betts efd26bc660 Fix .env directory extension issue 2026-02-24 11:41:05 -05:00
Jerop Kipruto 15f6c8b8da feat(policy): Propagate Tool Annotations for MCP Servers (#20083) 2026-02-24 14:20:11 +00:00
Kiryl Dubarenka ee2e947435 feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection (#15842)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-02-24 13:35:25 +00:00
Jacob Richman 6676546a4b fix punycode2 (#20154) 2026-02-24 09:43:22 +00:00
Keith Guerin d143a83d5b refactor(ui): Update and simplify use of gray colors in themes (#20141) 2026-02-24 09:21:10 +00:00
Keith Guerin e69e23e4a0 Shortcuts: Move SectionHeader title below top line and refine styling (#18721) 2026-02-24 08:12:29 +00:00
Tommaso Sciortino 81cd2561dc ci(actions): fix PR rate limiter excluding maintainers (#20117) 2026-02-24 03:57:55 +00:00
Steven Robertson b0ceb74462 feat: implement AfterTool tail tool calls (#18486) 2026-02-24 03:57:00 +00:00
nityam ee5eb70070 fix(cli): Remove unsafe type assertions in activityLogger #19713 (#19745) 2026-02-24 03:54:09 +00:00
Rishabh Khandelwal dde844dbe1 feat(security): Introduce Conseca framework (#13193) 2026-02-24 02:44:28 +00:00
Yuki Okita 05bc0399f3 feat(cli): allow expanding full details of MCP tool on approval (#19916) 2026-02-24 01:45:05 +00:00
Jagjeevan Kashid 3409de774c feat:PR-rate-limit (#19804)
Signed-off-by: Jagjeevan Kashid <jagjeevandev97@gmail.com>
Co-authored-by: kevinjwang1 <kevinjwang@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Christian Gunderman <gundermanc@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-24 00:42:07 +00:00
Christian Gunderman 175ffc452b Add 3.1 pro preview to behavioral evals. (#20088) 2026-02-24 00:34:26 +00:00
Tommaso Sciortino 544df749af make windows tests mandatory (#20096) 2026-02-24 00:06:14 +00:00
Christian Gunderman 56c8d7e985 Stabilize tests. (#20095) 2026-02-24 00:01:39 +00:00
kevinjwang1 2ff7738b5d Add new setting to configure maxRetries (#20064)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-23 23:57:16 +00:00
Tommaso Sciortino 1ad26adb2b fix critical dep vulnerability (#20087) 2026-02-23 23:36:35 +00:00
nityam af5aec69da Fix: Handle corrupted token file gracefully when switching auth types (#19845) (#19850) 2026-02-23 23:15:54 +00:00
nityam dae67983a8 fix(a2a-server): Remove unsafe type assertions in agent (#19723) 2026-02-23 22:40:55 +00:00
Zafeer Mahmood 70856d5a6e fix(scripts): Add Windows (win32/x64) support to lint.js (#16193)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-23 22:36:23 +00:00
Abhijit Balaji cec45a1ebc fix(cli): skip workspace policy loading when in home directory (#20054) 2026-02-23 22:08:56 +00:00
Adam Weidman 767d80e768 fix(core): prevent utility calls from changing session active model (#20035) 2026-02-23 21:54:02 +00:00
Jerop Kipruto 3e5e608a22 feat(policy): Implement Tool Annotation Matching in Policy Engine (#20029) 2026-02-23 21:39:40 +00:00
Gal Zahavi 0bc2d3ab16 fix(core): allow environment variable expansion and explicit overrides for MCP servers (#18837) 2026-02-23 21:35:01 +00:00
Aviral Garg 31960c3388 fix(sandbox): harden image packaging integrity checks (#19552) 2026-02-23 21:02:42 +00:00
Sandy Tao 0cc4f09595 feat(core): replace expected_replacements with allow_multiple in replace tool (#20033) 2026-02-23 19:53:58 +00:00
Michael Bleigh 70336e73b1 feat(core): implement experimental direct web fetch (#19557) 2026-02-23 19:50:14 +00:00
159 changed files with 5996 additions and 1054 deletions
+8
View File
@@ -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' }}"
+1 -1
View File
@@ -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
;;
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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."
+1
View File
@@ -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'
+29
View File
@@ -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
View File
@@ -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
+22 -6
View File
@@ -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
View File
@@ -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
+38 -14
View File
@@ -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
+8
View File
@@ -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.**
+14
View File
@@ -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:**
+23
View File
@@ -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`
@@ -868,6 +873,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 +982,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 +1274,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.
+4
View File
@@ -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)'
+7 -4
View File
@@ -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
+62 -2
View File
@@ -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
View File
@@ -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(
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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}]}]}
+107
View File
@@ -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
+382 -428
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -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,
+8 -2
View File
@@ -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[];
+99 -41
View File
@@ -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);
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
+51 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --no-warnings=DEP0040
/**
* @license
+34
View File
@@ -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();
+3 -1
View File
@@ -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,
});
}
@@ -530,6 +530,7 @@ Would you like to attempt to install via "git clone" instead?`,
return this.loadedExtensions;
}
for (const subdir of fs.readdirSync(extensionsDir)) {
if (subdir === '.env') continue;
const extensionDir = path.join(extensionsDir, subdir);
await this.loadExtension(extensionDir);
}
+11
View File
@@ -280,6 +280,17 @@ describe('extension tests', () => {
]);
});
it('should ignore .env directory in extensions folder', async () => {
// Create a .env directory
const envDir = path.join(userExtensionsDir, '.env');
fs.mkdirSync(envDir);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toEqual([]);
const { debugLogger } = await import('@google/gemini-cli-core');
expect(debugLogger.error).not.toHaveBeenCalled();
});
it('should annotate disabled extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@@ -468,6 +468,32 @@ describe('extensionSettings', () => {
expect(mockIsAvailable).toHaveBeenCalled();
expect(mockListSecrets).not.toHaveBeenCalled();
});
it('should throw error if .env is a directory when prompting for settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const envFilePath = path.join(extensionDir, '.env');
if (fs.existsSync(envFilePath)) {
fs.unlinkSync(envFilePath);
}
fs.mkdirSync(envFilePath);
mockRequestSetting.mockResolvedValue('new-value');
await expect(
maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
),
).rejects.toThrow(
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
);
});
});
describe('promptForSetting', () => {
@@ -590,6 +616,23 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});
it('should ignore .env if it is a directory', async () => {
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
if (fs.existsSync(userEnvPath)) {
fs.unlinkSync(userEnvPath);
}
fs.mkdirSync(userEnvPath);
const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
expect(contents).toEqual({});
});
});
describe('getEnvContents (merged)', () => {
@@ -890,5 +933,27 @@ describe('extensionSettings', () => {
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
});
it('should throw error if .env is a directory when updating a non-sensitive setting', async () => {
const envFilePath = path.join(extensionDir, '.env');
if (fs.existsSync(envFilePath)) {
fs.unlinkSync(envFilePath);
}
fs.mkdirSync(envFilePath);
mockRequestSetting.mockResolvedValue('new-value');
await expect(
updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
),
).rejects.toThrow(
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
);
});
});
});
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
const envContent = formatEnvContent(nonSensitiveSettings);
if (
fsSync.existsSync(envFilePath) &&
fsSync.statSync(envFilePath).isDirectory()
) {
throw new Error(
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
);
}
await fs.writeFile(envFilePath, envContent);
}
@@ -172,7 +181,7 @@ export async function getScopedEnvContents(
);
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(envFilePath)) {
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
}
@@ -258,6 +267,16 @@ export async function updateSetting(
// For non-sensitive settings, we need to read the existing .env file,
// update the value, and write it back, preserving any other values.
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
if (
fsSync.existsSync(envFilePath) &&
fsSync.statSync(envFilePath).isDirectory()
) {
throw new Error(
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
);
}
let envContent = '';
if (fsSync.existsSync(envFilePath)) {
envContent = await fs.readFile(envFilePath, 'utf-8');
@@ -323,7 +342,7 @@ async function clearSettings(
envFilePath: string,
keychain: KeychainTokenStorage,
) {
if (fsSync.existsSync(envFilePath)) {
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
await fs.writeFile(envFilePath, '');
}
if (!(await keychain.isAvailable())) {
@@ -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) => {
+44
View File
@@ -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);
}
});
});
+9 -3
View File
@@ -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',
+41 -7
View File
@@ -79,6 +79,7 @@ import {
import {
FatalConfigError,
GEMINI_DIR,
Storage,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -126,6 +127,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 +1516,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();
}
});
});
+5 -21
View File
@@ -637,24 +637,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 +696,7 @@ export function loadSettings(
settings: {} as Settings,
rawJson: undefined,
};
if (realWorkspaceDir !== realHomeDir) {
if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
@@ -800,11 +784,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,
+30
View File
@@ -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',
@@ -1483,6 +1493,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 +1713,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,
},
},
},
+5 -3
View File
@@ -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);
},
});
});
+6
View File
@@ -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;
},
@@ -96,6 +96,8 @@ describe('<Header />', () => {
},
background: {
primary: '',
message: '',
input: '',
diff: { added: '', removed: '' },
},
border: {
+2 -13
View File
@@ -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>
@@ -57,6 +57,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
config,
progressMessage,
progressPercent,
originalRequestName,
}) => {
const isThisShellFocused = checkIsShellFocused(
name,
@@ -93,6 +94,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
emphasis={emphasis}
progressMessage={progressMessage}
progressPercent={progressPercent}
originalRequestName={originalRequestName}
/>
<FocusHint
shouldShowFocusHint={shouldShowFocusHint}
@@ -189,6 +189,7 @@ type ToolInfoProps = {
emphasis: TextEmphasis;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
};
export const ToolInfo: React.FC<ToolInfoProps> = ({
@@ -198,6 +199,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
emphasis,
progressMessage,
progressPercent,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo<string>(() => {
@@ -242,6 +244,12 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
<Text color={nameColor} bold>
{name}
</Text>
{originalRequestName && originalRequestName !== name && (
<Text color={theme.text.secondary} italic>
{' '}
(redirection from {originalRequestName})
</Text>
)}
{!isCompletedAskUser && (
<>
{' '}
@@ -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
@@ -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
"
`;
+1 -1
View File
@@ -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/';
@@ -275,5 +275,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');
});
});
});
+1
View File
@@ -107,6 +107,7 @@ export function mapToDisplay(
progressMessage,
progressPercent,
approvalMode: call.approvalMode,
originalRequestName: call.request.originalRequestName,
};
});
@@ -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();
});
});
+26 -2
View File
@@ -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;
});
}
-1
View File
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
createKey('l', { ctrl: true }),
],
},
// Shell commands
{
command: Command.REVERSE_SEARCH,
+4
View File
@@ -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',
+25 -12
View File
@@ -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 {
+7 -7
View File
@@ -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');
});
});
+61 -17
View File
@@ -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: {
+1
View File
@@ -110,6 +110,7 @@ export interface IndividualToolCallDisplay {
approvalMode?: ApprovalMode;
progressMessage?: string;
progressPercent?: number;
originalRequestName?: string;
}
export interface CompressionProps {
+130 -26
View File
@@ -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) {
+2
View File
@@ -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",
@@ -47,7 +47,10 @@ describe('Fallback Integration', () => {
const requestedModel = PREVIEW_GEMINI_MODEL;
// 3. Apply model selection
const result = applyModelSelection(config, { model: requestedModel });
const result = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
// 4. Expect fallback to Flash
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
@@ -222,7 +222,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-pro',
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(result.model).toBe('gemini-pro');
expect(result.maxAttempts).toBeUndefined();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
@@ -243,7 +246,10 @@ describe('policyHelpers', () => {
selectedModel: 'gemini-flash',
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(result.model).toBe('gemini-flash');
expect(result.config).toEqual({
@@ -253,14 +259,33 @@ describe('policyHelpers', () => {
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-pro',
isChatModel: true,
});
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
model: 'gemini-flash',
isChatModel: true,
});
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-flash');
});
it('consumes sticky attempt if indicated', () => {
it('does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
});
applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(config.setActiveModel).not.toHaveBeenCalled();
});
it('consumes sticky attempt if indicated and isChatModel is true', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
@@ -271,10 +296,36 @@ describe('policyHelpers', () => {
attempts: 1,
});
const result = applyModelSelection(config, { model: 'gemini-pro' });
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: true,
});
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
it('consumes sticky attempt if indicated but does not call setActiveModel if isChatModel is false', () => {
const config = createExtendedMockConfig();
mockModelConfigService.getResolvedConfig.mockReturnValue({
model: 'gemini-pro',
generateContentConfig: {},
});
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
selectedModel: 'gemini-pro',
attempts: 1,
});
const result = applyModelSelection(config, {
model: 'gemini-pro',
isChatModel: false,
});
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
'gemini-pro',
);
expect(config.setActiveModel).not.toHaveBeenCalled();
expect(result.maxAttempts).toBe(1);
});
@@ -291,7 +342,7 @@ describe('policyHelpers', () => {
const result = applyModelSelection(
config,
{ model: 'gemini-pro' },
{ model: 'gemini-pro', isChatModel: true },
{
consumeAttempt: false,
},
@@ -299,6 +350,7 @@ describe('policyHelpers', () => {
expect(
mockAvailabilityService.consumeStickyAttempt,
).not.toHaveBeenCalled();
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
expect(result.maxAttempts).toBe(1);
});
});
@@ -214,7 +214,9 @@ export function applyModelSelection(
generateContentConfig = fallbackResolved.generateContentConfig;
}
config.setActiveModel(finalModel);
if (modelConfigKey.isChatModel) {
config.setActiveModel(finalModel);
}
if (selection.attempts && options.consumeAttempt !== false) {
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
@@ -28,6 +28,11 @@ vi.mock('node:fs', () => ({
readFile: vi.fn(),
rm: vi.fn(),
},
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
vi.mock('node:os');
vi.mock('node:path');
+24
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ApprovalMode } from '../policy/types.js';
@@ -259,6 +260,29 @@ describe('Server Config (config.ts)', () => {
usageStatisticsEnabled: false,
};
describe('maxAttempts', () => {
it('should default to DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config(baseParams);
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
it('should use provided maxAttempts if <= DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 5,
});
expect(config.getMaxAttempts()).toBe(5);
});
it('should cap maxAttempts at DEFAULT_MAX_ATTEMPTS', () => {
const config = new Config({
...baseParams,
maxAttempts: 20,
});
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
});
});
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
+55 -6
View File
@@ -132,6 +132,11 @@ import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
import { CheckerRunner } from '../safety/checker-runner.js';
import { ContextBuilder } from '../safety/context-builder.js';
import { CheckerRegistry } from '../safety/registry.js';
import { ConsecaSafetyChecker } from '../safety/conseca/conseca.js';
export interface AccessibilitySettings {
/** @deprecated Use ui.loadingPhrases instead. */
enableLoadingPhrases?: boolean;
@@ -291,6 +296,7 @@ export interface ExtensionInstallMetadata {
allowPreRelease?: boolean;
}
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import type { FileFilteringOptions } from './constants.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -470,11 +476,13 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
directWebFetch?: boolean;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
retryFetchErrors?: boolean;
maxAttempts?: number;
enableShellOutputEfficiency?: boolean;
shellToolInactivityTimeout?: number;
fakeResponses?: string;
@@ -510,6 +518,7 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
agents?: AgentSettings;
}>;
enableConseca?: boolean;
}
export class Config {
@@ -537,6 +546,7 @@ export class Config {
private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean;
private readonly question: string | undefined;
readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined;
/** @deprecated Use Policy Engine instead */
@@ -633,6 +643,7 @@ export class Config {
readonly interactive: boolean;
private readonly ptyInfo: string;
private readonly trustedFolder: boolean | undefined;
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
@@ -655,6 +666,7 @@ export class Config {
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
private readonly enableShellOutputEfficiency: boolean;
private readonly shellToolInactivityTimeout: number;
readonly fakeResponses?: string;
@@ -826,6 +838,7 @@ export class Config {
this.interactive = params.interactive ?? false;
this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder;
this.directWebFetch = params.directWebFetch ?? false;
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
@@ -862,13 +875,35 @@ export class Config {
this.recordResponses = params.recordResponses;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.policyEngine = new PolicyEngine({
...params.policyEngineConfig,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
this.enableConseca = params.enableConseca ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
const checkersPath = this.targetDir;
// The checkersPath is used to resolve external checkers. Since we do not have any external checkers currently, it is set to the targetDir.
const checkerRegistry = new CheckerRegistry(checkersPath);
const checkerRunner = new CheckerRunner(contextBuilder, checkerRegistry, {
checkersPath,
timeout: 30000, // 30 seconds to allow for LLM-based checkers
});
this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest;
this.policyEngine = new PolicyEngine(
{
...params.policyEngineConfig,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
},
checkerRunner,
);
// Register Conseca if enabled
if (this.enableConseca) {
debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
ConsecaSafetyChecker.getInstance().setConfig(this);
}
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
this.skillManager = new SkillManager();
@@ -876,6 +911,10 @@ export class Config {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS,
);
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -1558,7 +1597,9 @@ export class Config {
*
* May change over time.
*/
getExcludeTools(): Set<string> | undefined {
getExcludeTools(
toolMetadata?: Map<string, Record<string, unknown>>,
): Set<string> | undefined {
// Right now this is present for backward compatibility with settings.json exclude
const excludeToolsSet = new Set([...(this.excludeTools ?? [])]);
for (const extension of this.getExtensionLoader().getExtensions()) {
@@ -1570,7 +1611,7 @@ export class Config {
}
}
const policyExclusions = this.policyEngine.getExcludedTools();
const policyExclusions = this.policyEngine.getExcludedTools(toolMetadata);
for (const tool of policyExclusions) {
excludeToolsSet.add(tool);
}
@@ -2085,6 +2126,10 @@ export class Config {
return this.approvedPlanPath;
}
getDirectWebFetch(): boolean {
return this.directWebFetch;
}
setApprovedPlanPath(path: string | undefined): void {
this.approvedPlanPath = path;
}
@@ -2408,6 +2453,10 @@ export class Config {
return this.retryFetchErrors;
}
getMaxAttempts(): number {
return this.maxAttempts;
}
getEnableShellOutputEfficiency(): boolean {
return this.enableShellOutputEfficiency;
}
+12
View File
@@ -14,6 +14,7 @@ import {
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -142,6 +143,17 @@ export class Storage {
return path.join(this.targetDir, GEMINI_DIR);
}
/**
* Checks if the current workspace storage location is the same as the global/user storage location.
* This handles symlinks and platform-specific path normalization.
*/
isWorkspaceHomeDir(): boolean {
return (
normalizePath(resolveToRealPath(this.targetDir)) ===
normalizePath(resolveToRealPath(homedir()))
);
}
getAgentsDir(): string {
return path.join(this.targetDir, AGENTS_DIR_NAME);
}
@@ -140,6 +140,29 @@ describe('MessageBus', () => {
expect(requestHandler).toHaveBeenCalledWith(request);
});
it('should forward toolAnnotations to policyEngine.check', async () => {
const checkSpy = vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
const annotations = { readOnlyHint: true };
const request: ToolConfirmationRequest = {
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { name: 'test-tool', args: {} },
correlationId: '123',
serverName: 'test-server',
toolAnnotations: annotations,
};
await messageBus.publish(request);
expect(checkSpy).toHaveBeenCalledWith(
{ name: 'test-tool', args: {} },
'test-server',
annotations,
);
});
it('should emit other message types directly', async () => {
const successHandler = vi.fn();
messageBus.subscribe(
@@ -55,6 +55,7 @@ export class MessageBus extends EventEmitter {
const { decision } = await this.policyEngine.check(
message.toolCall,
message.serverName,
message.toolAnnotations,
);
switch (decision) {
@@ -34,6 +34,10 @@ export interface ToolConfirmationRequest {
toolCall: FunctionCall;
correlationId: string;
serverName?: string;
/**
* Optional tool annotations (e.g., readOnlyHint, destructiveHint) from MCP.
*/
toolAnnotations?: Record<string, unknown>;
/**
* Optional rich details for the confirmation UI (diffs, counts, etc.)
*/
@@ -95,6 +99,9 @@ export type SerializableConfirmationDetails =
serverName: string;
toolName: string;
toolDisplayName: string;
toolArgs?: Record<string, unknown>;
toolDescription?: string;
toolParameterSchema?: unknown;
}
| {
type: 'ask_user';
+47 -25
View File
@@ -641,7 +641,7 @@ describe('BaseLlmClient', () => {
);
contentOptions = {
modelConfigKey: { model: 'test-model' },
modelConfigKey: { model: 'test-model', isChatModel: false },
contents: [{ role: 'user', parts: [{ text: 'Give me a color.' }] }],
abortSignal: abortController.signal,
promptId: 'content-prompt-id',
@@ -650,12 +650,17 @@ describe('BaseLlmClient', () => {
jsonOptions = {
...defaultOptions,
modelConfigKey: {
...defaultOptions.modelConfigKey,
isChatModel: true,
},
promptId: 'json-prompt-id',
};
});
it('should mark model as healthy on success', async () => {
const successfulModel = 'gemini-pro';
mockConfig.getActiveModel.mockReturnValue(successfulModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: successfulModel,
skipped: [],
@@ -666,7 +671,7 @@ describe('BaseLlmClient', () => {
await client.generateContent({
...contentOptions,
modelConfigKey: { model: successfulModel },
modelConfigKey: { model: successfulModel, isChatModel: false },
role: LlmRole.UTILITY_TOOL,
});
@@ -678,44 +683,55 @@ describe('BaseLlmClient', () => {
it('marks the final attempted model healthy after a retry with availability enabled', async () => {
const firstModel = 'gemini-pro';
const fallbackModel = 'gemini-flash';
let activeModel = firstModel;
mockConfig.getActiveModel.mockImplementation(() => activeModel);
mockConfig.setActiveModel.mockImplementation((m) => {
activeModel = m;
});
vi.mocked(mockAvailabilityService.selectFirstAvailable)
.mockReturnValueOnce({ selectedModel: firstModel, skipped: [] })
.mockReturnValueOnce({ selectedModel: fallbackModel, skipped: [] });
// Mock generateContent to fail once and then succeed
mockGenerateContent
.mockResolvedValueOnce(createMockResponse('retry-me'))
.mockResolvedValueOnce(createMockResponse(''))
.mockResolvedValueOnce(createMockResponse('final-response'));
// Run the real retryWithBackoff (with fake timers) to exercise the retry path
vi.useFakeTimers();
// 1. First call starts. applyModelSelection(firstModel) -> currentModel = firstModel.
// 2. apiCall() runs. getActiveModel() === firstModel. call(firstModel). returns ''.
// 3. retry triggers.
// 4. Second call starts. applyModelSelection(firstModel).
// selectFirstAvailable -> fallbackModel.
// setActiveModel(fallbackModel) -> activeModel = fallbackModel.
// returns fallbackModel.
// 5. apiCall() runs. getActiveModel() === fallbackModel. call(fallbackModel). returns 'final-response'.
const retryPromise = client.generateContent({
vi.mocked(retryWithBackoff).mockImplementation(async (fn) => {
// First call
let res = (await fn()) as GenerateContentResponse;
if (res.candidates?.[0]?.content?.parts?.[0]?.text === '') {
// Second call
activeModel = fallbackModel;
mockConfig.setActiveModel(fallbackModel);
res = (await fn()) as GenerateContentResponse;
}
mockAvailabilityService.markHealthy(activeModel);
return res;
});
const result = await client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel },
modelConfigKey: { model: firstModel, isChatModel: true },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
await vi.runAllTimersAsync();
await retryPromise;
await client.generateContent({
...contentOptions,
modelConfigKey: { model: firstModel },
maxAttempts: 2,
role: LlmRole.UTILITY_TOOL,
});
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(firstModel);
expect(result).toEqual(createMockResponse('final-response'));
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(fallbackModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
fallbackModel,
);
expect(mockGenerateContent).toHaveBeenLastCalledWith(
expect.objectContaining({ model: fallbackModel }),
expect.any(String),
LlmRole.UTILITY_TOOL,
);
});
it('should consume sticky attempt if selection has attempts', async () => {
@@ -754,6 +770,7 @@ describe('BaseLlmClient', () => {
it('should mark healthy and honor availability selection when using generateJson', async () => {
const availableModel = 'gemini-json-pro';
mockConfig.getActiveModel.mockReturnValue(availableModel);
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
selectedModel: availableModel,
skipped: [],
@@ -770,10 +787,15 @@ describe('BaseLlmClient', () => {
return result;
});
const result = await client.generateJson(jsonOptions);
const result = await client.generateJson({
...jsonOptions,
modelConfigKey: {
...jsonOptions.modelConfigKey,
isChatModel: false,
},
});
expect(result).toEqual({ color: 'violet' });
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(availableModel);
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
availableModel,
);
+6 -3
View File
@@ -280,19 +280,22 @@ export class BaseLlmClient {
() => currentModel,
);
let initialActiveModel = this.config.getActiveModel();
try {
const apiCall = () => {
// Ensure we use the current active model
// in case a fallback occurred in a previous attempt.
const activeModel = this.config.getActiveModel();
if (activeModel !== currentModel) {
currentModel = activeModel;
if (activeModel !== initialActiveModel) {
initialActiveModel = activeModel;
// Re-resolve config if model changed during retry
const { generateContentConfig } =
const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: activeModel,
});
currentModel = resolvedModel;
currentGenerateContentConfig = generateContentConfig;
}
const finalConfig: GenerateContentConfig = {
+11 -7
View File
@@ -957,17 +957,21 @@ export class GeminiClient {
() => currentAttemptModel,
);
let initialActiveModel = this.config.getActiveModel();
const apiCall = () => {
// AvailabilityService
const active = this.config.getActiveModel();
if (active !== currentAttemptModel) {
currentAttemptModel = active;
if (active !== initialActiveModel) {
initialActiveModel = active;
// Re-resolve config if model changed
const newConfig = this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: currentAttemptModel,
});
currentAttemptGenerateContentConfig = newConfig.generateContentConfig;
const { model: resolvedModel, generateContentConfig } =
this.config.modelConfigService.getResolvedConfig({
...modelConfigKey,
model: active,
});
currentAttemptModel = resolvedModel;
currentAttemptGenerateContentConfig = generateContentConfig;
}
const requestConfig: GenerateContentConfig = {
@@ -75,6 +75,7 @@ export async function executeToolWithHooks(
shellExecutionConfig?: ShellExecutionConfig,
setPidCallback?: (pid: number) => void,
config?: Config,
originalRequestName?: string,
): Promise<ToolResult> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolInput = (invocation.params || {}) as Record<string, unknown>;
@@ -90,6 +91,7 @@ export async function executeToolWithHooks(
toolName,
toolInput,
mcpContext,
originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -196,6 +198,7 @@ export async function executeToolWithHooks(
error: toolResult.error,
},
mcpContext,
originalRequestName,
);
// Check if hook requested to stop entire agent execution
@@ -242,6 +245,12 @@ export async function executeToolWithHooks(
toolResult.llmContent = wrappedContext;
}
}
// Check if the hook requested a tail tool call
const tailToolCallRequest = afterOutput?.getTailToolCallRequest();
if (tailToolCallRequest) {
toolResult.tailToolCallRequest = tailToolCallRequest;
}
}
return toolResult;
@@ -1926,13 +1926,14 @@ describe('CoreToolScheduler Sequential Execution', () => {
isModifiableSpy.mockRestore();
});
it('should pass serverName to policy engine for DiscoveredMCPTool', async () => {
it('should pass serverName and toolAnnotations to policy engine for DiscoveredMCPTool', async () => {
const mockMcpTool = {
tool: async () => ({ functionDeclarations: [] }),
callTool: async () => [],
};
const serverName = 'test-server';
const toolName = 'test-tool';
const annotations = { readOnlyHint: true };
const mcpTool = new DiscoveredMCPTool(
mockMcpTool as unknown as CallableTool,
serverName,
@@ -1940,6 +1941,13 @@ describe('CoreToolScheduler Sequential Execution', () => {
'description',
{ type: 'object', properties: {} },
createMockMessageBus() as unknown as MessageBus,
undefined, // trust
true, // isReadOnly
undefined, // nameOverride
undefined, // cliConfig
undefined, // extensionName
undefined, // extensionId
annotations, // toolAnnotations
);
const mockToolRegistry = {
@@ -1989,6 +1997,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
expect.objectContaining({ name: toolName }),
serverName,
annotations,
);
});
+2 -1
View File
@@ -624,10 +624,11 @@ export class CoreToolScheduler {
toolCall.tool instanceof DiscoveredMCPTool
? toolCall.tool.serverName
: undefined;
const toolAnnotations = toolCall.tool.toolAnnotations;
const { decision, rule } = await this.config
.getPolicyEngine()
.check(toolCallForPolicy, serverName);
.check(toolCallForPolicy, serverName, toolAnnotations);
if (decision === PolicyDecision.DENY) {
const { errorMessage, errorType } = getPolicyDenialError(
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getMaxAttempts: vi.fn().mockReturnValue(10),
getUserTier: vi.fn().mockReturnValue(undefined),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
+3 -7
View File
@@ -18,11 +18,7 @@ import type {
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
import { createUserContent, FinishReason } from '@google/genai';
import {
retryWithBackoff,
isRetryableError,
DEFAULT_MAX_ATTEMPTS,
} from '../utils/retry.js';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
import {
@@ -635,12 +631,12 @@ export class GeminiChat {
authType: this.config.getContentGeneratorConfig()?.authType,
retryFetchErrors: this.config.getRetryFetchErrors(),
signal: abortSignal,
maxAttempts: availabilityMaxAttempts,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
getAvailabilityContext,
onRetry: (attempt, error, delayMs) => {
coreEvents.emitRetryAttempt({
attempt,
maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
delayMs,
error: error instanceof Error ? error.message : String(error),
model: lastModelToUse,
@@ -94,6 +94,7 @@ describe('GeminiChat Network Retries', () => {
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
getRetryFetchErrors: vi.fn().mockReturnValue(false), // Default false
getMaxAttempts: vi.fn().mockReturnValue(10),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({
model: modelConfigKey.model,
@@ -22,6 +22,11 @@ import { LlmRole } from '../telemetry/types.js';
vi.mock('node:fs', () => ({
appendFileSync: vi.fn(),
createWriteStream: vi.fn(() => ({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
})),
}));
describe('RecordingContentGenerator', () => {
@@ -76,12 +76,16 @@ export class HookEventHandler {
toolName: string,
toolInput: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<AggregatedHookResult> {
const input: BeforeToolInput = {
...this.createBaseInput(HookEventName.BeforeTool),
tool_name: toolName,
tool_input: toolInput,
...(mcpContext && { mcp_context: mcpContext }),
...(originalRequestName && {
original_request_name: originalRequestName,
}),
};
const context: HookEventContext = { toolName };
@@ -97,6 +101,7 @@ export class HookEventHandler {
toolInput: Record<string, unknown>,
toolResponse: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<AggregatedHookResult> {
const input: AfterToolInput = {
...this.createBaseInput(HookEventName.AfterTool),
@@ -104,6 +109,9 @@ export class HookEventHandler {
tool_input: toolInput,
tool_response: toolResponse,
...(mcpContext && { mcp_context: mcpContext }),
...(originalRequestName && {
original_request_name: originalRequestName,
}),
};
const context: HookEventContext = { toolName };
+4
View File
@@ -368,12 +368,14 @@ export class HookSystem {
toolName: string,
toolInput: Record<string, unknown>,
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<DefaultHookOutput | undefined> {
try {
const result = await this.hookEventHandler.fireBeforeToolEvent(
toolName,
toolInput,
mcpContext,
originalRequestName,
);
return result.finalOutput;
} catch (error) {
@@ -391,6 +393,7 @@ export class HookSystem {
error: unknown;
},
mcpContext?: McpToolContext,
originalRequestName?: string,
): Promise<DefaultHookOutput | undefined> {
try {
const result = await this.hookEventHandler.fireAfterToolEvent(
@@ -398,6 +401,7 @@ export class HookSystem {
toolInput,
toolResponse as Record<string, unknown>,
mcpContext,
originalRequestName,
);
return result.finalOutput;
} catch (error) {
+37
View File
@@ -253,6 +253,33 @@ export class DefaultHookOutput implements HookOutput {
shouldClearContext(): boolean {
return false;
}
/**
* Optional request to execute another tool immediately after this one.
* The result of this tail call will replace the original tool's response.
*/
getTailToolCallRequest():
| {
name: string;
args: Record<string, unknown>;
}
| undefined {
if (
this.hookSpecificOutput &&
'tailToolCallRequest' in this.hookSpecificOutput
) {
const request = this.hookSpecificOutput['tailToolCallRequest'];
if (
typeof request === 'object' &&
request !== null &&
!Array.isArray(request)
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return request as { name: string; args: Record<string, unknown> };
}
}
return undefined;
}
}
/**
@@ -430,6 +457,7 @@ export interface BeforeToolInput extends HookInput {
tool_name: string;
tool_input: Record<string, unknown>;
mcp_context?: McpToolContext; // Only present for MCP tools
original_request_name?: string;
}
/**
@@ -450,6 +478,7 @@ export interface AfterToolInput extends HookInput {
tool_input: Record<string, unknown>;
tool_response: Record<string, unknown>;
mcp_context?: McpToolContext; // Only present for MCP tools
original_request_name?: string;
}
/**
@@ -459,6 +488,14 @@ export interface AfterToolOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'AfterTool';
additionalContext?: string;
/**
* Optional request to execute another tool immediately after this one.
* The result of this tail call will replace the original tool's response.
*/
tailToolCallRequest?: {
name: string;
args: Record<string, unknown>;
};
};
}
@@ -34,6 +34,49 @@ describe('getIdeProcessInfo', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
describe('GEMINI_CLI_IDE_PID override', () => {
it('should use GEMINI_CLI_IDE_PID and fetch command on Unix', async () => {
(os.platform as Mock).mockReturnValue('linux');
vi.stubEnv('GEMINI_CLI_IDE_PID', '12345');
mockedExec.mockResolvedValueOnce({ stdout: '0 my-ide-command' }); // getProcessInfo result
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 12345, command: 'my-ide-command' });
expect(mockedExec).toHaveBeenCalledWith(
expect.stringContaining('ps -o ppid=,command= -p 12345'),
);
});
it('should use GEMINI_CLI_IDE_PID and fetch command on Windows', async () => {
(os.platform as Mock).mockReturnValue('win32');
vi.stubEnv('GEMINI_CLI_IDE_PID', '54321');
const processes = [
{
ProcessId: 54321,
ParentProcessId: 0,
Name: 'Code.exe',
CommandLine: 'C:\\Program Files\\VSCode\\Code.exe',
},
];
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
const result = await getIdeProcessInfo();
expect(result).toEqual({
pid: 54321,
command: 'C:\\Program Files\\VSCode\\Code.exe',
});
expect(mockedExec).toHaveBeenCalledWith(
expect.stringContaining(
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine',
),
expect.anything(),
);
});
});
describe('on Unix', () => {
+20
View File
@@ -208,6 +208,13 @@ async function getIdeProcessInfoForWindows(): Promise<{
* to identify the main application process (e.g., the main VS Code window
* process).
*
* This function can be overridden by setting the `GEMINI_CLI_IDE_PID`
* environment variable. This is useful for launching Gemini CLI in a
* standalone terminal while still connecting to an IDE instance.
*
* If `GEMINI_CLI_IDE_PID` is set, the function uses that PID and fetches
* the command for it.
*
* If the IDE process cannot be reliably identified, it will return the
* top-level ancestor process ID and command as a fallback.
*
@@ -219,6 +226,19 @@ export async function getIdeProcessInfo(): Promise<{
}> {
const platform = os.platform();
if (process.env['GEMINI_CLI_IDE_PID']) {
const idePid = parseInt(process.env['GEMINI_CLI_IDE_PID'], 10);
if (!isNaN(idePid) && idePid > 0) {
if (platform === 'win32') {
const processMap = await getProcessTableWindows();
const proc = processMap.get(idePid);
return { pid: idePid, command: proc?.command || '' };
}
const { command } = await getProcessInfo(idePid);
return { pid: idePid, command };
}
}
if (platform === 'win32') {
return getIdeProcessInfoForWindows();
}
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
rename: vi.fn(),
},
}));
@@ -38,6 +39,7 @@ describe('FileTokenStorage', () => {
writeFile: ReturnType<typeof vi.fn>;
unlink: ReturnType<typeof vi.fn>;
mkdir: ReturnType<typeof vi.fn>;
rename: ReturnType<typeof vi.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
@@ -105,12 +107,48 @@ describe('FileTokenStorage', () => {
expect(result).toEqual(credentials);
});
it('should throw error for corrupted files', async () => {
it('should throw error with file path when file is corrupted', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
await expect(storage.getCredentials('test-server')).rejects.toThrow(
'Token file corrupted',
);
try {
await storage.getCredentials('test-server');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('auth type switching', () => {
it('should throw error when trying to save credentials with corrupted file', async () => {
// Simulate corrupted file on first read
mockFs.readFile.mockResolvedValue('corrupted-data');
// Try to save new credentials (simulating switch from OAuth to API key)
const newCredentials: OAuthCredentials = {
serverName: 'new-auth-server',
token: {
accessToken: 'new-api-key',
tokenType: 'ApiKey',
},
updatedAt: Date.now(),
};
// Should throw error with file path
try {
await storage.setCredentials(newCredentials);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});

Some files were not shown because too many files have changed in this diff Show More