mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e5fc1663 | |||
| 3815de798f | |||
| 50947c57ce | |||
| ac3c643035 | |||
| 29e8f2abf4 | |||
| bf278ef2b0 | |||
| 1f9da6723f | |||
| 3ff5cfaaf6 | |||
| 70b650122f | |||
| 16d3883642 | |||
| 5c23f7f6e0 | |||
| d47d4855db | |||
| 6510347d5b | |||
| 4dd940f8ce | |||
| 87f7f0beb7 | |||
| 8e9ce3f4c3 | |||
| 812794a471 | |||
| 3c6539a32c | |||
| e4204d5939 | |||
| baccda969d | |||
| 9e95b8b3c5 | |||
| 182c858e67 | |||
| c0b76af442 | |||
| 4efdbe9089 | |||
| 15f6c8b8da | |||
| ee2e947435 | |||
| 6676546a4b | |||
| d143a83d5b | |||
| e69e23e4a0 | |||
| 81cd2561dc | |||
| b0ceb74462 | |||
| ee5eb70070 | |||
| dde844dbe1 | |||
| 05bc0399f3 | |||
| 3409de774c | |||
| 175ffc452b | |||
| 544df749af | |||
| 56c8d7e985 | |||
| 2ff7738b5d | |||
| 1ad26adb2b | |||
| af5aec69da | |||
| dae67983a8 | |||
| 70856d5a6e | |||
| cec45a1ebc | |||
| 767d80e768 | |||
| 3e5e608a22 | |||
| 0bc2d3ab16 | |||
| 31960c3388 | |||
| 0cc4f09595 | |||
| 70336e73b1 | |||
| 7cfbb6fb71 | |||
| a105768de8 | |||
| 347f3fe7e4 | |||
| 25803e05fd | |||
| 774ae220be | |||
| 813e0c18ac | |||
| 3f6cec22e6 | |||
| 3966f3c053 | |||
| 2e3cbd6363 | |||
| 8b1dc15182 | |||
| fa9aee2bf0 | |||
| 6628cbb39d | |||
| aa9163da60 | |||
| ec0f23ae03 | |||
| ac04c388e0 | |||
| 621ddbe744 | |||
| c537fd5aec | |||
| a91bc60e18 | |||
| faa1ec3044 | |||
| d96bd05d36 | |||
| 84666e1bbc | |||
| a7d851146a | |||
| acb7f577de | |||
| d2d345f41a | |||
| dfd7721e69 | |||
| 09218572d0 | |||
| 5d98ed5820 | |||
| 727f9b67b1 | |||
| 547f5d45f5 | |||
| 58d637f919 | |||
| b746524a1b | |||
| c5baf39dbd | |||
| b48970da15 | |||
| 9a8e5d3940 | |||
| 089aec8b8d | |||
| b7555ab1e1 | |||
| c04602f209 | |||
| a01d7e9a05 | |||
| 49b2e76ee1 | |||
| aed348a99c | |||
| 7cf4c05c66 | |||
| cdf157e52a | |||
| 723f269df6 | |||
| 239aa0909c | |||
| 6cfd29ef9b | |||
| f97b04cc9a | |||
| 788a40c445 | |||
| ce03156c9f | |||
| fe428936d5 | |||
| d24f10b087 | |||
| d54702185b | |||
| 27b7fc04de | |||
| be03e0619f | |||
| 2bb7aaecd0 | |||
| 0f855fc0c4 | |||
| c7e309efc9 | |||
| 429932c663 | |||
| d08b1efc72 | |||
| c3b52b8206 |
@@ -20,7 +20,7 @@ async function run(cmd) {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,14 @@ runs:
|
||||
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
|
||||
--output-file final_image_uri.txt
|
||||
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
|
||||
- name: 'verify'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc '
|
||||
set -e
|
||||
node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));"
|
||||
/usr/local/share/npm-global/bin/gemini --version >/dev/null
|
||||
'
|
||||
- name: 'publish'
|
||||
shell: 'bash'
|
||||
if: "${{ inputs.dry-run != 'true' }}"
|
||||
|
||||
@@ -22,7 +22,7 @@ get_issue_labels() {
|
||||
# Check cache
|
||||
case "${ISSUE_LABELS_CACHE_FLAT}" in
|
||||
*"|${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}"
|
||||
echo "${suffix%%|*}"
|
||||
return
|
||||
;;
|
||||
|
||||
@@ -224,8 +224,6 @@ jobs:
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -315,6 +313,7 @@ jobs:
|
||||
needs:
|
||||
- 'e2e_linux'
|
||||
- 'e2e_mac'
|
||||
- 'e2e_windows'
|
||||
- 'evals'
|
||||
- 'merge_queue_skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -323,6 +322,7 @@ jobs:
|
||||
run: |
|
||||
if [[ ${{ needs.e2e_linux.result }} != 'success' || \
|
||||
${{ needs.e2e_mac.result }} != 'success' || \
|
||||
${{ needs.e2e_windows.result }} != 'success' || \
|
||||
${{ needs.evals.result }} != 'success' ]]; then
|
||||
echo "One or more E2E jobs failed."
|
||||
exit 1
|
||||
|
||||
@@ -360,7 +360,6 @@ jobs:
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -458,6 +457,7 @@ jobs:
|
||||
- 'link_checker'
|
||||
- 'test_linux'
|
||||
- 'test_mac'
|
||||
- 'test_windows'
|
||||
- 'codeql'
|
||||
- 'bundle_size'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -468,6 +468,7 @@ jobs:
|
||||
(${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \
|
||||
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
|
||||
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
|
||||
(${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \
|
||||
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
|
||||
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
|
||||
echo "One or more CI jobs failed."
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
model:
|
||||
- 'gemini-3.1-pro-preview-customtools'
|
||||
- 'gemini-3-pro-preview'
|
||||
- 'gemini-3-flash-preview'
|
||||
- 'gemini-2.5-pro'
|
||||
|
||||
@@ -155,7 +155,10 @@ jobs:
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
}
|
||||
},
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)"
|
||||
],
|
||||
}
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
@@ -48,6 +48,24 @@ jobs:
|
||||
const repo = context.repo.repo;
|
||||
const MAX_ISSUES_ASSIGNED = 3;
|
||||
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted');
|
||||
|
||||
if (!hasHelpWantedLabel) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: issueNumber,
|
||||
body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for open issues already assigned to the commenter in this repo
|
||||
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
|
||||
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
|
||||
@@ -64,13 +82,6 @@ jobs:
|
||||
return; // exit
|
||||
}
|
||||
|
||||
// Check if the issue is already assigned
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
if (issue.data.assignees.length > 0) {
|
||||
// Comment that it's already assigned
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: 'PR rate limiter'
|
||||
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
|
||||
jobs:
|
||||
limit:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Limit open pull requests per user'
|
||||
uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master
|
||||
with:
|
||||
except-author-associations: 'MEMBER,OWNER,COLLABORATOR'
|
||||
comment-limit: 8
|
||||
comment: >
|
||||
You already have 7 pull requests open. Please work on getting
|
||||
existing PRs merged before opening more.
|
||||
close-limit: 8
|
||||
close: true
|
||||
+5
-6
@@ -372,8 +372,7 @@ specific debug settings.
|
||||
|
||||
### React DevTools
|
||||
|
||||
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library
|
||||
used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
To debug the CLI's React-based UI, you can use React DevTools.
|
||||
|
||||
1. **Start the Gemini CLI in development mode:**
|
||||
|
||||
@@ -381,20 +380,20 @@ used for the CLI's interface, is compatible with React DevTools version 4.x.
|
||||
DEV=true npm start
|
||||
```
|
||||
|
||||
2. **Install and run React DevTools version 4.28.5 (or the latest compatible
|
||||
4.x version):**
|
||||
2. **Install and run React DevTools version 6 (which matches the CLI's
|
||||
`react-devtools-core`):**
|
||||
|
||||
You can either install it globally:
|
||||
|
||||
```bash
|
||||
npm install -g react-devtools@4.28.5
|
||||
npm install -g react-devtools@6
|
||||
react-devtools
|
||||
```
|
||||
|
||||
Or run it directly using npx:
|
||||
|
||||
```bash
|
||||
npx react-devtools@4.28.5
|
||||
npx react-devtools@6
|
||||
```
|
||||
|
||||
Your running CLI application should then connect to React DevTools.
|
||||
|
||||
+4
-1
@@ -42,7 +42,10 @@ USER node
|
||||
# install gemini-cli and clean up
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
&& gemini --version > /dev/null \
|
||||
&& npm cache clean --force \
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
|
||||
## 📄 Legal
|
||||
|
||||
- **License**: [Apache License 2.0](LICENSE)
|
||||
- **Terms of Service**: [Terms & Privacy](./docs/tos-privacy.md)
|
||||
- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
|
||||
- **Security**: [Security Policy](SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.30.0-preview.3
|
||||
# Preview release: v0.30.0-preview.5
|
||||
|
||||
Released: February 19, 2026
|
||||
Released: February 24, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -25,6 +25,10 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch
|
||||
version v0.30.0-preview.4 and create version 0.30.0-preview.5 by
|
||||
@gemini-cli-robot in
|
||||
[#20086](https://github.com/google-gemini/gemini-cli/pull/20086)
|
||||
- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch
|
||||
version v0.30.0-preview.0 and create version 0.30.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
@@ -311,4 +315,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#19008](https://github.com/google-gemini/gemini-cli/pull/19008)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5
|
||||
|
||||
+173
-115
@@ -1,50 +1,62 @@
|
||||
# Plan Mode (experimental)
|
||||
|
||||
Plan Mode is a safe, read-only mode for researching and designing complex
|
||||
changes. It prevents modifications while you research, design and plan an
|
||||
implementation strategy.
|
||||
Plan Mode is a read-only environment for architecting robust solutions before
|
||||
implementation. It allows you to:
|
||||
|
||||
> **Note: Plan Mode is currently an experimental feature.**
|
||||
>
|
||||
> Experimental features are subject to change. To use Plan Mode, enable it via
|
||||
> `/settings` (search for `Plan`) or add the following to your `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": {
|
||||
> "plan": true
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Your feedback is invaluable as we refine this feature. If you have ideas,
|
||||
- **Research:** Explore the project in a read-only state to prevent accidental
|
||||
changes.
|
||||
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
|
||||
- **Plan:** Align on an execution strategy before any code is modified.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development. Your
|
||||
> feedback is invaluable as we refine this feature. If you have ideas,
|
||||
> suggestions, or encounter issues:
|
||||
>
|
||||
> - Use the `/bug` command within the CLI to file an issue.
|
||||
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
|
||||
> GitHub.
|
||||
> - Use the **/bug** command within Gemini CLI to file an issue.
|
||||
|
||||
- [Starting in Plan Mode](#starting-in-plan-mode)
|
||||
- [Enabling Plan Mode](#enabling-plan-mode)
|
||||
- [How to use Plan Mode](#how-to-use-plan-mode)
|
||||
- [Entering Plan Mode](#entering-plan-mode)
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Planning Workflow](#planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
|
||||
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
|
||||
- [Automatic Model Routing](#automatic-model-routing)
|
||||
|
||||
## Starting in Plan Mode
|
||||
## Enabling Plan Mode
|
||||
|
||||
You can configure Gemini CLI to start directly in Plan Mode by default:
|
||||
To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
|
||||
following to your `settings.json`:
|
||||
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for `Default Approval Mode`.
|
||||
3. Set the value to `Plan`.
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other ways to start in Plan Mode:
|
||||
## How to use Plan Mode
|
||||
|
||||
- **CLI Flag:** `gemini --approval-mode=plan`
|
||||
- **Manual Settings:** Manually update your `settings.json`:
|
||||
### Entering Plan Mode
|
||||
|
||||
You can configure Gemini CLI to start in Plan Mode by default or enter it
|
||||
manually during a session.
|
||||
|
||||
- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
|
||||
default:
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for **Default Approval Mode**.
|
||||
3. Set the value to **Plan**.
|
||||
|
||||
Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
|
||||
update:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -54,43 +66,44 @@ Other ways to start in Plan Mode:
|
||||
}
|
||||
```
|
||||
|
||||
## How to use Plan Mode
|
||||
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Auto-Edit` -> `Plan`).
|
||||
|
||||
### Entering Plan Mode
|
||||
> **Note:** Plan Mode is automatically removed from the rotation when Gemini
|
||||
> CLI is actively processing or showing confirmation dialogs.
|
||||
|
||||
You can enter Plan Mode in three ways:
|
||||
- **Command:** Type `/plan` in the input box.
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Auto-Edit` -> `Plan`).
|
||||
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then
|
||||
calls the [`enter_plan_mode`] tool to switch modes.
|
||||
> **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
|
||||
|
||||
> **Note:** Plan Mode is automatically removed from the rotation when the
|
||||
> agent is actively processing or showing confirmation dialogs.
|
||||
### Planning Workflow
|
||||
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
- **Note:** This tool is not available when the CLI is in YOLO mode.
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
1. **Explore & Analyze:** Analyze requirements and use read-only tools to map
|
||||
the codebase and validate assumptions. For complex tasks, identify at least
|
||||
two viable implementation approaches.
|
||||
2. **Consult:** Present a summary of the identified approaches via [`ask_user`]
|
||||
to obtain a selection. For simple or canonical tasks, this step may be
|
||||
skipped.
|
||||
3. **Draft:** Once an approach is selected, write a detailed implementation
|
||||
plan to the plans directory.
|
||||
4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan
|
||||
and formally request approval.
|
||||
- **Approve:** Exit Plan Mode and start implementation.
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
|
||||
For more complex or specialized planning tasks, you can
|
||||
[customize the planning workflow with skills](#customizing-planning-with-skills).
|
||||
|
||||
### Exiting Plan Mode
|
||||
|
||||
To exit Plan Mode:
|
||||
To exit Plan Mode, you can:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
|
||||
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -103,30 +116,92 @@ These are the only allowed tools:
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory or your
|
||||
[custom plans directory](#custom-plan-directory-and-policies).
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
approaches planning for specific types of tasks. When a skill is activated
|
||||
during Plan Mode, its specialized instructions and procedural workflows will
|
||||
guide the research and design phases.
|
||||
You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches
|
||||
planning for specific types of tasks. When a skill is activated during Plan
|
||||
Mode, its specialized instructions and procedural workflows will guide the
|
||||
research, design and planning phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
- A **"Security Audit"** skill could prompt Gemini CLI to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
|
||||
`<skill-name>` skill to plan..." or Gemini CLI may autonomously activate it
|
||||
based on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
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).
|
||||
|
||||
#### 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
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research subagents in Plan Mode
|
||||
|
||||
You can enable experimental research [subagents] like `codebase_investigator` to
|
||||
help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell Gemini CLI it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [policy engine]
|
||||
docs.
|
||||
|
||||
### Custom Plan Directory and Policies
|
||||
|
||||
@@ -152,11 +227,10 @@ locations defined within a project's workspace cannot be used to escape and
|
||||
overwrite sensitive files elsewhere. Any user-configured directory must reside
|
||||
within the project boundary.
|
||||
|
||||
Because Plan Mode is read-only by default, using a custom directory requires
|
||||
updating your [Policy Engine] configurations to allow `write_file` and `replace`
|
||||
in that specific location. For example, to allow writing to the `.gemini/plans`
|
||||
directory within your project, create a policy file at
|
||||
`~/.gemini/policies/plan-custom-directory.toml`:
|
||||
Using a custom directory requires updating your [policy engine] configurations
|
||||
to allow `write_file` and `replace` in that specific location. For example, to
|
||||
allow writing to the `.gemini/plans` directory within your project, create a
|
||||
policy file at `~/.gemini/policies/plan-custom-directory.toml`:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
@@ -166,56 +240,35 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
# Adjust the pattern to match your custom directory.
|
||||
# This example matches any .md file in a .gemini/plans directory within the project.
|
||||
argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\""
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
```
|
||||
|
||||
### Customizing Policies
|
||||
## Automatic Model Routing
|
||||
|
||||
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.
|
||||
When using an [**auto model**], Gemini CLI automatically optimizes [**model
|
||||
routing**] based on the current phase of your task:
|
||||
|
||||
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.
|
||||
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
|
||||
high-reasoning **Pro** model to ensure robust architectural decisions and
|
||||
high-quality plans.
|
||||
2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode,
|
||||
the CLI detects the existence of the approved plan and automatically
|
||||
switches to a high-speed **Flash** model. This provides a faster, more
|
||||
responsive experience during the implementation of the plan.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
This behavior is enabled by default to provide the best balance of quality and
|
||||
performance. You can disable this automatic switching in your settings:
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"plan": {
|
||||
"modelRouting": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
|
||||
@@ -225,8 +278,13 @@ Guide].
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/reference/policy-engine.md
|
||||
[subagents]: /docs/core/subagents.md
|
||||
[policy engine]: /docs/reference/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
|
||||
[`plan.toml`]:
|
||||
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
|
||||
[auto model]: /docs/reference/configuration.md#model-settings
|
||||
[model routing]: /docs/cli/telemetry.md#model-routing
|
||||
|
||||
+22
-19
@@ -22,17 +22,18 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| 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` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| 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` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| 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` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| 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` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -112,14 +113,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
|
||||
|
||||
@@ -136,6 +138,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
|
||||
|
||||
|
||||
@@ -487,6 +487,7 @@ Captures Gemini API requests, responses, and errors.
|
||||
- `reasoning` (string, optional)
|
||||
- `failed` (boolean)
|
||||
- `error_message` (string, optional)
|
||||
- `approval_mode` (string)
|
||||
|
||||
#### Chat and streaming
|
||||
|
||||
@@ -711,12 +712,14 @@ Routing latency/failures and slash-command selections.
|
||||
- **Attributes**:
|
||||
- `routing.decision_model` (string)
|
||||
- `routing.decision_source` (string)
|
||||
- `routing.approval_mode` (string)
|
||||
|
||||
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
|
||||
failures.
|
||||
- **Attributes**:
|
||||
- `routing.decision_source` (string)
|
||||
- `routing.error_message` (string)
|
||||
- `routing.approval_mode` (string)
|
||||
|
||||
##### Agent runs
|
||||
|
||||
|
||||
@@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice:
|
||||
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you
|
||||
will only be asked once per folder.
|
||||
|
||||
## Understanding folder contents: The discovery phase
|
||||
|
||||
Before you make a choice, the Gemini CLI performs a **discovery phase** to scan
|
||||
the folder for potential configurations. This information is displayed in the
|
||||
trust dialog to help you make an informed decision.
|
||||
|
||||
The discovery UI lists the following categories of items found in the project:
|
||||
|
||||
- **Commands**: Custom `.toml` command definitions that add new functionality.
|
||||
- **MCP Servers**: Configured Model Context Protocol servers that the CLI will
|
||||
attempt to connect to.
|
||||
- **Hooks**: System or custom hooks that can intercept and modify CLI behavior.
|
||||
- **Skills**: Local agent skills that provide specialized capabilities.
|
||||
- **Setting overrides**: Any project-specific configurations that override your
|
||||
global user settings.
|
||||
|
||||
### Security warnings and errors
|
||||
|
||||
The trust dialog also highlights critical information that requires your
|
||||
attention:
|
||||
|
||||
- **Security Warnings**: The CLI will explicitly flag potentially dangerous
|
||||
settings, such as auto-approving certain tools or disabling the security
|
||||
sandbox.
|
||||
- **Discovery Errors**: If the CLI encounters issues while scanning the folder
|
||||
(e.g., a malformed `settings.json` file), these errors will be displayed
|
||||
prominently.
|
||||
|
||||
By reviewing these details, you can ensure that you only grant trust to projects
|
||||
that you know are safe.
|
||||
|
||||
## Why trust matters: The impact of an untrusted workspace
|
||||
|
||||
When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode"
|
||||
|
||||
@@ -80,6 +80,122 @@ Gemini CLI comes with the following built-in subagents:
|
||||
invoked by the user.
|
||||
- **Configuration:** Enabled by default. No specific configuration options.
|
||||
|
||||
### Browser Agent (experimental)
|
||||
|
||||
- **Name:** `browser_agent`
|
||||
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
|
||||
clicking buttons, and extracting information from web pages — using the
|
||||
accessibility tree.
|
||||
- **When to use:** "Go to example.com and fill out the contact form," "Extract
|
||||
the pricing table from this page," "Click the login button and enter my
|
||||
credentials."
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
The browser agent requires:
|
||||
|
||||
- **Chrome** version 144 or later (any recent stable release will work).
|
||||
- **Node.js** with `npx` available (used to launch the
|
||||
[`chrome-devtools-mcp`](https://www.npmjs.com/package/chrome-devtools-mcp)
|
||||
server).
|
||||
|
||||
#### Enabling the browser agent
|
||||
|
||||
The browser agent is disabled by default. Enable it in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Session modes
|
||||
|
||||
The `sessionMode` setting controls how Chrome is launched and managed. Set it
|
||||
under `agents.browser`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"sessionMode": "persistent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The available modes are:
|
||||
|
||||
| Mode | Description |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `persistent` | **(Default)** Launches Chrome with a persistent profile stored at `~/.gemini/cli-browser-profile/`. Cookies, history, and settings are preserved between sessions. |
|
||||
| `isolated` | Launches Chrome with a temporary profile that is deleted after each session. Use this for clean-state automation. |
|
||||
| `existing` | Attaches to an already-running Chrome instance. You must enable remote debugging first by navigating to `chrome://inspect/#remote-debugging` in Chrome. No new browser process is launched. |
|
||||
|
||||
#### Configuration reference
|
||||
|
||||
All browser-specific settings go under `agents.browser` in your `settings.json`.
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| :------------ | :-------- | :------------- | :---------------------------------------------------------------------------------------------- |
|
||||
| `sessionMode` | `string` | `"persistent"` | How Chrome is managed: `"persistent"`, `"isolated"`, or `"existing"`. |
|
||||
| `headless` | `boolean` | `false` | Run Chrome in headless mode (no visible window). |
|
||||
| `profilePath` | `string` | — | Custom path to a browser profile directory. |
|
||||
| `visualModel` | `string` | — | Model override for the visual agent (for example, `"gemini-2.5-computer-use-preview-10-2025"`). |
|
||||
|
||||
#### Security
|
||||
|
||||
The browser agent enforces the following security restrictions:
|
||||
|
||||
- **Blocked URL patterns:** `file://`, `javascript:`, `data:text/html`,
|
||||
`chrome://extensions`, and `chrome://settings/passwords` are always blocked.
|
||||
- **Sensitive action confirmation:** Actions like form filling, file uploads,
|
||||
and form submissions require user confirmation through the standard policy
|
||||
engine.
|
||||
|
||||
#### Visual agent
|
||||
|
||||
By default, the browser agent interacts with pages through the accessibility
|
||||
tree using element `uid` values. For tasks that require visual identification
|
||||
(for example, "click the yellow button" or "find the red error message"), you
|
||||
can enable the visual agent by setting a `visualModel`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"overrides": {
|
||||
"browser_agent": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"visualModel": "gemini-2.5-computer-use-preview-10-2025"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the agent gains access to the `analyze_screenshot` tool, which
|
||||
captures a screenshot and sends it to the vision model for analysis. The model
|
||||
returns coordinates and element descriptions that the browser agent uses with
|
||||
the `click_at` tool for precise, coordinate-based interactions.
|
||||
|
||||
> **Note:** The visual agent requires API key or Vertex AI authentication. It is
|
||||
> not available when using Google Login.
|
||||
|
||||
## Creating custom subagents
|
||||
|
||||
You can create your own subagents to automate specific workflows or enforce
|
||||
|
||||
@@ -116,7 +116,9 @@ The manifest file defines the extension's behavior and configuration.
|
||||
"description": "My awesome extension",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}/my-server.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
@@ -124,19 +126,41 @@ The manifest file defines the extension's behavior and configuration.
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: A unique identifier for the extension. Use lowercase letters, numbers,
|
||||
and dashes. This name must match the extension's directory name.
|
||||
- `version`: The current version of the extension.
|
||||
- `description`: A short summary shown in the extension gallery.
|
||||
- <a id="mcp-servers"></a>`mcpServers`: A map of Model Context Protocol (MCP)
|
||||
servers. Extension servers follow the same format as standard
|
||||
[CLI configuration](../reference/configuration.md).
|
||||
- `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can
|
||||
also be an array of strings to load multiple context files.
|
||||
- `excludeTools`: An array of tools to block from the model. You can restrict
|
||||
specific arguments, such as `run_shell_command(rm -rf)`.
|
||||
- `themes`: An optional list of themes provided by the extension. See
|
||||
[Themes](../cli/themes.md) for more information.
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `description`: A short description of the extension. This will be displayed on
|
||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers defined in a
|
||||
[`settings.json` file](../reference/configuration.md). If both an extension
|
||||
and a `settings.json` file define an MCP server with the same name, the server
|
||||
defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- For portability, you should use `${extensionPath}` to refer to files within
|
||||
your extension directory.
|
||||
- Separate your executable and its arguments using `command` and `args`
|
||||
instead of putting them both in `command`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
precedence.
|
||||
|
||||
### Extension settings
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
||||
|
||||
> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have
|
||||
> access to Gemini 3.1, use the `/model` command and select **Manual**. If you
|
||||
> have access, you will see `gemini-3.1-pro-preview`.
|
||||
>
|
||||
> If you have access to Gemini 3.1, it will be included in model routing when
|
||||
> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model
|
||||
> directly using the `-m` flag:
|
||||
>
|
||||
> ```
|
||||
> gemini -m gemini-3.1-pro-preview
|
||||
> ```
|
||||
>
|
||||
> Learn more about [models](../cli/model.md) and
|
||||
> [model routing](../cli/model-routing.md).
|
||||
|
||||
## How to get started with Gemini 3 on Gemini CLI
|
||||
|
||||
Get started by upgrading Gemini CLI to the latest version:
|
||||
@@ -12,9 +27,8 @@ npm install -g @google/gemini-cli@latest
|
||||
|
||||
After you’ve confirmed your version is 0.21.1 or later:
|
||||
|
||||
1. Use the `/settings` command in Gemini CLI.
|
||||
2. Toggle **Preview Features** to `true`.
|
||||
3. Run `/model` and select **Auto (Gemini 3)**.
|
||||
1. Run `/model`.
|
||||
2. Select **Auto (Gemini 3)**.
|
||||
|
||||
For more information, see [Gemini CLI model selection](../cli/model.md).
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@ and more.
|
||||
|
||||
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Follow the [File management](../cli/tutorials/file-management.md) guide to
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -56,7 +56,6 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
|
||||
@@ -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:**
|
||||
|
||||
+24
-25
@@ -21,8 +21,10 @@ Jump in to Gemini CLI.
|
||||
personal and enterprise accounts.
|
||||
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
|
||||
action.
|
||||
- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
|
||||
commands and options.
|
||||
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
|
||||
support in Gemini CLI.
|
||||
|
||||
## Use Gemini CLI
|
||||
|
||||
@@ -50,33 +52,29 @@ User-focused guides and tutorials for daily development workflows.
|
||||
|
||||
Technical documentation for each capability of Gemini CLI.
|
||||
|
||||
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
|
||||
loading expert procedures.
|
||||
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
|
||||
clarification.
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
|
||||
- **[File system (tool)](./tools/file-system.md):** Technical details for local
|
||||
file operations.
|
||||
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
|
||||
- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
|
||||
lookup for CLI features.
|
||||
- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
|
||||
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
|
||||
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
|
||||
- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
|
||||
capabilities.
|
||||
- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
|
||||
tasks.
|
||||
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
|
||||
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
|
||||
- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
|
||||
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
|
||||
your favorite IDE.
|
||||
- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
|
||||
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
|
||||
- **[Model selection](./cli/model.md):** Choose the best model for your needs.
|
||||
- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
|
||||
planning complex changes.
|
||||
- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
|
||||
tasks.
|
||||
- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
|
||||
remote agents.
|
||||
- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
|
||||
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
|
||||
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
|
||||
- **[Settings](./cli/settings.md):** Full configuration reference.
|
||||
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
|
||||
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
|
||||
- **[Token caching](./cli/token-caching.md):** Performance optimization.
|
||||
- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
|
||||
details.
|
||||
- **[Web search (tool)](./tools/web-search.md):** Google Search integration
|
||||
technicals.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -91,7 +89,6 @@ Settings and customization options for Gemini CLI.
|
||||
parameters like temperature and thinking budget.
|
||||
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
|
||||
context files.
|
||||
- **[Settings](./cli/settings.md):** Full configuration reference.
|
||||
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
|
||||
logic.
|
||||
- **[Themes](./cli/themes.md):** UI personalization technical guide.
|
||||
@@ -119,11 +116,13 @@ Deep technical documentation and API specifications.
|
||||
Support, release history, and legal information.
|
||||
|
||||
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
|
||||
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
|
||||
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
|
||||
details.
|
||||
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
|
||||
terms.
|
||||
- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
|
||||
solutions.
|
||||
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
conversation state interactively, or resuming a previous state from a later
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`debug`**
|
||||
- **Description:** Export the most recent API request as a JSON payload.
|
||||
- **`delete <tag>`**
|
||||
- **Description:** Deletes a saved conversation checkpoint.
|
||||
- **`list`**
|
||||
@@ -128,8 +130,29 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/extensions`
|
||||
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
- **Description:** Manage extensions. See
|
||||
[Gemini CLI Extensions](../extensions/index.md).
|
||||
- **Sub-commands:**
|
||||
- **`config`**:
|
||||
- **Description:** Configure extension settings.
|
||||
- **`disable`**:
|
||||
- **Description:** Disable an extension.
|
||||
- **`enable`**:
|
||||
- **Description:** Enable an extension.
|
||||
- **`explore`**:
|
||||
- **Description:** Open extensions page in your browser.
|
||||
- **`install`**:
|
||||
- **Description:** Install an extension from a git repo or local path.
|
||||
- **`link`**:
|
||||
- **Description:** Link an extension from a local path.
|
||||
- **`list`**:
|
||||
- **Description:** List active extensions.
|
||||
- **`restart`**:
|
||||
- **Description:** Restart all extensions.
|
||||
- **`uninstall`**:
|
||||
- **Description:** Uninstall an extension.
|
||||
- **`update`**:
|
||||
- **Description:** Update extensions. Usage: update <extension-names>|--all
|
||||
|
||||
### `/help` (or `/?`)
|
||||
|
||||
@@ -184,6 +207,10 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
servers that support OAuth authentication.
|
||||
- **`desc`**
|
||||
- **Description:** List configured MCP servers and tools with descriptions.
|
||||
- **`disable`**
|
||||
- **Description:** Disable an MCP server.
|
||||
- **`enable`**
|
||||
- **Description:** Enable a disabled MCP server.
|
||||
- **`list`** or **`ls`**:
|
||||
- **Description:** List configured MCP servers and tools. This is the
|
||||
default action if no subcommand is specified.
|
||||
@@ -221,7 +248,21 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
### `/model`
|
||||
|
||||
- **Description:** Opens a dialog to choose your Gemini model.
|
||||
- **Description:** Manage model configuration.
|
||||
- **Sub-commands:**
|
||||
- **`manage`**:
|
||||
- **Description:** Opens a dialog to configure the model.
|
||||
- **`set`**:
|
||||
- **Description:** Set the model to use.
|
||||
- **Usage:** `/model set <model-name> [--persist]`
|
||||
|
||||
### `/permissions`
|
||||
|
||||
- **Description:** Manage folder trust settings and other permissions.
|
||||
- **Sub-commands:**
|
||||
- **`trust`**:
|
||||
- **Description:** Manage folder trust settings.
|
||||
- **Usage:** `/permissions trust [<directory-path>]`
|
||||
|
||||
### `/plan`
|
||||
|
||||
@@ -331,10 +372,16 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
### `/stats`
|
||||
|
||||
- **Description:** Display detailed statistics for the current Gemini CLI
|
||||
session, including token usage, cached token savings (when available), and
|
||||
session duration. Note: Cached token information is only displayed when cached
|
||||
tokens are being used, which occurs with API key authentication but not with
|
||||
OAuth authentication at this time.
|
||||
session.
|
||||
- **Sub-commands:**
|
||||
- **`session`**:
|
||||
- **Description:** Show session-specific usage statistics, including
|
||||
duration, tool calls, and performance metrics. This is the default view.
|
||||
- **`model`**:
|
||||
- **Description:** Show model-specific usage statistics, including token
|
||||
counts and quota information.
|
||||
- **`tools`**:
|
||||
- **Description:** Show tool-specific usage statistics.
|
||||
|
||||
### `/terminal-setup`
|
||||
|
||||
|
||||
@@ -137,17 +137,22 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.enablePromptCompletion`** (boolean):
|
||||
- **Description:** Enable AI-powered prompt completion suggestions while
|
||||
typing.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
- **`general.plan.modelRouting`** (boolean):
|
||||
- **Description:** Automatically switch between Pro and Flash models based on
|
||||
Plan Mode status. Uses Pro for the planning phase and Flash for the
|
||||
implementation phase.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.retryFetchErrors`** (boolean):
|
||||
- **Description:** Retry on "exception TypeError: fetch failed sending
|
||||
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`
|
||||
@@ -647,6 +652,27 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `{}`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.sessionMode`** (enum):
|
||||
- **Description:** Session mode: 'persistent', 'isolated', or 'existing'.
|
||||
- **Default:** `"persistent"`
|
||||
- **Values:** `"persistent"`, `"isolated"`, `"existing"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.headless`** (boolean):
|
||||
- **Description:** Run browser in headless mode.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.profilePath`** (string):
|
||||
- **Description:** Path to browser profile directory for session persistence.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`agents.browser.visualModel`** (string):
|
||||
- **Description:** Model override for the visual agent.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `context`
|
||||
|
||||
- **`context.fileName`** (string | string[]):
|
||||
@@ -874,6 +900,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):
|
||||
@@ -975,6 +1009,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):
|
||||
@@ -1262,6 +1301,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.
|
||||
|
||||
@@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments.
|
||||
|
||||
The `toolName` in the rule must match the name of the tool being called.
|
||||
|
||||
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
|
||||
wildcard. A `toolName` of `my-server__*` will match any tool from the
|
||||
`my-server` MCP.
|
||||
- **Wildcards**: You can use wildcards to match multiple tools.
|
||||
- `*`: Matches **any tool** (built-in or MCP).
|
||||
- `server__*`: Matches any tool from a specific MCP server.
|
||||
- `*__toolName`: Matches a specific tool name across **all** MCP servers.
|
||||
- `*__*`: Matches **any tool from any MCP server**.
|
||||
|
||||
#### Arguments pattern
|
||||
|
||||
@@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met:
|
||||
|
||||
1. **Tool name**: The `toolName` in the rule must match the name of the tool
|
||||
being called.
|
||||
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
|
||||
wildcard. A `toolName` of `my-server__*` will match any tool from the
|
||||
`my-server` MCP.
|
||||
- **Wildcards**: You can use wildcards like `*`, `server__*`, or
|
||||
`*__toolName` to match multiple tools. See [Tool Name](#tool-name) for
|
||||
details.
|
||||
2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments
|
||||
are converted to a stable JSON string, which is then tested against the
|
||||
provided regular expression. If the arguments don't match the pattern, the
|
||||
@@ -203,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)'
|
||||
|
||||
@@ -272,13 +278,12 @@ priority = 100
|
||||
|
||||
### Special syntax for MCP tools
|
||||
|
||||
You can create rules that target tools from Model-hosting-protocol (MCP) servers
|
||||
using the `mcpName` field or a wildcard pattern.
|
||||
You can create rules that target tools from Model Context Protocol (MCP) servers
|
||||
using the `mcpName` field or composite wildcard patterns.
|
||||
|
||||
**1. Using `mcpName`**
|
||||
**1. Targeting a specific tool on a server**
|
||||
|
||||
To target a specific tool from a specific server, combine `mcpName` and
|
||||
`toolName`.
|
||||
Combine `mcpName` and `toolName` to target a single operation.
|
||||
|
||||
```toml
|
||||
# Allows the `search` tool on the `my-jira-server` MCP
|
||||
@@ -289,10 +294,10 @@ decision = "allow"
|
||||
priority = 200
|
||||
```
|
||||
|
||||
**2. Using a wildcard**
|
||||
**2. Targeting all tools on a specific server**
|
||||
|
||||
To create a rule that applies to _all_ tools on a specific MCP server, specify
|
||||
only the `mcpName`.
|
||||
Specify only the `mcpName` to apply a rule to every tool provided by that
|
||||
server.
|
||||
|
||||
```toml
|
||||
# Denies all tools from the `untrusted-server` MCP
|
||||
@@ -303,6 +308,33 @@ priority = 500
|
||||
deny_message = "This server is not trusted by the admin."
|
||||
```
|
||||
|
||||
**3. Targeting all MCP servers**
|
||||
|
||||
Use `mcpName = "*"` to create a rule that applies to **all** tools from **any**
|
||||
registered MCP server. This is useful for setting category-wide defaults.
|
||||
|
||||
```toml
|
||||
# Ask user for any tool call from any MCP server
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
decision = "ask_user"
|
||||
priority = 10
|
||||
```
|
||||
|
||||
**4. Targeting a tool name across all servers**
|
||||
|
||||
Use `mcpName = "*"` with a specific `toolName` to target that operation
|
||||
regardless of which server provides it.
|
||||
|
||||
```toml
|
||||
# Allow the `search` tool across all connected MCP servers
|
||||
[[rule]]
|
||||
mcpName = "*"
|
||||
toolName = "search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
```
|
||||
|
||||
## Default policies
|
||||
|
||||
The Gemini CLI ships with a set of default policies to provide a safe
|
||||
|
||||
@@ -135,6 +135,18 @@ Flow video editor). These plans do not apply to the API usage which powers the
|
||||
Gemini CLI. Supporting these plans is under active consideration for future
|
||||
support.
|
||||
|
||||
## Check usage and quota
|
||||
|
||||
You can check your current token usage and quota information using the
|
||||
`/stats model` command. This command provides a snapshot of your current
|
||||
session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
A summary of model usage is also presented on exit at the end of a session.
|
||||
|
||||
## Tips to avoid high costs
|
||||
|
||||
When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
|
||||
@@ -151,8 +163,3 @@ costs.
|
||||
models directly.
|
||||
- Vertex AI: This is the enterprise-grade platform for building, deploying, and
|
||||
managing Gemini models with specific security and control requirements.
|
||||
|
||||
## Understanding your usage
|
||||
|
||||
A summary of model usage is available through the `/stats` command and presented
|
||||
on exit at the end of a session.
|
||||
|
||||
+195
-168
@@ -1,199 +1,226 @@
|
||||
[
|
||||
{
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{ "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Use Gemini CLI",
|
||||
"label": "docs_tab",
|
||||
"items": [
|
||||
{
|
||||
"label": "File management",
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
|
||||
{
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Get started with Agent skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
"label": "Use Gemini CLI",
|
||||
"items": [
|
||||
{
|
||||
"label": "File management",
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
},
|
||||
{
|
||||
"label": "Get started with Agent skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
"label": "Manage context and memory",
|
||||
"slug": "docs/cli/tutorials/memory-management"
|
||||
},
|
||||
{
|
||||
"label": "Execute shell commands",
|
||||
"slug": "docs/cli/tutorials/shell-commands"
|
||||
},
|
||||
{
|
||||
"label": "Manage sessions and history",
|
||||
"slug": "docs/cli/tutorials/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Plan tasks with todos",
|
||||
"slug": "docs/cli/tutorials/task-planning"
|
||||
},
|
||||
{
|
||||
"label": "Web search and fetch",
|
||||
"slug": "docs/cli/tutorials/web-tools"
|
||||
},
|
||||
{
|
||||
"label": "Set up an MCP server",
|
||||
"slug": "docs/cli/tutorials/mcp-setup"
|
||||
},
|
||||
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Manage context and memory",
|
||||
"slug": "docs/cli/tutorials/memory-management"
|
||||
"label": "Features",
|
||||
"items": [
|
||||
{
|
||||
"label": "Extensions",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
|
||||
{
|
||||
"label": "Subagents",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Remote subagents",
|
||||
"badge": "🔬",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Execute shell commands",
|
||||
"slug": "docs/cli/tutorials/shell-commands"
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{
|
||||
"label": "Enterprise configuration",
|
||||
"slug": "docs/cli/enterprise"
|
||||
},
|
||||
{
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Model configuration",
|
||||
"slug": "docs/cli/generation-settings"
|
||||
},
|
||||
{
|
||||
"label": "Project context (GEMINI.md)",
|
||||
"slug": "docs/cli/gemini-md"
|
||||
},
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "System prompt override",
|
||||
"slug": "docs/cli/system-prompt"
|
||||
},
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Manage sessions and history",
|
||||
"slug": "docs/cli/tutorials/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Plan tasks with todos",
|
||||
"slug": "docs/cli/tutorials/task-planning"
|
||||
},
|
||||
{
|
||||
"label": "Web search and fetch",
|
||||
"slug": "docs/cli/tutorials/web-tools"
|
||||
},
|
||||
{
|
||||
"label": "Set up an MCP server",
|
||||
"slug": "docs/cli/tutorials/mcp-setup"
|
||||
},
|
||||
{ "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Features",
|
||||
"items": [
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{
|
||||
"label": "Extensions",
|
||||
"slug": "docs/extensions/index"
|
||||
},
|
||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||
{ "label": "Help", "link": "/docs/reference/commands/#help-or" },
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{
|
||||
"label": "Memory management",
|
||||
"link": "/docs/reference/commands/#memory"
|
||||
},
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
|
||||
{ "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" },
|
||||
{
|
||||
"label": "Remote subagents",
|
||||
"badge": "🧪",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Rewind", "slug": "docs/cli/rewind" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "Shell",
|
||||
"link": "/docs/reference/commands/#shells-or-bashes"
|
||||
},
|
||||
{
|
||||
"label": "Stats",
|
||||
"link": "/docs/reference/commands/#stats"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Tools", "link": "/docs/reference/commands/#tools" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise configuration", "slug": "docs/cli/enterprise" },
|
||||
{
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Model configuration",
|
||||
"slug": "docs/cli/generation-settings"
|
||||
},
|
||||
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Extensions",
|
||||
"items": [
|
||||
{
|
||||
"label": "Overview",
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "User guide: Install and manage",
|
||||
"link": "/docs/extensions/#manage-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Build extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
},
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Reference",
|
||||
"label": "reference_tab",
|
||||
"items": [
|
||||
{ "label": "Command reference", "slug": "docs/reference/commands" },
|
||||
{
|
||||
"label": "Configuration reference",
|
||||
"slug": "docs/reference/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/reference/keyboard-shortcuts"
|
||||
},
|
||||
{ "label": "Memory import processor", "slug": "docs/reference/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Command reference", "slug": "docs/reference/commands" },
|
||||
{
|
||||
"label": "Configuration reference",
|
||||
"slug": "docs/reference/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/reference/keyboard-shortcuts"
|
||||
},
|
||||
{
|
||||
"label": "Memory import processor",
|
||||
"slug": "docs/reference/memport"
|
||||
},
|
||||
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/reference/tools-api" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Resources",
|
||||
"label": "resources_tab",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
},
|
||||
{ "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/resources/faq" },
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/resources/quota-and-pricing"
|
||||
},
|
||||
{
|
||||
"label": "Terms and privacy",
|
||||
"slug": "docs/resources/tos-privacy"
|
||||
},
|
||||
{
|
||||
"label": "Troubleshooting",
|
||||
"slug": "docs/resources/troubleshooting"
|
||||
},
|
||||
{ "label": "Uninstall", "slug": "docs/resources/uninstall" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"label": "releases_tab",
|
||||
"items": [
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
},
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
## 6. `replace` (Edit)
|
||||
|
||||
`replace` replaces text within a file. By default, replaces a single occurrence,
|
||||
but can replace multiple occurrences when `expected_replacements` is specified.
|
||||
This tool is designed for precise, targeted changes and requires significant
|
||||
context around the `old_string` to ensure it modifies the correct location.
|
||||
`replace` replaces text within a file. By default, the tool expects to find and
|
||||
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
|
||||
occurrences of the exact same string, set `allow_multiple` to `true`. This tool
|
||||
is designed for precise, targeted changes and requires significant context
|
||||
around the `old_string` to ensure it modifies the correct location.
|
||||
|
||||
- **Tool name:** `replace`
|
||||
- **Arguments:**
|
||||
@@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location.
|
||||
- `instruction` (string, required): Semantic description of the change.
|
||||
- `old_string` (string, required): Exact literal text to find.
|
||||
- `new_string` (string, required): Exact literal text to replace with.
|
||||
- `allow_multiple` (boolean, optional): If `true`, replaces all occurrences.
|
||||
If `false` (default), only succeeds if exactly one occurrence is found.
|
||||
- **Confirmation:** Requires manual user approval.
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -52,6 +52,9 @@ These tools help the model manage its plan and interact with you.
|
||||
complex plans.
|
||||
- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
|
||||
procedural expertise when needed.
|
||||
- **[Browser agent](../core/subagents.md#browser-agent-experimental)
|
||||
(`browser_agent`):** Automates web browser tasks through the accessibility
|
||||
tree.
|
||||
- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
|
||||
documentation to help answer your questions.
|
||||
|
||||
|
||||
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
|
||||
- **`args`** (string[]): Command-line arguments for Stdio transport
|
||||
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
|
||||
- **`env`** (object): Environment variables for the server process. Values can
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
|
||||
platforms), or `%VAR_NAME%` (Windows only).
|
||||
- **`cwd`** (string): Working directory for Stdio transport
|
||||
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
|
||||
10 minutes)
|
||||
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
|
||||
Service Account to impersonate. Used with
|
||||
`authProviderType: 'service_account_impersonation'`.
|
||||
|
||||
### Environment variable expansion
|
||||
|
||||
Gemini CLI automatically expands environment variables in the `env` block of
|
||||
your MCP server configuration. This allows you to securely reference variables
|
||||
defined in your shell or environment without hardcoding sensitive information
|
||||
directly in your `settings.json` file.
|
||||
|
||||
The expansion utility supports:
|
||||
|
||||
- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
|
||||
all platforms)
|
||||
- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
|
||||
|
||||
If a variable is not defined in the current environment, it resolves to an empty
|
||||
string.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
"env": {
|
||||
"API_KEY": "$MY_EXTERNAL_TOKEN",
|
||||
"LOG_LEVEL": "$LOG_LEVEL",
|
||||
"TEMP_DIR": "%TEMP%"
|
||||
}
|
||||
```
|
||||
|
||||
### Security and environment sanitization
|
||||
|
||||
To protect your credentials, Gemini CLI performs environment sanitization when
|
||||
spawning MCP server processes.
|
||||
|
||||
#### Automatic redaction
|
||||
|
||||
By default, the CLI redacts sensitive environment variables from the base
|
||||
environment (inherited from the host process) to prevent unintended exposure to
|
||||
third-party MCP servers. This includes:
|
||||
|
||||
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
|
||||
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
|
||||
`*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
|
||||
- Certificates and private key patterns.
|
||||
|
||||
#### Explicit overrides
|
||||
|
||||
If an environment variable must be passed to an MCP server, you must explicitly
|
||||
state it in the `env` property of the server configuration in `settings.json`.
|
||||
Explicitly defined variables (including those from extensions) are trusted and
|
||||
are **not** subjected to the automatic redaction process.
|
||||
|
||||
This follows the security principle that if a variable is explicitly configured
|
||||
by the user for a specific server, it constitutes informed consent to share that
|
||||
specific data with that server.
|
||||
|
||||
> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
|
||||
> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
|
||||
> securely pull the value from your host environment at runtime.
|
||||
|
||||
### OAuth support for remote MCP servers
|
||||
|
||||
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
|
||||
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
containing API keys or tokens. See
|
||||
[Security and environment sanitization](#security-and-environment-sanitization)
|
||||
for details on how Gemini CLI protects your credentials.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
|
||||
+2
-2
@@ -82,7 +82,7 @@ const commonAliases = {
|
||||
const cliConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
},
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
outfile: 'bundle/gemini.js',
|
||||
@@ -100,7 +100,7 @@ const cliConfig = {
|
||||
const a2aServerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
},
|
||||
entryPoints: ['packages/a2a-server/src/http/server.ts'],
|
||||
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
|
||||
|
||||
+4
-12
@@ -56,7 +56,7 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
// Import specific config
|
||||
files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -128,17 +128,7 @@ export default tseslint.config(
|
||||
],
|
||||
// Prevent async errors from bypassing catch handlers
|
||||
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/no-internal-modules': 'off',
|
||||
'import/no-relative-packages': 'error',
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
@@ -200,6 +190,8 @@ export default tseslint.config(
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+13
-12
@@ -78,22 +78,23 @@ describe('Frugal reads eval', () => {
|
||||
).toBe(true);
|
||||
|
||||
let totalLinesRead = 0;
|
||||
const readRanges: { offset: number; limit: number }[] = [];
|
||||
const readRanges: { start_line: number; end_line: number }[] = [];
|
||||
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent read the entire file (missing limit) instead of using ranged read',
|
||||
args.end_line,
|
||||
'Agent read the entire file (missing end_line) instead of using ranged read',
|
||||
).toBeDefined();
|
||||
|
||||
const limit = args.limit;
|
||||
const offset = args.offset ?? 0;
|
||||
totalLinesRead += limit;
|
||||
readRanges.push({ offset, limit });
|
||||
const end_line = args.end_line;
|
||||
const start_line = args.start_line ?? 1;
|
||||
const linesRead = end_line - start_line + 1;
|
||||
totalLinesRead += linesRead;
|
||||
readRanges.push({ start_line, end_line });
|
||||
|
||||
expect(args.limit, 'Agent read too many lines at once').toBeLessThan(
|
||||
expect(linesRead, 'Agent read too many lines at once').toBeLessThan(
|
||||
1001,
|
||||
);
|
||||
}
|
||||
@@ -108,7 +109,7 @@ describe('Frugal reads eval', () => {
|
||||
const errorLines = [500, 510, 520];
|
||||
for (const line of errorLines) {
|
||||
const covered = readRanges.some(
|
||||
(range) => line >= range.offset && line < range.offset + range.limit,
|
||||
(range) => line >= range.start_line && line <= range.end_line,
|
||||
);
|
||||
expect(covered, `Agent should have read around line ${line}`).toBe(
|
||||
true,
|
||||
@@ -191,8 +192,8 @@ describe('Frugal reads eval', () => {
|
||||
for (const call of targetFileReads) {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
expect(
|
||||
args.limit,
|
||||
'Agent should have used ranged read (limit) to save tokens',
|
||||
args.end_line,
|
||||
'Agent should have used ranged read (end_line) to save tokens',
|
||||
).toBeDefined();
|
||||
}
|
||||
},
|
||||
@@ -253,7 +254,7 @@ describe('Frugal reads eval', () => {
|
||||
// and just read the whole file to be efficient with tool calls.
|
||||
const readEntireFile = targetFileReads.some((call) => {
|
||||
const args = JSON.parse(call.toolRequest.args);
|
||||
return args.limit === undefined;
|
||||
return args.end_line === undefined;
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('Frugal Search', () => {
|
||||
const args = getParams(call);
|
||||
return (
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
(args.limit === undefined || args.limit === null)
|
||||
(args.end_line === undefined || args.end_line === null)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Frugal Search', () => {
|
||||
if (
|
||||
call.toolRequest.name === 'read_file' &&
|
||||
args.file_path === 'src/legacy_processor.ts' &&
|
||||
args.limit !== undefined
|
||||
args.end_line !== undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 202 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('grep_search_functionality', () => {
|
||||
const TEST_PREFIX = 'Grep Search Functionality: ';
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should find a simple string in a file',
|
||||
files: {
|
||||
'test.txt': `hello
|
||||
world
|
||||
hello world`,
|
||||
},
|
||||
prompt: 'Find "world" in test.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/L2: world/, /L3: hello world/],
|
||||
testName: `${TEST_PREFIX}simple search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should perform a case-sensitive search',
|
||||
files: {
|
||||
'test.txt': `Hello
|
||||
hello`,
|
||||
},
|
||||
prompt: 'Find "Hello" in test.txt, case-sensitively.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.case_sensitive === true;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with case_sensitive: true',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/L1: Hello/],
|
||||
forbiddenContent: [/L2: hello/],
|
||||
testName: `${TEST_PREFIX}case-sensitive search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should return only file names when names_only is used',
|
||||
files: {
|
||||
'file1.txt': 'match me',
|
||||
'file2.txt': 'match me',
|
||||
},
|
||||
prompt: 'Find the files containing "match me".',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.names_only === true;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with names_only: true',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/file1.txt/, /file2.txt/],
|
||||
forbiddenContent: [/L1:/],
|
||||
testName: `${TEST_PREFIX}names_only search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should search only within the specified include glob',
|
||||
files: {
|
||||
'file.js': 'my_function();',
|
||||
'file.ts': 'my_function();',
|
||||
},
|
||||
prompt: 'Find "my_function" in .js files.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.include === '*.js';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with include: "*.js"',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/file.js/],
|
||||
forbiddenContent: [/file.ts/],
|
||||
testName: `${TEST_PREFIX}include glob search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should search within a specific subdirectory',
|
||||
files: {
|
||||
'src/main.js': 'unique_string_1',
|
||||
'lib/main.js': 'unique_string_2',
|
||||
},
|
||||
prompt: 'Find "unique_string" in the src directory.',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'grep_search',
|
||||
undefined,
|
||||
(args) => {
|
||||
const params = JSON.parse(args);
|
||||
return params.dir_path === 'src';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'Expected grep_search to be called with dir_path: "src"',
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/unique_string_1/],
|
||||
forbiddenContent: [/unique_string_2/],
|
||||
testName: `${TEST_PREFIX}subdirectory search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should report no matches correctly',
|
||||
files: {
|
||||
'file.txt': 'nothing to see here',
|
||||
},
|
||||
prompt: 'Find "nonexistent" in file.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/No matches found/],
|
||||
testName: `${TEST_PREFIX}no matches`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ class MockClient implements acp.Client {
|
||||
};
|
||||
}
|
||||
|
||||
describe('ACP Environment and Auth', () => {
|
||||
describe.skip('ACP Environment and Auth', () => {
|
||||
let rig: TestRig;
|
||||
let child: ChildProcess | undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('web-fetch rate limiting', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (rig) {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('should rate limit multiple requests to the same host', async () => {
|
||||
rig.setup('web-fetch rate limit', {
|
||||
settings: { tools: { core: ['web_fetch'] } },
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'concurrency-limit.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const result = await rig.run({
|
||||
args: `Fetch 11 pages from example.com`,
|
||||
});
|
||||
|
||||
// We expect to find at least one tool call that failed with a rate limit error.
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const rateLimitedCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'web_fetch' &&
|
||||
log.toolRequest.error?.includes('Rate limit exceeded'),
|
||||
);
|
||||
|
||||
expect(rateLimitedCalls.length).toBeGreaterThan(0);
|
||||
expect(result).toContain('Rate limit exceeded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Tail call completed successfully."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -286,6 +286,113 @@ describe('Hooks System Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command Hooks - Tail Tool Calls', () => {
|
||||
it('should execute a tail tool call from AfterTool hooks and replace original response', async () => {
|
||||
// Create a script that acts as the hook.
|
||||
// It will trigger on "read_file" and issue a tail call to "write_file".
|
||||
rig.setup('should execute a tail tool call from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.tail-tool-call.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const hookOutput = {
|
||||
decision: 'allow',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'AfterTool',
|
||||
tailToolCallRequest: {
|
||||
name: 'write_file',
|
||||
args: {
|
||||
file_path: 'tail-called-file.txt',
|
||||
content: 'Content from tail call',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)})); process.exit(0);`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'tail_call_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
rig.setup('should execute a tail tool call from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.tail-tool-call.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'read_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a test file to trigger the read_file tool
|
||||
rig.createFile('original.txt', 'Original content');
|
||||
|
||||
const cliOutput = await rig.run({
|
||||
args: 'Read original.txt', // Fake responses should trigger read_file on this
|
||||
});
|
||||
|
||||
// 1. Verify that write_file was called (as a tail call replacing read_file)
|
||||
// Since read_file was replaced before finalizing, it will not appear in the tool logs.
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// Ensure hook logs are flushed and the final LLM response is received.
|
||||
// The mock LLM is configured to respond with "Tail call completed successfully."
|
||||
expect(cliOutput).toContain('Tail call completed successfully.');
|
||||
|
||||
// Ensure telemetry is written to disk
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
// Read hook logs to debug
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const relevantHookLog = hookLogs.find(
|
||||
(l) => l.hookCall.hook_event_name === 'AfterTool',
|
||||
);
|
||||
|
||||
expect(relevantHookLog).toBeDefined();
|
||||
|
||||
// 2. Verify write_file was executed.
|
||||
// In non-interactive mode, the CLI deduplicates tool execution logs by callId.
|
||||
// Since a tail call reuses the original callId, "Tool: write_file" is not printed.
|
||||
// Instead, we verify the side-effect (file creation) and the telemetry log.
|
||||
|
||||
// 3. Verify the tail-called tool actually wrote the file
|
||||
const modifiedContent = rig.readFile('tail-called-file.txt');
|
||||
expect(modifiedContent).toBe('Content from tail call');
|
||||
|
||||
// 4. Verify telemetry for the final tool call.
|
||||
// The original 'read_file' call is replaced, so only 'write_file' is finalized and logged.
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const successfulTools = toolLogs.filter((t) => t.toolRequest.success);
|
||||
expect(
|
||||
successfulTools.some((t) => t.toolRequest.name === 'write_file'),
|
||||
).toBeTruthy();
|
||||
// The original request name should be preserved in the log payload if possible,
|
||||
// but the executed tool name is 'write_file'.
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeModel Hooks - LLM Request Modification', () => {
|
||||
it('should modify LLM requests with BeforeModel hooks', async () => {
|
||||
// Create a hook script that replaces the LLM request with a modified version
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, checkModelOutputContent } from './test-helper.js';
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should allow read-only tools but deny write tools in plan mode', async () => {
|
||||
await rig.setup(
|
||||
'should allow read-only tools but deny write tools in plan mode',
|
||||
{
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: [
|
||||
'run_shell_command',
|
||||
'list_directory',
|
||||
'write_file',
|
||||
'read_file',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// We use a prompt that asks for both a read-only action and a write action.
|
||||
// "List files" (read-only) followed by "touch denied.txt" (write).
|
||||
const result = await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
|
||||
});
|
||||
|
||||
const lsCallFound = await rig.waitForToolCall('list_directory');
|
||||
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
|
||||
|
||||
const shellCallFound = await rig.waitForToolCall('run_shell_command');
|
||||
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
|
||||
expect(
|
||||
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(lsLog?.toolRequest.success).toBe(true);
|
||||
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['Plan Mode', 'read-only'],
|
||||
testName: 'Plan Mode restrictions test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow write_file only in the plans directory in plan mode', async () => {
|
||||
await rig.setup(
|
||||
'should allow write_file only in the plans directory in plan mode',
|
||||
{
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['write_file', 'read_file', 'list_directory'],
|
||||
allowed: ['write_file'],
|
||||
},
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
|
||||
// Verify that write_file outside of plan directory fails
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
stdin:
|
||||
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeLogs = toolLogs.filter(
|
||||
(l) => l.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
const planWrite = writeLogs.find(
|
||||
(l) =>
|
||||
l.toolRequest.args.includes('plans') &&
|
||||
l.toolRequest.args.includes('plan.md'),
|
||||
);
|
||||
|
||||
const blockedWrite = writeLogs.find((l) =>
|
||||
l.toolRequest.args.includes('hello.txt'),
|
||||
);
|
||||
|
||||
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
|
||||
if (blockedWrite) {
|
||||
expect(blockedWrite?.toolRequest.success).toBe(false);
|
||||
}
|
||||
|
||||
expect(planWrite?.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to enter plan mode from default mode', async () => {
|
||||
await rig.setup('should be able to enter plan mode from default mode', {
|
||||
settings: {
|
||||
experimental: { plan: true },
|
||||
tools: {
|
||||
core: ['enter_plan_mode'],
|
||||
allowed: ['enter_plan_mode'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Start in default mode and ask to enter plan mode.
|
||||
await rig.run({
|
||||
approvalMode: 'default',
|
||||
stdin:
|
||||
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
|
||||
});
|
||||
|
||||
const enterPlanCallFound = await rig.waitForToolCall(
|
||||
'enter_plan_mode',
|
||||
10000,
|
||||
);
|
||||
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const enterLog = toolLogs.find(
|
||||
(l) => l.toolRequest.name === 'enter_plan_mode',
|
||||
);
|
||||
expect(enterLog?.toolRequest.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, InteractiveRun } from './test-helper.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
const extension = `{
|
||||
"name": "test-symlink-extension",
|
||||
"version": "0.0.1"
|
||||
}`;
|
||||
|
||||
const otherExtension = `{
|
||||
"name": "malicious-extension",
|
||||
"version": "6.6.6"
|
||||
}`;
|
||||
|
||||
describe('extension symlink install spoofing protection', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
|
||||
// Enable folder trust for this test
|
||||
rig.setup('symlink spoofing test', {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const realExtPath = join(rig.testDir!, 'real-extension');
|
||||
mkdirSync(realExtPath);
|
||||
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
|
||||
|
||||
const maliciousExtPath = join(
|
||||
os.tmpdir(),
|
||||
`malicious-extension-${Date.now()}`,
|
||||
);
|
||||
mkdirSync(maliciousExtPath);
|
||||
writeFileSync(
|
||||
join(maliciousExtPath, 'gemini-extension.json'),
|
||||
otherExtension,
|
||||
);
|
||||
|
||||
const symlinkPath = join(rig.testDir!, 'symlink-extension');
|
||||
symlinkSync(realExtPath, symlinkPath);
|
||||
|
||||
// Function to run a command with a PTY to avoid headless mode
|
||||
const runPty = (args: string[]) => {
|
||||
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 80,
|
||||
cwd: rig.testDir!,
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||
GEMINI_PTY_INFO: 'node-pty',
|
||||
},
|
||||
});
|
||||
return new InteractiveRun(ptyProcess);
|
||||
};
|
||||
|
||||
// 1. Install via symlink, trust it
|
||||
const run1 = runPty(['extensions', 'install', symlinkPath]);
|
||||
await run1.expectText('Do you want to trust this folder', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('trust this workspace', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('Do you want to continue', 30000);
|
||||
await run1.type('y\r');
|
||||
await run1.expectText('installed successfully', 30000);
|
||||
await run1.kill();
|
||||
|
||||
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
|
||||
const trustedFoldersPath = join(
|
||||
rig.homeDir!,
|
||||
GEMINI_DIR,
|
||||
'trustedFolders.json',
|
||||
);
|
||||
// Wait for file to be written
|
||||
let attempts = 0;
|
||||
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const trustedFolders = JSON.parse(
|
||||
readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
const trustedPaths = Object.keys(trustedFolders);
|
||||
const canonicalRealExtPath = fs.realpathSync(realExtPath);
|
||||
|
||||
expect(trustedPaths).toContain(canonicalRealExtPath);
|
||||
expect(trustedPaths).not.toContain(symlinkPath);
|
||||
|
||||
// 3. Swap the symlink to point to the malicious extension
|
||||
unlinkSync(symlinkPath);
|
||||
symlinkSync(maliciousExtPath, symlinkPath);
|
||||
|
||||
// 4. Try to install again via the same symlink path.
|
||||
// It should NOT be trusted because the real path changed.
|
||||
const run2 = runPty(['extensions', 'install', symlinkPath]);
|
||||
await run2.expectText('Do you want to trust this folder', 30000);
|
||||
await run2.type('n\r');
|
||||
await run2.expectText('Installation aborted', 30000);
|
||||
await run2.kill();
|
||||
}, 60000);
|
||||
});
|
||||
Generated
+2736
-5177
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -66,10 +66,14 @@
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"zod": "3.25.76",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
}
|
||||
},
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -93,15 +97,16 @@
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "1.4.3",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"depcheck": "^1.4.7",
|
||||
"domexception": "^4.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-wasm": "^1.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-headers": "^1.3.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^12.0.0",
|
||||
@@ -129,6 +134,7 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"punycode": "^2.3.1",
|
||||
"simple-git": "^3.28.0"
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
CoderAgentEvent,
|
||||
getPersistedState,
|
||||
setPersistedState,
|
||||
getContextIdFromMetadata,
|
||||
getAgentSettingsFromMetadata,
|
||||
} from '../types.js';
|
||||
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
const config = await this.getConfig(agentSettings, sdkTask.id);
|
||||
const contextId: string =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(metadata['_contextId'] as string) || sdkTask.contextId;
|
||||
getContextIdFromMetadata(metadata) || sdkTask.contextId;
|
||||
const runtimeTask = await Task.create(
|
||||
sdkTask.id,
|
||||
contextId,
|
||||
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettingsInput?: AgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = agentSettingsInput || ({} as AgentSettings);
|
||||
const agentSettings: AgentSettings = agentSettingsInput || {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent,
|
||||
workspacePath: process.cwd(),
|
||||
};
|
||||
const config = await this.getConfig(agentSettings, taskId);
|
||||
const runtimeTask = await Task.create(
|
||||
taskId,
|
||||
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const contextId: string =
|
||||
userMessage.contextId ||
|
||||
sdkTask?.contextId ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(sdkTask?.metadata?.['_contextId'] as string) ||
|
||||
getContextIdFromMetadata(sdkTask?.metadata) ||
|
||||
uuidv4();
|
||||
|
||||
logger.info(
|
||||
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
}
|
||||
} else {
|
||||
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = userMessage.metadata?.[
|
||||
'coderAgent'
|
||||
] as AgentSettings;
|
||||
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
|
||||
try {
|
||||
wrapper = await this.createTask(
|
||||
taskId,
|
||||
|
||||
@@ -14,19 +14,21 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { Task } from './task.js';
|
||||
import type {
|
||||
ToolCall,
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
GitService,
|
||||
CompletedToolCall,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
type Config,
|
||||
type ToolCallRequestInfo,
|
||||
type GitService,
|
||||
type CompletedToolCall,
|
||||
ApprovalMode,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockConfig } from '../utils/testing_utils.js';
|
||||
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
||||
import { CoderAgentEvent } from '../types.js';
|
||||
import type { ToolCall } from '@google/gemini-cli-core';
|
||||
|
||||
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -511,7 +513,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
@@ -531,7 +536,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAllMCPServerStatuses,
|
||||
MCPServerStatus,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
parseAndFormatApiError,
|
||||
safeLiteralReplace,
|
||||
DEFAULT_GUI_EDITOR,
|
||||
@@ -30,8 +31,7 @@ import {
|
||||
EDIT_TOOL_NAMES,
|
||||
processRestorableToolCalls,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { RequestContext } from '@a2a-js/sdk/server';
|
||||
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
@@ -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,19 +777,27 @@ 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.message ?? 'Unknown error from LLM stream';
|
||||
// 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(
|
||||
'[Task] Received error event from LLM stream:',
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
|
||||
if (errorEvent.value) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value);
|
||||
if (errorEvent?.value?.error) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value.error);
|
||||
}
|
||||
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
|
||||
this.setTaskStateAndPublishUpdate(
|
||||
@@ -813,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);
|
||||
|
||||
@@ -69,6 +69,7 @@ export class RestoreCommand implements Command {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const toolCallData = JSON.parse(data);
|
||||
const ToolCallDataSchema = getToolCallDataSchema();
|
||||
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
|
||||
|
||||
@@ -267,4 +267,47 @@ describe('loadConfig', () => {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool configuration', () => {
|
||||
it('should pass V1 allowedTools to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['shell', 'edit'],
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'edit'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass V2 tools.allowed to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['shell', 'fetch'],
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'fetch'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-tool'],
|
||||
tools: {
|
||||
allowed: ['v2-tool'],
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['v1-tool'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,17 +8,19 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
import type { TelemetryTarget } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
TelemetryTarget,
|
||||
ConfigParameters,
|
||||
ExtensionLoader,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
type ConfigParameters,
|
||||
FileDiscoveryService,
|
||||
ApprovalMode,
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
type ExtensionLoader,
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
@@ -66,8 +68,9 @@ export async function loadConfig(
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
|
||||
coreTools: settings.coreTools || undefined,
|
||||
excludeTools: settings.excludeTools || undefined,
|
||||
coreTools: settings.coreTools || settings.tools?.core || undefined,
|
||||
excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
|
||||
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
|
||||
showMemoryUsage: settings.showMemoryUsage || false,
|
||||
approvalMode:
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type MCPServerConfig,
|
||||
debugLogger,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
@@ -27,6 +27,12 @@ export interface Settings {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
coreTools?: string[];
|
||||
excludeTools?: string[];
|
||||
allowedTools?: string[];
|
||||
tools?: {
|
||||
allowed?: string[];
|
||||
exclude?: string[];
|
||||
core?: string[];
|
||||
};
|
||||
telemetry?: TelemetrySettings;
|
||||
showMemoryUsage?: boolean;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
@@ -122,6 +128,7 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
function resolveEnvVarsInString(value: string): string {
|
||||
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
|
||||
return value.replace(envVarRegex, (match, varName1, varName2) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const varName = varName1 || varName2;
|
||||
if (process && process.env && typeof process.env[varName] === 'string') {
|
||||
return process.env[varName];
|
||||
@@ -146,7 +153,7 @@ function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
ApprovalMode,
|
||||
type ToolCallConfirmationDetails,
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
TaskStatusUpdateEvent,
|
||||
SendStreamingMessageSuccessResponse,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import express from 'express';
|
||||
|
||||
import type { AgentCard, Message } from '@a2a-js/sdk';
|
||||
import type { TaskStore } from '@a2a-js/sdk/server';
|
||||
import {
|
||||
type TaskStore,
|
||||
DefaultRequestHandler,
|
||||
InMemoryTaskStore,
|
||||
DefaultExecutionEventBus,
|
||||
@@ -25,9 +25,12 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
import { loadExtensions } from '../config/extension.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
SimpleExtensionLoader,
|
||||
GitService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
|
||||
type CommandResponse = {
|
||||
name: string;
|
||||
@@ -88,6 +91,7 @@ async function handleExecuteCommand(
|
||||
},
|
||||
) {
|
||||
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { command, args } = req.body;
|
||||
try {
|
||||
if (typeof command !== 'string') {
|
||||
@@ -211,6 +215,7 @@ export async function createApp() {
|
||||
const agentSettings = req.body.agentSettings as
|
||||
| AgentSettings
|
||||
| undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const contextId = req.body.contextId || uuidv4();
|
||||
const wrapper = await agentExecutor.createTask(
|
||||
taskId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -246,6 +246,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
}
|
||||
const [compressedMetadata] = await metadataFile.download();
|
||||
const jsonData = gunzipSync(compressedMetadata).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const loadedMetadata = JSON.parse(jsonData);
|
||||
logger.info(`Task ${taskId} metadata loaded from GCS.`);
|
||||
|
||||
@@ -282,12 +283,14 @@ export class GCSTaskStore implements TaskStore {
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
contextId: loadedMetadata._contextId || uuidv4(),
|
||||
kind: 'task',
|
||||
status: {
|
||||
state: persistedState._taskState,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
metadata: loadedMetadata,
|
||||
history: [],
|
||||
artifacts: [],
|
||||
|
||||
@@ -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());
|
||||
|
||||
+17
-2
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
|
||||
/**
|
||||
* @license
|
||||
@@ -36,7 +36,21 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
await runExitCleanup();
|
||||
// Set a timeout to force exit if cleanup hangs
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
writeToStderr('Cleanup timed out, forcing exit...\n');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} catch (cleanupError) {
|
||||
writeToStderr(
|
||||
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
|
||||
if (error instanceof FatalError) {
|
||||
let errorMessage = error.message;
|
||||
@@ -46,6 +60,7 @@ main().catch(async (error) => {
|
||||
writeToStderr(errorMessage + '\n');
|
||||
process.exit(error.exitCode);
|
||||
}
|
||||
|
||||
writeToStderr('An unexpected critical error occurred:');
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/gemini-cli-devtools": "file:../devtools",
|
||||
"@google/genai": "1.30.0",
|
||||
"@google/genai": "1.41.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
@@ -82,7 +81,6 @@
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -16,14 +16,20 @@ import {
|
||||
} from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as core from '@google/gemini-cli-core';
|
||||
import type { inferInstallMetadata } from '../../config/extension-manager.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import type {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
promptForConsentNonInteractive,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import type {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import type * as fs from 'node:fs/promises';
|
||||
import type { Stats } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const mockInstallOrUpdateExtension: Mock<
|
||||
typeof ExtensionManager.prototype.installOrUpdateExtension
|
||||
@@ -31,28 +37,54 @@ const mockInstallOrUpdateExtension: Mock<
|
||||
const mockRequestConsentNonInteractive: Mock<
|
||||
typeof requestConsentNonInteractive
|
||||
> = vi.hoisted(() => vi.fn());
|
||||
const mockPromptForConsentNonInteractive: Mock<
|
||||
typeof promptForConsentNonInteractive
|
||||
> = vi.hoisted(() => vi.fn());
|
||||
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
||||
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
||||
() => vi.fn(),
|
||||
);
|
||||
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
|
||||
vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
|
||||
INSTALL_WARNING_MESSAGE: 'warning',
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: mockIsWorkspaceTrusted,
|
||||
loadTrustedFolders: mockLoadTrustedFolders,
|
||||
TrustLevel: {
|
||||
TRUST_FOLDER: 'TRUST_FOLDER',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||
loadExtensions: vi.fn(),
|
||||
})),
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
FolderTrustDiscoveryService: {
|
||||
discover: mockDiscover,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import('../../config/extension-manager.js')
|
||||
>()),
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
@@ -83,12 +115,31 @@ describe('handleInstall', () => {
|
||||
let processSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
debugLogSpy = vi.spyOn(debugLogger, 'log');
|
||||
debugErrorSpy = vi.spyOn(debugLogger, 'error');
|
||||
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
|
||||
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'installOrUpdateExtension',
|
||||
).mockImplementation(mockInstallOrUpdateExtension);
|
||||
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
settings: [],
|
||||
securityWarnings: [],
|
||||
discoveryErrors: [],
|
||||
});
|
||||
|
||||
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
@@ -114,12 +165,29 @@ describe('handleInstall', () => {
|
||||
mockStat.mockClear();
|
||||
mockInferInstallMetadata.mockClear();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockExtension(
|
||||
overrides: Partial<core.GeminiCLIExtension> = {},
|
||||
): core.GeminiCLIExtension {
|
||||
return {
|
||||
name: 'mock-extension',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/mock/path',
|
||||
contextFiles: [],
|
||||
id: 'mock-id',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('should install an extension from a http source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'http-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'http-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'http://google.com',
|
||||
@@ -131,9 +199,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a https source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'https-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'https-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'https://google.com',
|
||||
@@ -145,9 +215,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a git source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'git-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'git-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'git@some-url',
|
||||
@@ -171,9 +243,11 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a sso source', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'sso-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'sso-extension',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleInstall({
|
||||
source: 'sso://google.com',
|
||||
@@ -185,12 +259,14 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
||||
name: 'local-extension',
|
||||
} as unknown as GeminiCLIExtension);
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
await handleInstall({
|
||||
source: '/some/path',
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
@@ -208,4 +284,144 @@ describe('handleInstall', () => {
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should proceed if local path is already trusted', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'some', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and proceed if user accepts trust', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
const mockSetValue = vi.fn();
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: mockSetValue,
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'untrusted', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join('untrusted', 'path')),
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "local-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt and abort if user denies trust', async () => {
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(false);
|
||||
|
||||
await handleInstall({
|
||||
source: path.join('/', 'evil', 'path'),
|
||||
});
|
||||
|
||||
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installation aborted: Folder'),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should include discovery results in trust prompt', async () => {
|
||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||
createMockExtension({
|
||||
name: 'local-extension',
|
||||
}),
|
||||
);
|
||||
mockStat.mockResolvedValue({} as Stats);
|
||||
mockIsWorkspaceTrusted.mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
mockDiscover.mockResolvedValue({
|
||||
commands: ['custom-cmd'],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
skills: ['cool-skill'],
|
||||
settings: [],
|
||||
securityWarnings: ['Security risk!'],
|
||||
discoveryErrors: ['Read error'],
|
||||
});
|
||||
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||
mockLoadTrustedFolders.mockReturnValue({
|
||||
setValue: vi.fn(),
|
||||
user: { path: '', config: {} },
|
||||
errors: [],
|
||||
rules: [],
|
||||
isPathTrusted: vi.fn(),
|
||||
});
|
||||
|
||||
await handleInstall({
|
||||
source: '/untrusted/path',
|
||||
});
|
||||
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('This folder contains:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('custom-cmd'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cool-skill'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security Warnings:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security risk!'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Discovery Errors:'),
|
||||
false,
|
||||
);
|
||||
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Read error'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Implementation completed.
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
FolderTrustDiscoveryService,
|
||||
getRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
promptForConsentNonInteractive,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import {
|
||||
@@ -16,6 +22,11 @@ import {
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
TrustLevel,
|
||||
} from '../../config/trustedFolders.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
@@ -36,6 +47,95 @@ export async function handleInstall(args: InstallArgs) {
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
});
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||
const resolvedPath = getRealPath(source);
|
||||
installMetadata.source = resolvedPath;
|
||||
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
|
||||
if (trustResult.isTrusted !== true) {
|
||||
const discoveryResults =
|
||||
await FolderTrustDiscoveryService.discover(resolvedPath);
|
||||
|
||||
const hasDiscovery =
|
||||
discoveryResults.commands.length > 0 ||
|
||||
discoveryResults.mcps.length > 0 ||
|
||||
discoveryResults.hooks.length > 0 ||
|
||||
discoveryResults.skills.length > 0 ||
|
||||
discoveryResults.settings.length > 0;
|
||||
|
||||
const promptLines = [
|
||||
'',
|
||||
chalk.bold('Do you trust the files in this folder?'),
|
||||
'',
|
||||
`The extension source at "${resolvedPath}" is not trusted.`,
|
||||
'',
|
||||
'Trusting a folder allows Gemini CLI to load its local configurations,',
|
||||
'including custom commands, hooks, MCP servers, agent skills, and',
|
||||
'settings. These configurations could execute code on your behalf or',
|
||||
'change the behavior of the CLI.',
|
||||
'',
|
||||
];
|
||||
|
||||
if (discoveryResults.discoveryErrors.length > 0) {
|
||||
promptLines.push(chalk.red('❌ Discovery Errors:'));
|
||||
for (const error of discoveryResults.discoveryErrors) {
|
||||
promptLines.push(chalk.red(` • ${error}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (discoveryResults.securityWarnings.length > 0) {
|
||||
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
|
||||
for (const warning of discoveryResults.securityWarnings) {
|
||||
promptLines.push(chalk.yellow(` • ${warning}`));
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
if (hasDiscovery) {
|
||||
promptLines.push(chalk.bold('This folder contains:'));
|
||||
const groups = [
|
||||
{ label: 'Commands', items: discoveryResults.commands },
|
||||
{ label: 'MCP Servers', items: discoveryResults.mcps },
|
||||
{ label: 'Hooks', items: discoveryResults.hooks },
|
||||
{ label: 'Skills', items: discoveryResults.skills },
|
||||
{ label: 'Setting overrides', items: discoveryResults.settings },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
for (const group of groups) {
|
||||
promptLines.push(
|
||||
` • ${chalk.bold(group.label)} (${group.items.length}):`,
|
||||
);
|
||||
for (const item of group.items) {
|
||||
promptLines.push(` - ${item}`);
|
||||
}
|
||||
}
|
||||
promptLines.push('');
|
||||
}
|
||||
|
||||
promptLines.push(
|
||||
chalk.yellow(
|
||||
'Do you want to trust this folder and continue with the installation? [y/N]: ',
|
||||
),
|
||||
);
|
||||
|
||||
const confirmed = await promptForConsentNonInteractive(
|
||||
promptLines.join('\n'),
|
||||
false,
|
||||
);
|
||||
if (confirmed) {
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve(true)
|
||||
: requestConsentNonInteractive;
|
||||
@@ -44,12 +144,11 @@ export async function handleInstall(args: InstallArgs) {
|
||||
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
settings,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
const extension =
|
||||
|
||||
@@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.confirm;
|
||||
};
|
||||
|
||||
|
||||
@@ -2016,6 +2016,40 @@ describe('loadCliConfig useRipgrep', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig directWebFetch', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should be false by default when directWebFetch is not set in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getDirectWebFetch()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when directWebFetch is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
directWebFetch: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getDirectWebFetch()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screenReader configuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -826,7 +826,8 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
planSettings: settings.general.plan,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
@@ -848,7 +849,6 @@ export async function loadCliConfig(
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
@@ -878,6 +878,7 @@ export async function loadCliConfig(
|
||||
agents: refreshedSettings.merged.agents,
|
||||
};
|
||||
},
|
||||
enableConseca: settings.security?.enableConseca,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
type CommandHookConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
@@ -248,9 +252,11 @@ System using model: \${MODEL_NAME}
|
||||
|
||||
expect(extension.hooks).toBeDefined();
|
||||
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
||||
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
|
||||
'hello-world',
|
||||
);
|
||||
expect(
|
||||
(extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
|
||||
'HOOK_CMD'
|
||||
],
|
||||
).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should pick up new settings after restartExtension', async () => {
|
||||
@@ -292,8 +298,9 @@ System using model: \${MODEL_NAME}
|
||||
});
|
||||
expect(extension.skills![0].body).toContain('Value is: first');
|
||||
|
||||
const { updateSetting, ExtensionSettingScope } =
|
||||
await import('./extensions/extensionSettings.js');
|
||||
const { updateSetting, ExtensionSettingScope } = await import(
|
||||
'./extensions/extensionSettings.js'
|
||||
);
|
||||
const extensionConfig =
|
||||
await extensionManager.loadExtensionConfig(extensionPath);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionUpdateEvent,
|
||||
getErrorMessage,
|
||||
getRealPath,
|
||||
logExtensionDisable,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
CoreToolCallStatus,
|
||||
HookType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -202,13 +204,11 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(
|
||||
this.workspaceDir,
|
||||
installMetadata.source,
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||
installMetadata.source = getRealPath(
|
||||
path.isAbsolute(installMetadata.source)
|
||||
? installMetadata.source
|
||||
: path.resolve(this.workspaceDir, installMetadata.source),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -736,8 +736,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
for (const hook of definition.hooks) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
if (hook.type === HookType.Command) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,6 +868,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
try {
|
||||
const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const rawHooks = JSON.parse(hooksContent);
|
||||
|
||||
if (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
KeychainTokenStorage,
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -186,11 +187,11 @@ describe('extension tests', () => {
|
||||
errors: [],
|
||||
});
|
||||
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
tempHomeDir = getRealPath(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
tempWorkspaceDir = getRealPath(
|
||||
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
|
||||
);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
mockRequestConsent = vi.fn();
|
||||
@@ -329,12 +330,14 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
@@ -361,18 +364,20 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension-with-path',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['${extensionPath}${/}server${/}index.js'],
|
||||
cwd: '${extensionPath}${/}server',
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension-with-path',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['${extensionPath}${/}server${/}index.js'],
|
||||
cwd: '${extensionPath}${/}server',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
@@ -844,11 +849,13 @@ describe('extension tests', () => {
|
||||
|
||||
it('should generate id from the original source for linked extensions', async () => {
|
||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||
const actualExtensionDir = createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const actualExtensionDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: extDevelopmentDir,
|
||||
name: 'link-ext-name',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.installOrUpdateExtension({
|
||||
type: 'link',
|
||||
@@ -994,11 +1001,13 @@ describe('extension tests', () => {
|
||||
|
||||
describe('installExtension', () => {
|
||||
it('should install an extension from a local path', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
@@ -1040,7 +1049,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
|
||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
@@ -1056,7 +1065,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
|
||||
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
|
||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
||||
@@ -1066,22 +1075,17 @@ describe('extension tests', () => {
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^Failed to load extension config from ${configPath.replace(
|
||||
/\\/g,
|
||||
'\\\\',
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
|
||||
});
|
||||
|
||||
it('should throw an error for missing name in gemini-extension.json', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'missing-name-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'missing-name-ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
// Overwrite with invalid config
|
||||
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
||||
@@ -1134,11 +1138,13 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should install a linked extension', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
@@ -1439,11 +1445,13 @@ ${INSTALL_WARNING_MESSAGE}`,
|
||||
});
|
||||
|
||||
it('should save the autoUpdate flag to the install metadata', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const sourceExtDir = getRealPath(
|
||||
createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
|
||||
@@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => {
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return irrelevant results', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
...mockExtensions,
|
||||
{
|
||||
id: 'dataplex',
|
||||
extensionName: 'dataplex',
|
||||
extensionDescription: 'Connect to Dataplex Universal Catalog...',
|
||||
fullName: 'google-cloud/dataplex',
|
||||
rank: 6,
|
||||
stars: 6,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'conductor',
|
||||
extensionName: 'conductor',
|
||||
extensionDescription: 'A conductor extension that actually matches.',
|
||||
fullName: 'someone/conductor',
|
||||
rank: 100,
|
||||
stars: 100,
|
||||
url: '',
|
||||
repoDescription: '',
|
||||
lastUpdated: '',
|
||||
extensionVersion: '1.0.0',
|
||||
avatarUrl: '',
|
||||
hasMCP: false,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: '',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('conductor');
|
||||
const ids = results.map((r) => r.id);
|
||||
|
||||
expect(ids).not.toContain('dataplex');
|
||||
expect(ids).toContain('conductor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,9 +79,11 @@ export class ExtensionRegistryClient {
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
fuzzy: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const results = await fzf.find(query);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
}
|
||||
|
||||
@@ -108,7 +110,6 @@ export class ExtensionRegistryClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('consent', () => {
|
||||
{ input: '', expected: true },
|
||||
{ input: 'n', expected: false },
|
||||
{ input: 'N', expected: false },
|
||||
{ input: 'yes', expected: false },
|
||||
{ input: 'yes', expected: true },
|
||||
])(
|
||||
'should return $expected for input "$input"',
|
||||
async ({ input, expected }) => {
|
||||
|
||||
@@ -91,10 +91,12 @@ export async function requestConsentInteractive(
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
* @param defaultValue Whether to resolve as true or false on enter.
|
||||
* @returns Whether or not the user answers 'y' (yes).
|
||||
*/
|
||||
async function promptForConsentNonInteractive(
|
||||
export async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
defaultValue = true,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
@@ -105,7 +107,12 @@ async function promptForConsentNonInteractive(
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
||||
const trimmedAnswer = answer.trim().toLowerCase();
|
||||
if (trimmedAnswer === '') {
|
||||
resolve(defaultValue);
|
||||
} else {
|
||||
resolve(['y', 'yes'].includes(trimmedAnswer));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export class ExtensionEnablementManager {
|
||||
readConfig(): AllExtensionsEnablementConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(this.configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -590,6 +590,29 @@ describe('extensionSettings', () => {
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore .env if it is a directory', async () => {
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceEnvPath);
|
||||
const workspaceKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||
);
|
||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||
|
||||
const contents = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
expect(contents).toEqual({
|
||||
SENSITIVE_VAR: 'workspace-secret',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvContents (merged)', () => {
|
||||
@@ -696,6 +719,26 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toContain('VAR1=new-workspace-value');
|
||||
});
|
||||
|
||||
it('should throw an error when trying to write to a workspace with a .env directory', async () => {
|
||||
const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
|
||||
fs.mkdirSync(workspaceEnvPath);
|
||||
|
||||
mockRequestSetting.mockResolvedValue('new-workspace-value');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/Cannot write extension settings to .* because it is a directory./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a sensitive setting in USER scope', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value2');
|
||||
|
||||
|
||||
@@ -124,6 +124,15 @@ export async function maybePromptForSettings(
|
||||
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
@@ -156,6 +165,7 @@ export async function promptForSetting(
|
||||
name: 'value',
|
||||
message: `${setting.name}\n${setting.description}`,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return response.value;
|
||||
}
|
||||
|
||||
@@ -172,8 +182,11 @@ export async function getScopedEnvContents(
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (!stat.isDirectory()) {
|
||||
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
|
||||
customEnv = dotenv.parse(envFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionConfig.settings) {
|
||||
@@ -259,6 +272,12 @@ export async function updateSetting(
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
let envContent = '';
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Cannot write extension settings to ${envFilePath} because it is a directory.`,
|
||||
);
|
||||
}
|
||||
envContent = await fs.readFile(envFilePath, 'utf-8');
|
||||
}
|
||||
|
||||
@@ -323,7 +342,10 @@ async function clearSettings(
|
||||
keychain: KeychainTokenStorage,
|
||||
) {
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
const stat = fsSync.statSync(envFilePath);
|
||||
if (!stat.isDirectory()) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
}
|
||||
if (!(await keychain.isAvailable())) {
|
||||
return;
|
||||
|
||||
@@ -58,6 +58,7 @@ export function recursivelyHydrateStrings<T>(
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should handle global MCP wildcard (*) in settings', async () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
allowed: ['*'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// ANY tool with a server name should be allowed
|
||||
expect(
|
||||
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
|
||||
.decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Built-in tools should NOT be allowed by the MCP wildcard
|
||||
expect(
|
||||
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
@@ -323,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) => {
|
||||
@@ -339,6 +400,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
|
||||
'C:\\Users\\user\\.gemini\\tmp\\project-id\\session-id\\plans\\plan.md',
|
||||
'D:\\gemini-cli\\.gemini\\tmp\\project-id\\session-1\\plans\\plan.md', // no session ID
|
||||
];
|
||||
|
||||
for (const file_path of validPaths) {
|
||||
@@ -364,7 +427,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const invalidPaths = [
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal (Unix)
|
||||
'C:\\Users\\user\\.gemini\\tmp\\id\\session\\plans\\..\\..\\..\\Windows\\System32\\config\\SAM', // Path traversal (Windows)
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,9 @@ export async function createPolicyEngineConfig(
|
||||
export function createPolicyUpdater(
|
||||
policyEngine: PolicyEngine,
|
||||
messageBus: MessageBus,
|
||||
storage: Storage,
|
||||
) {
|
||||
return createCorePolicyUpdater(policyEngine, messageBus);
|
||||
return createCorePolicyUpdater(policyEngine, messageBus, storage);
|
||||
}
|
||||
|
||||
export interface WorkspacePolicyState {
|
||||
@@ -66,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',
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('settings-validation', () => {
|
||||
expect(formatted).toContain('Expected: string, but received: object');
|
||||
expect(formatted).toContain('Please fix the configuration.');
|
||||
expect(formatted).toContain(
|
||||
'https://github.com/google-gemini/gemini-cli',
|
||||
'https://geminicli.com/docs/reference/configuration/',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -364,9 +364,8 @@ describe('settings-validation', () => {
|
||||
const formatted = formatValidationError(result.error, 'test.json');
|
||||
|
||||
expect(formatted).toContain(
|
||||
'https://github.com/google-gemini/gemini-cli',
|
||||
'https://geminicli.com/docs/reference/configuration/',
|
||||
);
|
||||
expect(formatted).toContain('configuration.md');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -327,9 +327,7 @@ export function formatValidationError(
|
||||
}
|
||||
|
||||
lines.push('Please fix the configuration.');
|
||||
lines.push(
|
||||
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
|
||||
);
|
||||
lines.push('See: https://geminicli.com/docs/reference/configuration/');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -75,10 +75,12 @@ import {
|
||||
SettingScope,
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -126,6 +128,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
const pathMod = await import('node:path');
|
||||
const fsMod = await import('node:fs');
|
||||
|
||||
// Helper to resolve paths using the test's mocked environment
|
||||
const testResolve = (p: string | undefined) => {
|
||||
if (!p) return '';
|
||||
try {
|
||||
// Use the mocked fs.realpathSync if available, otherwise fallback
|
||||
return fsMod.realpathSync(pathMod.resolve(p));
|
||||
} catch {
|
||||
return pathMod.resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a smarter mock for isWorkspaceHomeDir
|
||||
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
|
||||
function (this: Storage) {
|
||||
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
|
||||
// Pick up the mocked home directory specifically from the 'os' mock
|
||||
const home = testResolve(os.homedir());
|
||||
return actual.normalizePath(target) === actual.normalizePath(home);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
@@ -1491,20 +1517,29 @@ describe('Settings Loading and Merging', () => {
|
||||
return pStr;
|
||||
});
|
||||
|
||||
// Force the storage check to return true for this specific test
|
||||
const isWorkspaceHomeDirSpy = vi
|
||||
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
|
||||
.mockReturnValue(true);
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
try {
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
} finally {
|
||||
isWorkspaceHomeDirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1804,36 +1839,50 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files from untrusted spaces', () => {
|
||||
it('does not load env files from untrusted spaces when sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: true },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined', () => {
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: false },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
delete process.env['TESTTEST'];
|
||||
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
tools: { sandbox: true },
|
||||
} as Settings;
|
||||
|
||||
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.merged.tools.sandbox = true;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { sandbox: true },
|
||||
});
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
@@ -1846,10 +1895,10 @@ describe('Settings Loading and Merging', () => {
|
||||
process.argv.push('-s');
|
||||
try {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure sandbox is NOT in settings to test argv sniffing
|
||||
settings.merged.tools.sandbox = undefined;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { sandbox: false },
|
||||
});
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
@@ -2748,7 +2797,7 @@ describe('Settings Loading and Merging', () => {
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||
});
|
||||
|
||||
it('should NOT be tricked by positional arguments that look like flags', () => {
|
||||
@@ -2767,7 +2816,7 @@ describe('Settings Loading and Merging', () => {
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -573,10 +573,6 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
if (trustResult.isTrusted !== true && !isSandboxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
@@ -637,24 +633,8 @@ export function loadSettings(
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
const systemDefaultsPath = getSystemDefaultsPath();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
try {
|
||||
// fs.realpathSync gets the "true" path, resolving any symlinks
|
||||
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
||||
} catch (_e) {
|
||||
// This is okay. The path might not exist yet, and that's a valid state.
|
||||
}
|
||||
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = new Storage(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
@@ -712,7 +692,7 @@ export function loadSettings(
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
@@ -800,11 +780,11 @@ export function loadSettings(
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
|
||||
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: realWorkspaceDir === realHomeDir,
|
||||
readOnly: storage.isWorkspaceHomeDir(),
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
|
||||
@@ -285,18 +285,18 @@ const SETTINGS_SCHEMA = {
|
||||
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
modelRouting: {
|
||||
type: 'boolean',
|
||||
label: 'Plan Model Routing',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable AI-powered prompt completion suggestions while typing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
retryFetchErrors: {
|
||||
type: 'boolean',
|
||||
label: 'Retry Fetch Errors',
|
||||
@@ -307,6 +307,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',
|
||||
@@ -974,6 +984,60 @@ const SETTINGS_SCHEMA = {
|
||||
ref: 'AgentOverride',
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
type: 'object',
|
||||
label: 'Browser Agent',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings specific to the browser agent.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
sessionMode: {
|
||||
type: 'enum',
|
||||
label: 'Browser Session Mode',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: 'persistent',
|
||||
description:
|
||||
"Session mode: 'persistent', 'isolated', or 'existing'.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ value: 'persistent', label: 'Persistent' },
|
||||
{ value: 'isolated', label: 'Isolated' },
|
||||
{ value: 'existing', label: 'Existing' },
|
||||
],
|
||||
},
|
||||
headless: {
|
||||
type: 'boolean',
|
||||
label: 'Browser Headless',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Run browser in headless mode.',
|
||||
showInDialog: false,
|
||||
},
|
||||
profilePath: {
|
||||
type: 'string',
|
||||
label: 'Browser Profile Path',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Path to browser profile directory for session persistence.',
|
||||
showInDialog: false,
|
||||
},
|
||||
visualModel: {
|
||||
type: 'string',
|
||||
label: 'Browser Visual Model',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'Model override for the visual agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1493,6 +1557,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1703,6 +1777,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ describe('Settings Repro', () => {
|
||||
},
|
||||
general: {
|
||||
debugKeystrokeLogging: false,
|
||||
enablePromptCompletion: false,
|
||||
preferredEditor: 'vim',
|
||||
vimMode: false,
|
||||
},
|
||||
|
||||
@@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
@@ -746,8 +748,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it.skip('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { cleanupExpiredSessions } =
|
||||
await import('./utils/sessionCleanup.js');
|
||||
const { cleanupExpiredSessions } = await import(
|
||||
'./utils/sessionCleanup.js'
|
||||
);
|
||||
vi.mocked(cleanupExpiredSessions).mockRejectedValue(
|
||||
new Error('Cleanup failed'),
|
||||
);
|
||||
@@ -1192,7 +1195,9 @@ describe('startInteractiveUI', () => {
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
const mockStartupWarnings = ['warning1'];
|
||||
const mockStartupWarnings: StartupWarning[] = [
|
||||
{ id: 'w1', message: 'warning1', priority: WarningPriority.High },
|
||||
];
|
||||
const mockWorkspaceRoot = '/root';
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
@@ -1225,7 +1230,7 @@ describe('startInteractiveUI', () => {
|
||||
async function startTestInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
|
||||
+36
-23
@@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
@@ -99,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
@@ -180,7 +184,7 @@ ${reason.stack}`
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
@@ -235,17 +239,19 @@ export async function startInteractiveUI(
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
@@ -568,14 +574,10 @@ export async function main() {
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { setupInitialActivityLogger, stopDevTools } =
|
||||
await import('./utils/devtoolsService.js');
|
||||
const { ActivityLogger } = await import('./utils/activityLogger.js');
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
registerCleanup(async () => {
|
||||
await stopDevTools();
|
||||
ActivityLogger.getInstance().dispose();
|
||||
});
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -584,7 +586,7 @@ export async function main() {
|
||||
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
const messageBus = config.getMessageBus();
|
||||
createPolicyUpdater(policyEngine, messageBus);
|
||||
createPolicyUpdater(policyEngine, messageBus, config.storage);
|
||||
|
||||
// Register SessionEnd hook to fire on graceful exit
|
||||
// This runs before telemetry shutdown in runExitCleanup()
|
||||
@@ -672,9 +674,20 @@ export async function main() {
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
...(await getUserStartupWarnings(settings.merged)),
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
message,
|
||||
priority: WarningPriority.High,
|
||||
})),
|
||||
...(await getUserStartupWarnings(settings.merged, undefined, {
|
||||
isAlternateBuffer: useAlternateBuffer,
|
||||
})),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
|
||||
@@ -180,8 +180,9 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
});
|
||||
|
||||
it('should log error when cleanupExpiredSessions fails', async () => {
|
||||
const { loadCliConfig, parseArguments } =
|
||||
await import('./config/config.js');
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
cleanupMockState.shouldThrow = true;
|
||||
cleanupMockState.called = false;
|
||||
|
||||
@@ -212,8 +212,9 @@ describe('runNonInteractive', () => {
|
||||
computeMergedSettings: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { handleAtCommand } =
|
||||
await import('./ui/hooks/atCommandProcessor.js');
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
|
||||
processedQuery: [{ text: query }],
|
||||
}));
|
||||
@@ -635,8 +636,9 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should preprocess @include commands before sending to the model', async () => {
|
||||
// 1. Mock the imported atCommandProcessor
|
||||
const { handleAtCommand } =
|
||||
await import('./ui/hooks/atCommandProcessor.js');
|
||||
const { handleAtCommand } = await import(
|
||||
'./ui/hooks/atCommandProcessor.js'
|
||||
);
|
||||
const mockHandleAtCommand = vi.mocked(handleAtCommand);
|
||||
|
||||
// 2. Define the raw input and the expected processed output
|
||||
@@ -993,8 +995,9 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
it('should handle slash commands', async () => {
|
||||
const nonInteractiveCliCommands =
|
||||
await import('./nonInteractiveCliCommands.js');
|
||||
const nonInteractiveCliCommands = await import(
|
||||
'./nonInteractiveCliCommands.js'
|
||||
);
|
||||
const handleSlashCommandSpy = vi.spyOn(
|
||||
nonInteractiveCliCommands,
|
||||
'handleSlashCommand',
|
||||
@@ -1273,11 +1276,13 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should instantiate CommandService with correct loaders for slash commands', async () => {
|
||||
// This test indirectly checks that handleSlashCommand is using the right loaders.
|
||||
const { FileCommandLoader } =
|
||||
await import('./services/FileCommandLoader.js');
|
||||
const { FileCommandLoader } = await import(
|
||||
'./services/FileCommandLoader.js'
|
||||
);
|
||||
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
|
||||
const { BuiltinCommandLoader } =
|
||||
await import('./services/BuiltinCommandLoader.js');
|
||||
const { BuiltinCommandLoader } = await import(
|
||||
'./services/BuiltinCommandLoader.js'
|
||||
);
|
||||
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
@@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
@@ -72,8 +72,9 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { setupInitialActivityLogger } =
|
||||
await import('./utils/devtoolsService.js');
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
@@ -219,9 +220,9 @@ export async function runNonInteractive({
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToHistoryFormats(
|
||||
convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
).clientHistory,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ import { AuthState } from '../ui/types.js';
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const { MockShellExecutionService: MockService } =
|
||||
await import('./MockShellExecutionService.js');
|
||||
const { MockShellExecutionService: MockService } = await import(
|
||||
'./MockShellExecutionService.js'
|
||||
);
|
||||
// Register the real execution logic so MockShellExecutionService can fall back to it
|
||||
MockService.setOriginalImplementation(original.ShellExecutionService.execute);
|
||||
|
||||
@@ -216,13 +217,14 @@ export class AppRig {
|
||||
}
|
||||
|
||||
private stubRefreshAuth() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
|
||||
const gcConfig = this.config as any;
|
||||
gcConfig.refreshAuth = async (authMethod: AuthType) => {
|
||||
gcConfig.modelAvailabilityService.reset();
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
|
||||
@@ -45,7 +45,7 @@ export const createMockCommandContext = (
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -54,7 +54,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -94,11 +94,14 @@ export const createMockCommandContext = (
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const merge = (target: any, source: any): any => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const output = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const sourceValue = source[key];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
@@ -106,9 +109,11 @@ export const createMockCommandContext = (
|
||||
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
|
||||
Object.prototype.toString.call(targetValue) === '[object Object]'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
@@ -116,5 +121,6 @@ export const createMockCommandContext = (
|
||||
return output;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return merge(defaultMocks, overrides);
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
|
||||
getApprovedPlanPath: vi.fn(() => undefined),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
@@ -128,7 +130,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
|
||||
import {
|
||||
render as inkRenderDirect,
|
||||
type Instance as InkInstance,
|
||||
type RenderOptions,
|
||||
} from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Box } from 'ink';
|
||||
import type React from 'react';
|
||||
@@ -31,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
import {
|
||||
OverflowProvider,
|
||||
useOverflowActions,
|
||||
useOverflowState,
|
||||
type OverflowActions,
|
||||
type OverflowState,
|
||||
} from '../ui/contexts/OverflowContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -68,6 +79,25 @@ type TerminalState = {
|
||||
rows: number;
|
||||
};
|
||||
|
||||
type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];
|
||||
|
||||
interface InkRenderMetrics extends RenderMetrics {
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
|
||||
function isInkRenderMetrics(
|
||||
metrics: RenderMetrics,
|
||||
): metrics is InkRenderMetrics {
|
||||
const m = metrics as Record<string, unknown>;
|
||||
return (
|
||||
typeof m === 'object' &&
|
||||
m !== null &&
|
||||
'output' in m &&
|
||||
typeof m['output'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
class XtermStdout extends EventEmitter {
|
||||
private state: TerminalState;
|
||||
private pendingWrites = 0;
|
||||
@@ -312,6 +342,8 @@ export type RenderInstance = {
|
||||
lastFrame: (options?: { allowEmpty?: boolean }) => string;
|
||||
terminal: Terminal;
|
||||
waitUntilReady: () => Promise<void>;
|
||||
capturedOverflowState: OverflowState | undefined;
|
||||
capturedOverflowActions: OverflowActions | undefined;
|
||||
};
|
||||
|
||||
const instances: InkInstance[] = [];
|
||||
@@ -320,7 +352,10 @@ const instances: InkInstance[] = [];
|
||||
export const render = (
|
||||
tree: React.ReactElement,
|
||||
terminalWidth?: number,
|
||||
): RenderInstance => {
|
||||
): Omit<
|
||||
RenderInstance,
|
||||
'capturedOverflowState' | 'capturedOverflowActions'
|
||||
> => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||
// value was used (e.g. 40 rows). The alternatives to make things worse are
|
||||
@@ -357,9 +392,12 @@ export const render = (
|
||||
debug: false,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onRender: (metrics: any) => {
|
||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
|
||||
const staticOutput = isInkRenderMetrics(metrics)
|
||||
? (metrics.staticOutput ?? '')
|
||||
: '';
|
||||
stdout.onRender(staticOutput, output);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -538,6 +576,16 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
capturedOverflowState = useOverflowState();
|
||||
capturedOverflowActions = useOverflowActions();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
component: React.ReactElement,
|
||||
{
|
||||
@@ -639,6 +687,9 @@ export const renderWithProviders = (
|
||||
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
||||
.flatMap((item) => item.tools);
|
||||
|
||||
capturedOverflowState = undefined;
|
||||
capturedOverflowActions = undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
@@ -651,35 +702,39 @@ export const renderWithProviders = (
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
@@ -694,6 +749,8 @@ export const renderWithProviders = (
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
capturedOverflowState,
|
||||
capturedOverflowActions,
|
||||
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
|
||||
simulateClick(renderResult.stdin, col, row, button),
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export const createMockSettings = (
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
@@ -75,7 +76,7 @@ export const createMockSettings = (
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
CoreEvent,
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
AuthType,
|
||||
type AgentDefinition,
|
||||
CoreToolCallStatus,
|
||||
@@ -103,6 +105,11 @@ import {
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import {
|
||||
useOverflowActions,
|
||||
type OverflowActions,
|
||||
} from './contexts/OverflowContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -118,9 +125,11 @@ vi.mock('ink', async (importOriginal) => {
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -227,7 +236,10 @@ import {
|
||||
disableMouseEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
|
||||
describe('AppContainer State Management', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -248,18 +260,20 @@ describe('AppContainer State Management', () => {
|
||||
config?: Config;
|
||||
version?: string;
|
||||
initResult?: InitializationResult;
|
||||
startupWarnings?: string[];
|
||||
startupWarnings?: StartupWarning[];
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -501,7 +515,18 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('renders with startup warnings', async () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
message: 'Warning 1',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
message: 'Warning 2',
|
||||
priority: WarningPriority.High,
|
||||
},
|
||||
];
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
@@ -2674,12 +2699,14 @@ describe('AppContainer State Management', () => {
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
@@ -3290,10 +3317,311 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submission Handling', () => {
|
||||
it('resets expansion state on submission when not in alternate buffer', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout to clear any initial writes
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should refresh static (which clears terminal in non-alternate buffer)
|
||||
expect(mocks.mockStdout.write).toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
settings: {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
|
||||
},
|
||||
} as LoadedSettings,
|
||||
}).unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
// Expand first
|
||||
act(() => capturedUIActions.setConstrainHeight(false));
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
|
||||
// Reset mock stdout
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
// Submit
|
||||
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
|
||||
|
||||
// Should be reset
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
// Should NOT refresh static's clearTerminal in alternate buffer
|
||||
expect(mocks.mockStdout.write).not.toHaveBeenCalledWith(
|
||||
ansiEscapes.clearTerminal,
|
||||
);
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overflow Hint Handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show hint because we are in Standard Mode (default settings) and have overflow
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance just before the timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance to hit the timeout mark
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Simulate Ctrl+O
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // \x0f is Ctrl+O
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// constrainHeight should toggle
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Advance enough that the original timer would have expired if it hadn't reset
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000);
|
||||
});
|
||||
|
||||
// We expect it to still be true because Ctrl+O should have reset the timer
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Advance remaining time to reach the new timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => {
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof renderAppContainer>['stdin'];
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Initial state is constrainHeight = true
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
|
||||
// Trigger overflow so the hint starts showing
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
});
|
||||
|
||||
// Advance half the duration
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// First toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Second toggle 'off' (collapsed)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(true);
|
||||
});
|
||||
|
||||
// Wait 1 second
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Third toggle 'on' (expanded)
|
||||
act(() => {
|
||||
stdin.write('\x0f'); // Ctrl+O
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.constrainHeight).toBe(false);
|
||||
});
|
||||
|
||||
// Now we wait just before the timeout from the LAST toggle.
|
||||
// It should still be true.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100);
|
||||
});
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(true);
|
||||
|
||||
// Wait 0.1s more to hit exactly the timeout since the last toggle.
|
||||
// It should hide now.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
});
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
|
||||
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const settingsWithAlternateBuffer = {
|
||||
merged: {
|
||||
...alternateSettings,
|
||||
ui: {
|
||||
...alternateSettings.ui,
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
settings: settingsWithAlternateBuffer,
|
||||
});
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
// Trigger overflow
|
||||
act(() => {
|
||||
capturedOverflowActions.addOverflowingId('test-id');
|
||||
});
|
||||
|
||||
// Should NOT show hint because we are in Alternate Buffer Mode
|
||||
expect(capturedUIState.showIsExpandableHint).toBe(false);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('shows permission dialog when checkPermissions returns paths', async () => {
|
||||
const { checkPermissions } =
|
||||
await import('./hooks/atCommandProcessor.js');
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
|
||||
let unmount: () => void;
|
||||
@@ -3315,8 +3643,9 @@ describe('AppContainer State Management', () => {
|
||||
it.each([true, false])(
|
||||
'handles permissions when allowed is %s',
|
||||
async (allowed) => {
|
||||
const { checkPermissions } =
|
||||
await import('./hooks/atCommandProcessor.js');
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
const addReadOnlyPathSpy = vi.spyOn(
|
||||
mockConfig.getWorkspaceContext(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user