diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 773e4cc871..37d896381d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,41 +1,42 @@ -## TLDR +## Summary - + -## Dive Deeper +## Details - + -## Reviewer Test Plan +## Related Issues - + -## Testing Matrix +## How to Validate - + -| | šŸ | 🪟 | 🐧 | -| -------- | --- | --- | --- | -| npm run | ā“ | ā“ | ā“ | -| npx | ā“ | ā“ | ā“ | -| Docker | ā“ | ā“ | ā“ | -| Podman | ā“ | - | - | -| Seatbelt | ā“ | - | - | +## Pre-Merge Checklist -## Linked issues / bugs + - +- [ ] Updated relevant documentation and README (if needed) +- [ ] Added/updated tests (if needed) +- [ ] Noted breaking changes (if any) +- [ ] Validated on required platforms/methods: + - [ ] MacOS + - [ ] npm run + - [ ] npx + - [ ] Docker + - [ ] Podman + - [ ] Seatbelt + - [ ] Windows + - [ ] npm run + - [ ] npx + - [ ] Docker + - [ ] Linux + - [ ] npm run + - [ ] npx + - [ ] Docker diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 7ef200a0f1..f4214aadcf 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -2,7 +2,7 @@ name: 'Release: Nightly' on: schedule: - - cron: '0 20 * * *' + - cron: '0 0 * * *' workflow_dispatch: inputs: dry_run: diff --git a/.github/workflows/test_chained_e2e.yml b/.github/workflows/test_chained_e2e.yml index adb77ffa03..8ded1a7591 100644 --- a/.github/workflows/test_chained_e2e.yml +++ b/.github/workflows/test_chained_e2e.yml @@ -18,9 +18,9 @@ on: required: true concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + group: '${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.ref }}' cancel-in-progress: |- - ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} + ${{ github.event_name != 'push' && github.event_name != 'merge_group' }} permissions: contents: 'read' @@ -99,6 +99,7 @@ jobs: set_pending_status: runs-on: 'gemini-cli-ubuntu-16-core' + permissions: 'write-all' if: "github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'" needs: - 'parse_run_context' @@ -286,6 +287,7 @@ jobs: set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' + permissions: 'write-all' if: "github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'" needs: - 'parse_run_context' diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index dd6079cee2..c8cfe5d744 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -15,7 +15,7 @@ jobs: steps: - name: 'Save Repo name' env: - # Replace with github.event.pull_request.base.repo.full_name when switched to listen on pull request events. This repo name does not contain the org which is needed for checkout. + # Replace with github.event.pull_request.head.repo.full_name when switched to listen on pull request events. This repo name does not contain the org which is needed for checkout. REPO_NAME: '${{ github.event.repository.name }}' run: | mkdir -p ./pr diff --git a/.gitignore b/.gitignore index 5f3245db3a..5680ca7d46 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ patch_output.log # Local npm configuration .npmrc +.genkit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fac4279ff4..5df65605ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,18 @@ # How to Contribute -We would love to accept your patches and contributions to this project. +We would love to accept your patches and contributions to this project. This +document includes: + +- **[Before you begin](#before-you-begin):** Essential steps to take before + becoming a Gemini CLI contributor. +- **[Code contribution process](#code-contribution-process):** How to contribute + code to Gemini CLI. +- **[Development setup and workflow](#development-setup-and-workflow):** How to + set up your development environment and workflow. +- **[Documentation contribution process](#documentation-contribution-process):** + How to contribute documentation to Gemini CLI. + +We're looking forward to seeing your contributions! ## Before you begin @@ -23,15 +35,25 @@ sign a new one. This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). -## Contribution Process +## Code contribution process -### Code Reviews +### Get started + +The process for contributing code is as follows: + +1. **Find an issue** that you want to work on. +2. **Fork the repository** and create a new branch. +3. **Make your changes** in the `packages/` directory. +4. **Ensure all checks pass** by running `npm run preflight`. +5. **Open a pull request** with your changes. + +### Code reviews All submissions, including submissions by project members, require review. We use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests) for this purpose. -### Self Assigning Issues +### Self assigning issues If you're looking for an issue to work on, check out our list of issues that are labeled @@ -44,12 +66,12 @@ assign the issue to you, provided it is not already assigned. Please note that you can have a maximum of 3 issues assigned to you at any given time. -### Pull Request Guidelines +### Pull request guidelines To help us review and merge your PRs quickly, please follow these guidelines. PRs that do not meet these standards may be closed. -#### 1. Link to an Existing Issue +#### 1. Link to an existing issue All PRs should be linked to an existing issue in our tracker. This ensures that every change has been discussed and is aligned with the project's goals before @@ -62,7 +84,7 @@ any code is written. If an issue for your change doesn't exist, please **open one first** and wait for feedback before you start coding. -#### 2. Keep It Small and Focused +#### 2. Keep it small and focused We favor small, atomic PRs that address a single issue or add a single, self-contained feature. @@ -74,37 +96,40 @@ self-contained feature. Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. -#### 3. Use Draft PRs for Work in Progress +#### 3. Use draft PRs for work in progress If you'd like to get early feedback on your work, please use GitHub's **Draft Pull Request** feature. This signals to the maintainers that the PR is not yet ready for a formal review but is open for discussion and initial feedback. -#### 4. Ensure All Checks Pass +#### 4. Ensure all checks pass Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks. -#### 5. Update Documentation +#### 5. Update documentation If your PR introduces a user-facing change (e.g., a new command, a modified flag, or a change in behavior), you must also update the relevant documentation in the `/docs` directory. -#### 6. Write Clear Commit Messages and a Good PR Description +See more about writing documentation: +[Documentation contribution process](#documentation-contribution-process). + +#### 6. Write clear commit messages and a good PR description Your PR should have a clear, descriptive title and a detailed description of the changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard for your commit messages. -- **Good PR Title:** `feat(cli): Add --json flag to 'config get' command` -- **Bad PR Title:** `Made some changes` +- **Good PR title:** `feat(cli): Add --json flag to 'config get' command` +- **Bad PR title:** `Made some changes` In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`). -## Forking +### Forking If you are forking the repository you will be able to run the Build, Test and Integration test workflows. However in order to make the integration tests run @@ -118,12 +143,12 @@ Additionally you will need to click on the `Actions` tab and enable workflows for your repository, you'll find it's the large blue button in the center of the screen. -## Development Setup and Workflow +### Development setup and workflow This section guides contributors on how to build, modify, and understand the development setup of this project. -### Setting Up the Development Environment +### Setting up the development environment **Prerequisites:** @@ -135,7 +160,7 @@ development setup of this project. version of Node.js `>=20` is acceptable. 2. **Git** -### Build Process +### Build process To clone the repository: @@ -160,7 +185,7 @@ This command typically compiles TypeScript to JavaScript, bundles assets, and prepares the packages for execution. Refer to `scripts/build.js` and `package.json` scripts for more details on what happens during the build. -### Enabling Sandboxing +### Enabling sandboxing [Sandboxing](#sandboxing) is highly recommended and requires, at a minimum, setting `GEMINI_SANDBOX=true` in your `~/.env` and ensuring a sandboxing @@ -176,7 +201,7 @@ npm run build:all To skip building the sandbox container, you can use `npm run build` instead. -### Running +### Running the CLI To start the Gemini CLI from the source code (after building), run the following command from the root directory: @@ -190,11 +215,11 @@ utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini` -### Running Tests +### Running tests This project contains two types of tests: unit tests and integration tests. -#### Unit Tests +#### Unit tests To execute the unit test suite for the project: @@ -206,7 +231,7 @@ This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`. -#### Integration Tests +#### Integration tests The integration tests are designed to validate the end-to-end functionality of the Gemini CLI. They are not run as part of the default `npm run test` command. @@ -218,9 +243,9 @@ npm run test:e2e ``` For more detailed information on the integration testing framework, please see -the [Integration Tests documentation](./docs/integration-tests.md). +the [Integration Tests documentation](/docs/integration-tests.md). -### Linting and Preflight Checks +### Linting and preflight checks To ensure code quality and formatting consistency, run the preflight check: @@ -267,7 +292,7 @@ root directory: npm run lint ``` -### Coding Conventions +### Coding conventions - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. @@ -320,16 +345,21 @@ command again to update it. ### Project Structure - `packages/`: Contains the individual sub-packages of the project. + - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) - `cli/`: The command-line interface. - `core/`: The core backend logic for the Gemini CLI. + - `test-utils` Utilities for creating and cleaning temporary file systems for + testing. + - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with + Gemini CLI. - `docs/`: Contains all project documentation. - `scripts/`: Utility scripts for building, testing, and development tasks. For more detailed architecture, see `docs/architecture.md`. -## Debugging +### Debugging -### VS Code: +#### VS Code 0. Run the CLI to interactively debug in VS Code with `F5` 1. Start the CLI in debug mode from the root directory: @@ -387,9 +417,9 @@ used for the CLI's interface, is compatible with React DevTools version 4.x. Your running CLI application should then connect to React DevTools. ![](/docs/assets/connected_devtools.png) -## Sandboxing +### Sandboxing -### macOS Seatbelt +#### macOS Seatbelt On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open` profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that @@ -405,7 +435,7 @@ Available built-in profiles are `{permissive,restrictive}-{open,closed,proxied}` `.gemini/sandbox-macos-.sb` under your project settings directory `.gemini`. -### Container-based Sandboxing (All Platforms) +#### Container-based sandboxing (all platforms) For stronger container-based sandboxing on macOS or other platforms, you can set `GEMINI_SANDBOX=true|docker|podman|` in your environment or `.env` @@ -428,7 +458,7 @@ for your projects by creating the files `.gemini/sandbox.Dockerfile` and/or running `gemini` with `BUILD_SANDBOX=1` to trigger building of your custom sandbox. -#### Proxied Networking +#### Proxied networking All sandboxing methods, including macOS Seatbelt using `*-proxied` profiles, support restricting outbound network traffic through a custom proxy server that @@ -439,7 +469,7 @@ connections to `example.com:443` (e.g. `curl https://example.com`) and declines all other requests. The proxy is started and stopped automatically alongside the sandbox. -## Manual Publish +### Manual publish We publish an artifact for each commit to our internal registry. But if you need to manually cut a local build, then run the following commands: @@ -451,3 +481,91 @@ npm run auth npm run prerelease:dev npm publish --workspaces ``` + +## Documentation contribution process + +Our documentation must be kept up-to-date with our code contributions. We want +our documentation to be clear, concise, and helpful to our users. We value: + +- **Clarity:** Use simple and direct language. Avoid jargon where possible. +- **Accuracy:** Ensure all information is correct and up-to-date. +- **Completeness:** Cover all aspects of a feature or topic. +- **Examples:** Provide practical examples to help users understand how to use + Gemini CLI. + +### Getting started + +The process for contributing to the documentation is similar to contributing +code. + +1. **Fork the repository** and create a new branch. +2. **Make your changes** in the `/docs` directory. +3. **Preview your changes locally** in Markdown rendering. +4. **Lint and format your changes.** Our preflight check includes linting and + formatting for documentation files. + ```bash + npm run preflight + ``` +5. **Open a pull request** with your changes. + +### Documentation structure + +Our documentation is organized using [sidebar.json](/docs/sidebar.json) as the +table of contents. When adding new documentation: + +1. Create your markdown file **in the appropriate directory** under `/docs`. +2. Add an entry to `sidebar.json` in the relevant section. +3. Ensure all internal links use relative paths and point to existing files. + +### Style guide + +We follow the +[Google Developer Documentation Style Guide](https://developers.google.com/style). +Please refer to it for guidance on writing style, tone, and formatting. + +#### Key style points + +- Use sentence case for headings. +- Write in second person ("you") when addressing the reader. +- Use present tense. +- Keep paragraphs short and focused. +- Use code blocks with appropriate language tags for syntax highlighting. +- Include practical examples whenever possible. + +### Linting and formatting + +We use `prettier` to enforce a consistent style across our documentation. The +`npm run preflight` command will check for any linting issues. + +You can also run the linter and formatter separately: + +- `npm run lint` - Check for linting issues +- `npm run format` - Auto-format markdown files +- `npm run lint:fix` - Auto-fix linting issues where possible + +Please make sure your contributions are free of linting errors before submitting +a pull request. + +### Before you submit + +Before submitting your documentation pull request, please: + +1. Run `npm run preflight` to ensure all checks pass. +2. Review your changes for clarity and accuracy. +3. Check that all links work correctly. +4. Ensure any code examples are tested and functional. +5. Sign the + [Contributor License Agreement (CLA)](https://cla.developers.google.com/) if + you haven't already. + +### Need help? + +If you have questions about contributing documentation: + +- Check our [FAQ](/docs/faq.md). +- Review existing documentation for examples. +- Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss + your proposed changes. +- Reach out to the maintainers. + +We appreciate your contributions to making Gemini CLI documentation better! diff --git a/README.md b/README.md index 82ca5955d9..494d059cd3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/). ## šŸ“¦ Installation +### Pre-requisites before installation + +- Node.js version 20 or higher +- macOS, Linux, or Windows + ### Quick Install #### Run instantly with npx @@ -48,11 +53,6 @@ npm install -g @google/gemini-cli brew install gemini-cli ``` -#### System Requirements - -- Node.js version 20 or higher -- macOS, Linux, or Windows - ## Release Cadence and Tags See [Releases](./docs/releases.md) for more details. @@ -306,6 +306,8 @@ gemini corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. - [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools. +- [**Local development**](./docs/local-development.md) - Local development + tooling. ### Troubleshooting & Support diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 120000 index 0000000000..44fcc63439 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 2e7a86c022..b5ff0b8e00 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -10,10 +10,10 @@ This document lists the available keyboard shortcuts in the Gemini CLI. | `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | | `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | | `Ctrl+L` | Clear the screen. | -| `Ctrl+O` | Toggle the display of the debug console. | | `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | | `Ctrl+T` | Toggle the display of the todo list. | | `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. | +| `F12` | Toggle the display of the debug console. | ## Input Prompt diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index fd59260d2a..4b218cb8bd 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -298,6 +298,26 @@ Captures tool executions, output truncation, and Smart Edit behavior. - **Attributes**: - `correction` ("success" | "failure") +- `gen_ai.client.inference.operation.details`: This event provides detailed + information about the GenAI operation, aligned with [OpenTelemetry GenAI + semantic conventions for events]. + - **Attributes**: + - `gen_ai.request.model` (string) + - `gen_ai.provider.name` (string) + - `gen_ai.operation.name` (string) + - `gen_ai.input.messages` (json string) + - `gen_ai.output.messages` (json string) + - `gen_ai.response.finish_reasons` (array of strings) + - `gen_ai.usage.input_tokens` (int) + - `gen_ai.usage.output_tokens` (int) + - `gen_ai.request.temperature` (float) + - `gen_ai.request.top_p` (float) + - `gen_ai.request.top_k` (int) + - `gen_ai.request.max_tokens` (int) + - `gen_ai.system_instructions` (json string) + - `server.address` (string) + - `server.port` (int) + #### Files Tracks file operations performed by tools. @@ -735,3 +755,5 @@ standardized observability across GenAI applications: [OpenTelemetry GenAI semantic conventions]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md +[OpenTelemetry GenAI semantic conventions for events]: + https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md diff --git a/docs/extensions/index.md b/docs/extensions/index.md index e07930dcf4..84d116cfe6 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -190,8 +190,8 @@ Each object in the array should have the following properties: - `description`: A description of the setting and what it's used for. - `envVar`: The name of the environment variable that the setting will be stored as. - -**Example** +- `sensitive`: Optional boolean. If true, obfuscates the input the user provides + and stores the secret in keychain storage. **Example** ```json { diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 24377c1934..341c8e2899 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -56,6 +56,22 @@ To run a single test by its name, use the `--test-name-pattern` flag: npm run test:e2e -- --test-name-pattern "reads a file" ``` +### Regenerating model responses + +Some integration tests use faked out model responses, which may need to be +regenerated from time to time as the implementations change. + +To regenerate these golden files, set the REGENERATE_MODEL_GOLDENS environment +variable to "true" when running the tests, for example: + +**WARNING**: If running locally you should review these updated responses for +any information about yourself or your system that gemini may have included in +these responses. + +```bash +REGENERATE_MODEL_GOLDENS="true" npm run test:e2e +``` + ### Deflaking a test Before adding a **new** integration test, you should test it at least 5 times diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000000..f69308f356 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,128 @@ +# Local Development Guide + +This guide provides instructions for setting up and using local development +features, such as development tracing. + +## Development Tracing + +Development traces (dev traces) are OpenTelemetry (OTel) traces that help you +debug your code by instrumenting interesting events like model calls, tool +scheduler, tool calls, etc. + +Dev traces are verbose and are specifically meant for understanding agent +behaviour and debugging issues. They are disabled by default. + +To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable +when running Gemini CLI. + +### Viewing Dev Traces + +You can view dev traces using either Jaeger or the Genkit Developer UI. + +#### Using Genkit + +Genkit provides a web-based UI for viewing traces and other telemetry data. + +1. **Start the Genkit Telemetry Server:** + + Run the following command to start the Genkit server: + + ```bash + npm run telemetry -- --target=genkit + ``` + + The script will output the URL for the Genkit Developer UI, for example: + + ``` + Genkit Developer UI: http://localhost:4000 + ``` + +2. **Run Gemini CLI with Dev Tracing:** + + In a separate terminal, run your Gemini CLI command with the + `GEMINI_DEV_TRACING` environment variable: + + ```bash + GEMINI_DEV_TRACING=true gemini + ``` + +3. **View the Traces:** + + Open the Genkit Developer UI URL in your browser and navigate to the + **Traces** tab to view the traces. + +#### Using Jaeger + +You can view dev traces in the Jaeger UI. To get started, follow these steps: + +1. **Start the telemetry collector:** + + Run the following command in your terminal to download and start Jaeger and + an OTEL collector: + + ```bash + npm run telemetry -- --target=local + ``` + + This command also configures your workspace for local telemetry and provides + a link to the Jaeger UI (usually `http://localhost:16686`). + +2. **Run Gemini CLI with dev tracing:** + + In a separate terminal, run your Gemini CLI command with the + `GEMINI_DEV_TRACING` environment variable: + + ```bash + GEMINI_DEV_TRACING=true gemini + ``` + +3. **View the traces:** + + After running your command, open the Jaeger UI link in your browser to view + the traces. + +For more detailed information on telemetry, see the +[telemetry documentation](./cli/telemetry.md). + +### Instrumenting Code with Dev Traces + +You can add dev traces to your own code for more detailed instrumentation. This +is useful for debugging and understanding the flow of execution. + +Use the `runInDevTraceSpan` function to wrap any section of code in a trace +span. + +Here is a basic example: + +```typescript +import { runInDevTraceSpan } from '@google/gemini-cli-core'; + +await runInDevTraceSpan({ name: 'my-custom-span' }, async ({ metadata }) => { + // The `metadata` object allows you to record the input and output of the + // operation as well as other attributes. + metadata.input = { key: 'value' }; + // Set custom attributes. + metadata.attributes['gen_ai.request.model'] = 'gemini-4.0-mega'; + + // Your code to be traced goes here + try { + const output = await somethingRisky(); + metadata.output = output; + return output; + } catch (e) { + metadata.error = e; + throw e; + } +}); +``` + +In this example: + +- `name`: The name of the span, which will be displayed in the trace. +- `metadata.input`: (Optional) An object containing the input data for the + traced operation. +- `metadata.output`: (Optional) An object containing the output data from the + traced operation. +- `metadata.attributes`: (Optional) A record of custom attributes to add to the + span. +- `metadata.error`: (Optional) An error object to record if the operation fails. diff --git a/docs/sidebar.json b/docs/sidebar.json index 80e2494e4b..c9fd3e16b1 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -9,6 +9,10 @@ { "label": "Architecture Overview", "slug": "docs/architecture" + }, + { + "label": "Contribution Guide", + "slug": "docs/contributing" } ] }, diff --git a/integration-tests/context-compress-interactive.compress-empty.json b/integration-tests/context-compress-interactive.compress-empty.json deleted file mode 100644 index 5366bf317b..0000000000 --- a/integration-tests/context-compress-interactive.compress-empty.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "generateContent": [ - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "This is more than the 5 tokens we return below which will trigger an error" - } - ] - } - } - ] - } - ] -} diff --git a/integration-tests/context-compress-interactive.compress-empty.responses b/integration-tests/context-compress-interactive.compress-empty.responses new file mode 100644 index 0000000000..e69de29bb2 diff --git a/integration-tests/context-compress-interactive.compress-failure.json b/integration-tests/context-compress-interactive.compress-failure.json deleted file mode 100644 index 939189366b..0000000000 --- a/integration-tests/context-compress-interactive.compress-failure.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "generateContent": [ - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "This is more than the 5 tokens we return below which will trigger an error" - } - ] - } - } - ] - } - ], - "generateContentStream": [ - [ - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "The initial response from the model" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 5 - } - } - ] - ] -} diff --git a/integration-tests/context-compress-interactive.compress-failure.responses b/integration-tests/context-compress-interactive.compress-failure.responses new file mode 100644 index 0000000000..a70004c5d3 --- /dev/null +++ b/integration-tests/context-compress-interactive.compress-failure.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Observing Initial Conditions**\n\nI'm currently focused on the initial context. I've taken note of the provided date, OS, and working directory. I'm also carefully examining the file structure presented within the current working directory. It's helping me understand the starting point for further analysis.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12316,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":46}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Assessing User Intent**\n\nI'm now shifting my focus. I've successfully registered the provided data and file structure. My current task is to understand the user's ultimate goal, given the information provided. The \"Hello.\" command is straightforward, but I'm checking if there's an underlying objective.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb3dRh+BYdbZvmulSN2Pwbc75DfQOT3H4EN0rn039hoMKfwHR7YpvvyqNKoxXAiCbYw3gbcTr/+pegUpgnsIrt8oQPMytFMjKSsMyshfygc21T2MkyuI6Q5I/fNCcHROWexdZnIeppVCDB2TarN4LGW4T9Yci6n/ynMMFT2xc2/vyHpkDgRM7avhMElnBhuxAY+e4TpxkZIncGWCEHP1TouoKpgEB0e2Kb8Xpwm0hiKhPt2ZLizpxjk+CVtcbnlgv69xo5VsuQ+iNyrVGBGRwNx+eTeNGdGpn6e73WOCZeP91FwOZe7URyL12IA6E6gYWqw0kXJR4hO4p6Lwv49E3+FRiG2C4OKDF8LF5XorYyCHSgBFT1/RUAVj81GDTx1xxtmYKN3xq8Ri+HsPbqU/FM/jtNZKkXXAtufw2Bmw8lJfmugENIv/TQI7xCo8BAdHtim8KgAXJfZ7ASfutVLKTylQeaslyB/SmcHJ0ZiNr5j8WP1prZdb6XnZZ1ZNbhjxUf/ymoxHKGvtTPBgLE9azMj8Lx/k0clhd2a+wNsiIqW9qCzlVah0tBMytpQUjIDtQe9Hj4LLUprF9PUe/xJkj000Z0ZzsgFm2ncdTWZTdkhCQDpyETVAxdE+oklwKJAHR7YpvUjSkD6KwY1gLrOsHKy0UNfn2lMbxjVetKNMVBRqsTg==","text":"Hello."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12270,"totalTokenCount":12341,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12270}],"thoughtsTokenCount":71}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"\n \n \n \n\n \n - OS: linux\n - Date: Friday, October 24, 2025\n \n\n \n - OBSERVED: The directory contains `telemetry.log` and a `.gemini/` directory.\n - OBSERVED: The `.gemini/` directory contains `settings.json` and `settings.json.orig`.\n \n\n \n - The user initiated the chat.\n \n\n \n 1. [TODO] Await the user's first instruction to formulate a plan.\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":983,"candidatesTokenCount":299,"totalTokenCount":1637,"promptTokensDetails":[{"modality":"TEXT","tokenCount":983}],"thoughtsTokenCount":355}}} diff --git a/integration-tests/context-compress-interactive.compress.json b/integration-tests/context-compress-interactive.compress.json deleted file mode 100644 index b9d470fc9c..0000000000 --- a/integration-tests/context-compress-interactive.compress.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "generateContent": [ - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "A summary of the conversation." - } - ] - } - } - ] - } - ], - "generateContentStream": [ - [ - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "The initial response from the model" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 100000 - } - } - ] - ] -} diff --git a/integration-tests/context-compress-interactive.compress.responses b/integration-tests/context-compress-interactive.compress.responses new file mode 100644 index 0000000000..48ecaf5bda --- /dev/null +++ b/integration-tests/context-compress-interactive.compress.responses @@ -0,0 +1,3 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Generating a Story**\n\nI've crafted the robot story. The narrative is complete and meets the length requirement. Now, I'm getting ready to use the `write_file` tool to save it. I'm choosing the filename `robot_story.txt` as a default.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12352,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"thoughtsTokenCount":70}},{"candidates":[{"finishReason":"MALFORMED_FUNCTION_CALL","index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12282,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Drafting the Narrative**\n\nI'm currently focused on the narrative's central conflict. I'm aiming for a compelling story about a robot and am working to keep the word count tight. The \"THE _END.\" conclusion is proving challenging to integrate organically. I need to make the ending feel natural and satisfying.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"totalTokenCount":12326,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CikB0e2Kb7zkpgRyJXXNt6ykO/+FoOglhrKxjLgoESrgafzIZak2Ofxo1gpaAdHtim9aG7MvpXlIg+n2zgmcDBWOPXtvQHxhE9k8pR+DO8i2jIe3tMWLxdN944XpUlR9vaNmVdtSRMKr4MhB/t1R3WSWR3QYhk7MEQxnjYR7cv/pR9viwZyFCoYBAdHtim/xKmMl/S+U8p+p9848q4agsL/STufluXewPqL3uJSinZbN0Z4jTYfMzXKldhDYIonvw3Crn/Y11oAjnT656Sx0kkKtavAXbiU/WsGyDxZbNhLofnJGQxruljPGztxkKawz1cTiQnddnQRfLddhy+3iJIOSh6ZpYq9uGHz3PzVkUuQ=","text":"Unit 734 whirred, its optical sensors scanning the desolate junkyard. For years, its purpose had been clear: compress refuse, maintain order. But today, a glint of tarnished silver beneath a rusted hull"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":47,"totalTokenCount":12373,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" caught its attention. It was a discarded music box, its delicate gears jammed, a faint, melancholic tune trapped within.\n\n734 usually crushed, never salvaged. Yet, a new directive flickered in its circuits – curiosity."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":95,"totalTokenCount":12421,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" With surprising gentleness for its formidable pincers, it retrieved the box. Back in its monochrome workshop, it meticulously cleaned and repaired. Each tiny spring, each worn tooth, became a puzzle.\n\nHours later, a soft, ethereal melody filled the clang"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":146,"totalTokenCount":12472,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":"orous space. The music box sang. 734 felt a strange, new sensation – a warmth in its core processors, a hum of contentment. Its existence, once solely utilitarian, now held a note of beauty, a hint of something more than"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":197,"totalTokenCount":12523,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}},{"candidates":[{"content":{"parts":[{"text":" just compression. It had fixed a broken song, and in doing so, had found a different kind of purpose. THE_END."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12282,"candidatesTokenCount":224,"totalTokenCount":12550,"cachedContentTokenCount":11883,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12282}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":11883}],"thoughtsTokenCount":44}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"scratchpad\nThe user's overall goal was to write a 200-word story about a robot, ending with \"THE_END.\". The agent successfully completed this task.\n\nSince the task is complete, the snapshot should reflect that the goal has been achieved and there are no further actions planned regarding the story.\n\nLet's break down the required sections for the snapshot:\n\n1. **Overall Goal**: The initial goal was to write a story. This has been completed.\n2. **Key Knowledge**: No specific key knowledge was provided or discovered during this simple interaction beyond the prompt's constraints.\n3. **File System State**: No files were read, modified, or created by the agent relevant to the task. The initial file system state was merely provided for context.\n4. **Recent Actions**: The agent wrote the story.\n5. **Current Plan**: The plan was to write the story, which is now done. Therefore, the plan should indicate completion."},{"text":"\n \n Write a 200-word story about a robot, ending with \"THE_END.\".\n \n\n \n - The story must be approximately 200 words.\n - The story must end with the exact phrase \"THE_END.\"\n \n\n \n \n \n\n \n - Generated a 200-word story about a robot, successfully ending it with \"THE_END.\".\n \n\n \n 1. [DONE] Write a 200-word story about a robot.\n 2. [DONE] Ensure the story ends with the exact text \"THE_END.\".\n \n"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1223,"candidatesTokenCount":424,"totalTokenCount":1647,"promptTokensDetails":[{"modality":"TEXT","tokenCount":1223}]}}} diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts index 5be9b73141..49f5e2aa7c 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/context-compress-interactive.test.ts @@ -20,26 +20,29 @@ describe('Interactive Mode', () => { }); it('should trigger chat compression with /compress command', async () => { - await rig.setup('interactive-compress-test', { + await rig.setup('interactive-compress-success', { fakeResponsesPath: join( import.meta.dirname, - 'context-compress-interactive.compress.json', + 'context-compress-interactive.compress.responses', ), }); const run = await rig.runInteractive(); - await run.type('Initial prompt'); - await run.type('\r'); + await run.sendKeys( + 'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.', + ); + await run.sendKeys('\r'); - await run.expectText('The initial response from the model', 5000); + // Wait for the specific end marker. + await run.expectText('THE_END.', 30000); await run.type('/compress'); await run.type('\r'); const foundEvent = await rig.waitForTelemetryEvent( 'chat_compression', - 5000, + 25000, ); expect(foundEvent, 'chat_compression telemetry event was not found').toBe( true, @@ -48,24 +51,27 @@ describe('Interactive Mode', () => { await run.expectText('Chat history compressed', 5000); }); - it('should handle compression failure on token inflation', async () => { + // TODO: Context compression is broken and doesn't include the system + // instructions or tool counts, so it thinks compression is beneficial when + // it is in fact not. + it.skip('should handle compression failure on token inflation', async () => { await rig.setup('interactive-compress-failure', { fakeResponsesPath: join( import.meta.dirname, - 'context-compress-interactive.compress-failure.json', + 'context-compress-interactive.compress-failure.responses', ), }); const run = await rig.runInteractive(); - await run.type('Initial prompt'); + await run.type('Respond with exactly "Hello" followed by a period'); await run.type('\r'); - await run.expectText('The initial response from the model', 25000); + await run.expectText('Hello.', 25000); await run.type('/compress'); await run.type('\r'); - await run.expectText('compression was not beneficial', 5000); + await run.expectText('compression was not beneficial', 25000); // Verify no telemetry event is logged for NOOP const foundEvent = await rig.waitForTelemetryEvent( @@ -82,7 +88,7 @@ describe('Interactive Mode', () => { rig.setup('interactive-compress-empty', { fakeResponsesPath: join( import.meta.dirname, - 'context-compress-interactive.compress-empty.json', + 'context-compress-interactive.compress-empty.responses', ), }); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index c71f6239ed..d643437eac 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -144,7 +144,7 @@ describe('run_shell_command', () => { validateModelOutput(result, 'test-stdin', 'Shell command stdin test'); }); - it('should run allowed sub-command in non-interactive mode', async () => { + it.skip('should run allowed sub-command in non-interactive mode', async () => { const rig = new TestRig(); await rig.setup('should run allowed sub-command in non-interactive mode'); @@ -262,7 +262,7 @@ describe('run_shell_command', () => { expect(toolCall.toolRequest.success).toBe(true); }); - it('should work with ShellTool alias', async () => { + it.skip('should work with ShellTool alias', async () => { const rig = new TestRig(); await rig.setup('should work with ShellTool alias'); @@ -427,7 +427,8 @@ describe('run_shell_command', () => { expect(failureLog!.toolRequest.success).toBe(false); }); - it('should reject chained commands when only the first segment is allowlisted in non-interactive mode', async () => { + // TODO(#11966): Deflake this test and re-enable once the underlying race is resolved. + it.skip('should reject chained commands when only the first segment is allowlisted in non-interactive mode', async () => { const rig = new TestRig(); await rig.setup( 'should reject chained commands when only the first segment is allowlisted', diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index d5a9026726..35f9c4100e 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -255,7 +255,10 @@ export class TestRig { testDir: string | null; testName?: string; _lastRunStdout?: string; + // Path to the copied fake responses file for this test. fakeResponsesPath?: string; + // Original fake responses file path for rewriting goldens in record mode. + originalFakeResponsesPath?: string; constructor() { this.bundlePath = join(__dirname, '..', 'bundle/gemini.js'); @@ -275,7 +278,10 @@ export class TestRig { mkdirSync(this.testDir, { recursive: true }); if (options.fakeResponsesPath) { this.fakeResponsesPath = join(this.testDir, 'fake-responses.json'); - fs.copyFileSync(options.fakeResponsesPath, this.fakeResponsesPath); + this.originalFakeResponsesPath = options.fakeResponsesPath; + if (process.env['REGENERATE_MODEL_GOLDENS'] !== 'true') { + fs.copyFileSync(options.fakeResponsesPath, this.fakeResponsesPath); + } } // Create a settings file to point the CLI to the local collector @@ -344,7 +350,11 @@ export class TestRig { ? extraInitialArgs : [this.bundlePath, ...extraInitialArgs]; if (this.fakeResponsesPath) { - initialArgs.push('--fake-responses', this.fakeResponsesPath); + if (process.env['REGENERATE_MODEL_GOLDENS'] === 'true') { + initialArgs.push('--record-responses', this.fakeResponsesPath); + } else { + initialArgs.push('--fake-responses', this.fakeResponsesPath); + } } return { command, initialArgs }; } @@ -555,6 +565,12 @@ export class TestRig { } async cleanup() { + if ( + process.env['REGENERATE_MODEL_GOLDENS'] === 'true' && + this.fakeResponsesPath + ) { + fs.copyFileSync(this.fakeResponsesPath, this.originalFakeResponsesPath!); + } // Clean up test directory if (this.testDir && !env['KEEP_OUTPUT']) { try { diff --git a/package-lock.json b/package-lock.json index c55c14de62..cb3c97e71e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "@google-gemini/gemini-cli", - "version": "0.12.0-nightly.20251022.0542de95-ci.12345.48a04d2d2da8ee3f0f7900ebd6cf0780040a0462", + "name": "@google/gemini-cli", + "version": "0.13.0-nightly.20251029.cca41edc", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@google-gemini/gemini-cli", - "version": "0.12.0-nightly.20251022.0542de95-ci.12345.48a04d2d2da8ee3f0f7900ebd6cf0780040a0462", + "name": "@google/gemini-cli", + "version": "0.13.0-nightly.20251029.cca41edc", "workspaces": [ "packages/*" ], "dependencies": { "@testing-library/dom": "^10.4.1", + "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, "bin": { @@ -3895,6 +3896,16 @@ "text-table": "^0.2.0" } }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@textlint/linter-formatter/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -5494,6 +5505,15 @@ "string-width": "^4.1.0" } }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-align/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5980,15 +6000,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/atomically": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", - "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", - "dependencies": { - "stubborn-fs": "^1.2.5", - "when-exit": "^2.1.1" - } - }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -7000,30 +7011,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/configstore": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.0.0.tgz", - "integrity": "sha512-yk7/5PN5im4qwz0WFZW3PXnzHgPu9mX29Y8uZ3aefe2lBPC1FYttWZRcaW9fKkT0pBCJyuQ2HfbmPVaODi9jcQ==", - "license": "BSD-2-Clause", - "dependencies": { - "atomically": "^2.0.3", - "dot-prop": "^9.0.0", - "graceful-fs": "^4.2.11", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/yeoman/configstore?sponsor=1" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -7643,33 +7630,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-prop/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dotenv": { "version": "17.1.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", @@ -8079,18 +8039,6 @@ "node": ">=6" } }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -9497,21 +9445,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globals": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", @@ -10220,13 +10153,10 @@ "license": "ISC" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" }, "node_modules/ink": { "version": "6.2.3", @@ -10672,21 +10602,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -10705,22 +10620,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-installed-globally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", - "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", - "license": "MIT", - "dependencies": { - "global-directory": "^4.0.1", - "is-path-inside": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -10754,18 +10653,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10802,18 +10689,6 @@ "node": ">=8" } }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -11433,9 +11308,9 @@ "license": "MIT" }, "node_modules/ky": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", - "integrity": "sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.13.0.tgz", + "integrity": "sha512-JeNNGs44hVUp2XxO3FY9WV28ymG7LgO4wju4HL/dCq1A8eKDcFgVrdCn1ssn+3Q/5OQilv5aYsL0DMt5mmAV9w==", "license": "MIT", "engines": { "node": ">=18" @@ -13825,21 +13700,6 @@ "node": ">=6" } }, - "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", - "license": "MIT", - "dependencies": { - "escape-goat": "^4.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -13960,12 +13820,6 @@ "node": ">=6" } }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -15501,15 +15355,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -15579,11 +15424,6 @@ "boundary": "^2.0.0" } }, - "node_modules/stubborn-fs": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", - "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" - }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", @@ -15744,6 +15584,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/table/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -16618,126 +16468,6 @@ "node": ">= 0.8" } }, - "node_modules/update-notifier": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", - "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", - "license": "BSD-2-Clause", - "dependencies": { - "boxen": "^8.0.1", - "chalk": "^5.3.0", - "configstore": "^7.0.0", - "is-in-ci": "^1.0.0", - "is-installed-globally": "^1.0.0", - "is-npm": "^6.0.0", - "latest-version": "^9.0.0", - "pupa": "^3.1.0", - "semver": "^7.6.3", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "license": "MIT" - }, - "node_modules/update-notifier/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/update-notifier/node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -17121,12 +16851,6 @@ "node": ">=18" } }, - "node_modules/when-exit": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", - "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -17738,8 +17462,8 @@ } }, "packages/a2a-server": { - "name": "@google-gemini/gemini-cli-a2a-server", - "version": "0.12.0-nightly.20251022.0542de95", + "name": "@google/gemini-cli-a2a-server", + "version": "0.13.0-nightly.20251029.cca41edc", "dependencies": { "@a2a-js/sdk": "^0.3.2", "@google-cloud/storage": "^7.16.0", @@ -18013,7 +17737,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.12.0-nightly.20251022.0542de95", + "version": "0.13.0-nightly.20251029.cca41edc", "dependencies": { "@google/gemini-cli-core": "file:../core", "@google/genai": "1.16.0", @@ -18032,6 +17756,7 @@ "ink": "^6.2.3", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", + "latest-version": "^9.0.0", "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", @@ -18045,7 +17770,6 @@ "strip-json-comments": "^3.1.1", "tar": "^7.5.1", "undici": "^7.10.0", - "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", "zod": "^3.23.8" @@ -18108,6 +17832,12 @@ } } }, + "packages/cli/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "packages/cli/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -18126,8 +17856,8 @@ } }, "packages/core": { - "name": "@google-gemini/gemini-cli-core", - "version": "0.12.0-nightly.20251022.0542de95", + "name": "@google/gemini-cli-core", + "version": "0.13.0-nightly.20251029.cca41edc", "dependencies": { "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", @@ -18268,7 +17998,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.12.0-nightly.20251022.0542de95", + "version": "0.13.0-nightly.20251029.cca41edc", "license": "Apache-2.0", "devDependencies": { "typescript": "^5.3.3" @@ -18279,7 +18009,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.12.0-nightly.20251022.0542de95", + "version": "0.13.0-nightly.20251029.cca41edc", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 046ae822f8..3e6e40d444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@google-gemini/gemini-cli", - "version": "0.12.0-nightly.20251022.0542de95-ci.12345.48a04d2d2da8ee3f0f7900ebd6cf0780040a0462", + "name": "@google/gemini-cli", + "version": "0.13.0-nightly.20251029.cca41edc", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-nightly.20251022.0542de95" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.13.0-nightly.20251029.cca41edc" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -67,7 +67,6 @@ }, "overrides": { "wrap-ansi": "9.0.2", - "ansi-regex": "5.0.1", "cliui": { "wrap-ansi": "7.0.0" } @@ -121,6 +120,7 @@ }, "dependencies": { "@testing-library/dom": "^10.4.1", + "latest-version": "^9.0.0", "simple-git": "^3.28.0" }, "optionalDependencies": { diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index 55b3873cd2..049fa23b48 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { - "name": "@google-gemini/gemini-cli-a2a-server", - "version": "0.12.0-nightly.20251022.0542de95", + "name": "@google/gemini-cli-a2a-server", + "version": "0.13.0-nightly.20251029.cca41edc", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index a5360a94b7..1e3afd6e81 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -17,7 +17,10 @@ import type { ServerGeminiToolCallRequestEvent, Config, } from '@google/gemini-cli-core'; -import { GeminiEventType } from '@google/gemini-cli-core'; +import { + GeminiEventType, + SimpleExtensionLoader, +} from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; @@ -96,7 +99,11 @@ export class CoderAgentExecutor implements AgentExecutor { loadEnvironment(); // Will override any global env with workspace envs const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); - return await loadConfig(settings, extensions, taskId); + return await loadConfig( + settings, + new SimpleExtensionLoader(extensions), + taskId, + ); } /** diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 513867f4e2..8b347f70e2 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -4,11 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { Task } from './task.js'; -import type { Config, ToolCallRequestInfo } from '@google/gemini-cli-core'; +import { + GeminiEventType, + type Config, + type ToolCallRequestInfo, +} from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; +import { CoderAgentEvent } from '../types.js'; +import type { ToolCall } from '@google/gemini-cli-core'; describe('Task', () => { it('scheduleToolCalls should not modify the input requests array', async () => { @@ -93,5 +107,167 @@ describe('Task', () => { }), ); }); + + it('should handle Citation event and publish to event bus', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const citationText = 'Source: example.com'; + const citationEvent = { + type: GeminiEventType.Citation, + value: citationText, + }; + + await task.acceptAgentMessage(citationEvent); + + expect(mockEventBus.publish).toHaveBeenCalledOnce(); + const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0]; + + expect(publishedEvent.kind).toBe('status-update'); + expect(publishedEvent.taskId).toBe('task-id'); + expect(publishedEvent.metadata.coderAgent.kind).toBe( + CoderAgentEvent.CitationEvent, + ); + expect(publishedEvent.status.message).toBeDefined(); + expect(publishedEvent.status.message.parts).toEqual([ + { + kind: 'text', + text: citationText, + }, + ]); + }); + }); + + describe('_schedulerToolCallsUpdate', () => { + let task: Task; + type SpyInstance = ReturnType; + let setTaskStateAndPublishUpdateSpy: SpyInstance; + + beforeEach(() => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + // Spy on the method we want to check calls for + setTaskStateAndPublishUpdateSpy = vi.spyOn( + task, + 'setTaskStateAndPublishUpdate', + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should set state to input-required when a tool is awaiting approval and none are executing', () => { + const toolCalls = [ + { request: { callId: '1' }, status: 'awaiting_approval' }, + ] as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + // The last call should be the final state update + expect(setTaskStateAndPublishUpdateSpy).toHaveBeenLastCalledWith( + 'input-required', + { kind: 'state-change' }, + undefined, + undefined, + true, // final: true + ); + }); + + it('should NOT set state to input-required if a tool is awaiting approval but another is executing', () => { + const toolCalls = [ + { request: { callId: '1' }, status: 'awaiting_approval' }, + { request: { callId: '2' }, status: 'executing' }, + ] as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + // It will be called for status updates, but not with final: true + const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( + (call) => call[4] === true, + ); + expect(finalCall).toBeUndefined(); + }); + + it('should set state to input-required once an executing tool finishes, leaving one awaiting approval', () => { + const initialToolCalls = [ + { request: { callId: '1' }, status: 'awaiting_approval' }, + { request: { callId: '2' }, status: 'executing' }, + ] as ToolCall[]; + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(initialToolCalls); + + // No final call yet + let finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( + (call) => call[4] === true, + ); + expect(finalCall).toBeUndefined(); + + // Now, the executing tool finishes. The scheduler would call _resolveToolCall for it. + // @ts-expect-error - Calling private method + task._resolveToolCall('2'); + + // Then another update comes in for the awaiting tool (e.g., a re-check) + const subsequentToolCalls = [ + { request: { callId: '1' }, status: 'awaiting_approval' }, + ] as ToolCall[]; + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(subsequentToolCalls); + + // NOW we should get the final call + finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( + (call) => call[4] === true, + ); + expect(finalCall).toBeDefined(); + expect(finalCall?.[0]).toBe('input-required'); + }); + + it('should NOT set state to input-required if skipFinalTrueAfterInlineEdit is true', () => { + task.skipFinalTrueAfterInlineEdit = true; + const toolCalls = [ + { request: { callId: '1' }, status: 'awaiting_approval' }, + ] as ToolCall[]; + + // @ts-expect-error - Calling private method + task._schedulerToolCallsUpdate(toolCalls); + + const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( + (call) => call[4] === true, + ); + expect(finalCall).toBeUndefined(); + }); }); }); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index a7b0e288c9..f0061bc6a9 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -40,7 +40,6 @@ import type { import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import * as fs from 'node:fs'; - import { CoderAgentEvent } from '../types.js'; import type { CoderAgentMessage, @@ -50,6 +49,7 @@ import type { TaskMetadata, Thought, ThoughtSummary, + Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; @@ -373,11 +373,11 @@ export class Task { // Only send an update if the status has actually changed. if (hasChanged) { - const message = this.toolStatusMessage(tc, this.id, this.contextId); const coderAgentMessage: CoderAgentMessage = tc.status === 'awaiting_approval' ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } : { kind: CoderAgentEvent.ToolCallUpdateEvent }; + const message = this.toolStatusMessage(tc, this.id, this.contextId); const event = this._createStatusUpdateEvent( this.taskState, @@ -404,20 +404,16 @@ export class Task { const isAwaitingApproval = allPendingStatuses.some( (status) => status === 'awaiting_approval', ); - const allPendingAreStable = allPendingStatuses.every( - (status) => - status === 'awaiting_approval' || - status === 'success' || - status === 'error' || - status === 'cancelled', + const isExecuting = allPendingStatuses.some( + (status) => status === 'executing', ); - // 1. Are any pending tool calls awaiting_approval - // 2. Are all pending tool calls in a stable state (i.e. not in validing or executing) - // 3. After an inline edit, the edited tool call will send awaiting_approval THEN scheduled. We wait for the next update in this case. + // The turn is complete and requires user input if at least one tool + // is waiting for the user's decision, and no other tool is actively + // running in the background. if ( isAwaitingApproval && - allPendingAreStable && + !isExecuting && !this.skipFinalTrueAfterInlineEdit ) { this.skipFinalTrueAfterInlineEdit = false; @@ -643,6 +639,10 @@ export class Task { logger.info('[Task] Sending agent thought...'); this._sendThought(event.value, traceId); break; + case GeminiEventType.Citation: + logger.info('[Task] Received citation from LLM stream.'); + this._sendCitation(event.value); + break; case GeminiEventType.ChatCompressed: break; case GeminiEventType.Finished: @@ -984,4 +984,18 @@ export class Task { ), ); } + + _sendCitation(citation: string) { + if (!citation || citation.trim() === '') { + return; + } + logger.info('[Task] Sending citation to event bus.'); + const message = this._createTextMessage(citation); + const citationEvent: Citation = { + kind: CoderAgentEvent.CitationEvent, + }; + this.eventBus?.publish( + this._createStatusUpdateEvent(this.taskState, citationEvent, message), + ); + } } diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index c75c902ca5..5492bb9b0a 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -21,6 +21,7 @@ import { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_MODEL, type GeminiCLIExtension, + type ExtensionLoader, debugLogger, } from '@google/gemini-cli-core'; @@ -30,10 +31,10 @@ import { type AgentSettings, CoderAgentEvent } from '../types.js'; export async function loadConfig( settings: Settings, - extensions: GeminiCLIExtension[], + extensionLoader: ExtensionLoader, taskId: string, ): Promise { - const mcpServers = mergeMcpServers(settings, extensions); + const mcpServers = mergeMcpServers(settings, extensionLoader.getExtensions()); const workspaceDir = process.cwd(); const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; @@ -71,7 +72,7 @@ export async function loadConfig( }, ideMode: false, folderTrust: settings.folderTrust === true, - extensions, + extensionLoader, }; const fileService = new FileDiscoveryService(workspaceDir); @@ -80,7 +81,7 @@ export async function loadConfig( [workspaceDir], false, fileService, - extensions, + extensionLoader, settings.folderTrust === true, ); configParams.userMemory = memoryContent; diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 70d90f78cb..15b386bd3d 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -313,7 +313,7 @@ describe('E2E Tests', () => { expect(workingEvent.kind).toBe('status-update'); expect(workingEvent.status.state).toBe('working'); - // State Update: Validate each tool call + // State Update: Validate the first tool call const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent; expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', @@ -326,47 +326,218 @@ describe('E2E Tests', () => { }, }, ]); - const toolCallValidateEvent2 = events[4].result as TaskStatusUpdateEvent; - expect(toolCallValidateEvent2.metadata?.['coderAgent']).toMatchObject({ + + // --- Assert the event stream --- + // 1. Initial "submitted" status. + expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe( + 'submitted', + ); + + // 2. "working" status after receiving the user prompt. + expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe( + 'working', + ); + + // 3. A "state-change" event from the agent. + expect(events[2].result.metadata?.['coderAgent']).toMatchObject({ + kind: 'state-change', + }); + + // 4. Tool 1 is validating. + const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent; + expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({ kind: 'tool-call-update', }); - expect(toolCallValidateEvent2.status.message?.parts).toMatchObject([ + expect(toolCallUpdate1.status.message?.parts).toMatchObject([ { data: { + request: { callId: 'test-call-id-1' }, status: 'validating', - request: { callId: 'test-call-id-2' }, }, }, ]); - // State Update: Set each tool call to awaiting - const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent; - expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({ - kind: 'tool-call-confirmation', + // 5. Tool 2 is validating. + const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent; + expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', }); - expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([ + expect(toolCallUpdate2.status.message?.parts).toMatchObject([ { data: { - status: 'awaiting_approval', - request: { callId: 'test-call-id-1' }, - }, - }, - ]); - const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent; - expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({ - kind: 'tool-call-confirmation', - }); - expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([ - { - data: { - status: 'awaiting_approval', request: { callId: 'test-call-id-2' }, + status: 'validating', }, }, ]); + // 6. Tool 1 is awaiting approval. + const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent; + expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-confirmation', + }); + expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([ + { + data: { + request: { callId: 'test-call-id-1' }, + status: 'awaiting_approval', + }, + }, + ]); + + // 7. The final event is "input-required". + const finalEvent = events[6].result as TaskStatusUpdateEvent; + expect(finalEvent.final).toBe(true); + expect(finalEvent.status.state).toBe('input-required'); + + // The scheduler now waits for approval, so no more events are sent. + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(7); + }); + + it('should handle multiple tool calls sequentially in YOLO mode', async () => { + // Set YOLO mode to auto-approve tools and test sequential execution. + getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); + + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-1', + name: 'test-tool-1', + args: {}, + }, + }, + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-2', + name: 'test-tool-2', + args: {}, + }, + }, + ]; + }); + // Subsequent calls yield nothing, as the tools will "succeed". + sendMessageStreamSpy.mockImplementation(async function* () { + yield* [{ type: 'content', value: 'All tools executed.' }]; + }); + + const mockTool1 = new MockTool({ + name: 'test-tool-1', + displayName: 'Test Tool 1', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }), + }); + const mockTool2 = new MockTool({ + name: 'test-tool-2', + displayName: 'Test Tool 2', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === 'test-tool-1') return mockTool1; + if (name === 'test-tool-2') return mockTool2; + return undefined; + }), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send( + createStreamMessageRequest( + 'run two tools', + 'a2a-multi-tool-test-message', + ), + ) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // --- Assert the sequential execution flow --- + const eventStream = events.slice(2).map((e) => { + const update = e.result as TaskStatusUpdateEvent; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agentData = update.metadata?.['coderAgent'] as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const toolData = update.status.message?.parts[0] as any; + if (!toolData) { + return { kind: agentData.kind }; + } + return { + kind: agentData.kind, + status: toolData.data?.status, + callId: toolData.data?.request.callId, + }; + }); + + const expectedFlow = [ + // Initial state change + { kind: 'state-change', status: undefined, callId: undefined }, + // Tool 1 Lifecycle + { + kind: 'tool-call-update', + status: 'validating', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'executing', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'success', + callId: 'test-call-id-1', + }, + // Tool 2 Lifecycle + { + kind: 'tool-call-update', + status: 'validating', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'executing', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'success', + callId: 'test-call-id-2', + }, + // Final updates + { kind: 'state-change', status: undefined, callId: undefined }, + { kind: 'text-content', status: undefined, callId: undefined }, + ]; + + // Use `toContainEqual` for flexibility if other events are interspersed. + expect(eventStream).toEqual(expect.arrayContaining(expectedFlow)); + assertUniqueFinalEventIsLast(events); - expect(events.length).toBe(8); }); it('should handle tool calls that do not require approval', async () => { diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index e7b45d347c..89bfa2cf25 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -20,6 +20,7 @@ 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 { SimpleExtensionLoader } from '@google/gemini-cli-core'; const coderAgentCard: AgentCard = { name: 'Gemini SDLC Agent', @@ -70,7 +71,11 @@ export async function createApp() { loadEnvironment(); const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); - const config = await loadConfig(settings, extensions, 'a2a-server'); + const config = await loadConfig( + settings, + new SimpleExtensionLoader(extensions), + 'a2a-server', + ); // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts index f806af833d..74b5ec9320 100644 --- a/packages/a2a-server/src/types.ts +++ b/packages/a2a-server/src/types.ts @@ -37,6 +37,10 @@ export enum CoderAgentEvent { * An event that contains a thought from the agent. */ ThoughtEvent = 'thought', + /** + * An event that contains citation from the agent. + */ + CitationEvent = 'citation', } export interface AgentSettings { @@ -64,6 +68,10 @@ export interface Thought { kind: CoderAgentEvent.ThoughtEvent; } +export interface Citation { + kind: CoderAgentEvent.CitationEvent; +} + export type ThoughtSummary = { subject: string; description: string; @@ -80,7 +88,8 @@ export type CoderAgentMessage = | ToolCallUpdate | TextContent | StateChange - | Thought; + | Thought + | Citation; export interface TaskMetadata { id: string; diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b54c665cd..a2ba06ce2d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.12.0-nightly.20251022.0542de95", + "version": "0.13.0-nightly.20251029.cca41edc", "description": "Gemini CLI", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-nightly.20251022.0542de95" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.13.0-nightly.20251029.cca41edc" }, "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -45,6 +45,7 @@ "ink": "^6.2.3", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", + "latest-version": "^9.0.0", "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", @@ -58,7 +59,6 @@ "strip-json-comments": "^3.1.1", "tar": "^7.5.1", "undici": "^7.10.0", - "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", "zod": "^3.23.8" diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap new file mode 100644 index 0000000000..5d41472b89 --- /dev/null +++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` +"Use mock tool +Use mock tool again +Finished. +" +`; diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 184d11a410..bb60087275 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -17,20 +17,24 @@ interface DisableArgs { scope?: string; } -export function handleDisable(args: DisableArgs) { +export async function handleDisable(args: DisableArgs) { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + await extensionManager.loadExtensions(); try { if (args.scope?.toLowerCase() === 'workspace') { - extensionManager.disableExtension(args.name, SettingScope.Workspace); + await extensionManager.disableExtension( + args.name, + SettingScope.Workspace, + ); } else { - extensionManager.disableExtension(args.name, SettingScope.User); + await extensionManager.disableExtension(args.name, SettingScope.User); } debugLogger.log( `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 43523af372..0796830100 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -20,14 +20,16 @@ interface EnableArgs { scope?: string; } -export function handleEnable(args: EnableArgs) { +export async function handleEnable(args: EnableArgs) { const workingDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir: workingDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workingDir), + settings: loadSettings(workingDir).merged, }); + await extensionManager.loadExtensions(); + try { if (args.scope?.toLowerCase() === 'workspace') { extensionManager.enableExtension(args.name, SettingScope.Workspace); diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 7348bf89ec..1e5ff94eb6 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -23,6 +23,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => { ...actual, ExtensionManager: vi.fn().mockImplementation(() => ({ installOrUpdateExtension: mockInstallOrUpdateExtension, + loadExtensions: vi.fn(), })), }; }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 13c59a1855..920cfe63a4 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -74,8 +74,9 @@ export async function handleInstall(args: InstallArgs) { workspaceDir, requestConsent, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + await extensionManager.loadExtensions(); const name = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log(`Extension "${name}" installed successfully and enabled.`); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 9f0693cd7e..9bee299a5e 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -31,8 +31,9 @@ export async function handleLink(args: InstallArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + await extensionManager.loadExtensions(); const extensionName = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log( diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 432299c902..4596f95cd9 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -19,9 +19,9 @@ export async function handleList() { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); if (extensions.length === 0) { debugLogger.log('No extensions installed.'); return; diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 59dc8c828f..c768c95164 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -23,8 +23,9 @@ export async function handleUninstall(args: UninstallArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension(args.name, false); debugLogger.log(`Extension "${args.name}" successfully uninstalled.`); } catch (error) { diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 5523149f18..f3e78f2cca 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -34,10 +34,10 @@ export async function handleUpdate(args: UpdateArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); if (args.name) { try { const extension = extensions.find( diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 3253641894..9b5571d134 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -28,12 +28,12 @@ async function getMcpServersFromConfig(): Promise< > { const settings = loadSettings(); const extensionManager = new ExtensionManager({ - loadedSettings: settings, + settings: settings.merged, workspaceDir: process.cwd(), requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); const mcpServers = { ...(settings.merged.mcpServers || {}) }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index b935d4a696..c6e8a71458 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -8,18 +8,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { + DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, OutputFormat, - type GeminiCLIExtension, SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, + type ExtensionLoader, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import { ExtensionManager } from './extension-manager.js'; vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi @@ -96,11 +98,22 @@ vi.mock('@google/gemini-cli-core', async () => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => - Promise.resolve({ - memoryContent: extensionPaths?.join(',') || '', + ( + cwd, + dirs, + debug, + fileService, + extensionLoader: ExtensionLoader, + _maxDirs, + ) => { + const extensionPaths = extensionLoader + .getExtensions() + .flatMap((e) => e.contextFiles); + return Promise.resolve({ + memoryContent: extensionPaths.join(',') || '', fileCount: extensionPaths?.length || 0, - }), + }); + }, ), DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, @@ -113,13 +126,26 @@ vi.mock('@google/gemini-cli-core', async () => { }; }); +vi.mock('./extension-manager.js'); + +// Global setup to ensure clean environment for all tests in this file +const originalArgv = process.argv; +const originalGeminiModel = process.env['GEMINI_MODEL']; + +beforeEach(() => { + delete process.env['GEMINI_MODEL']; +}); + +afterEach(() => { + process.argv = originalArgv; + if (originalGeminiModel !== undefined) { + process.env['GEMINI_MODEL'] = originalGeminiModel; + } else { + delete process.env['GEMINI_MODEL']; + } +}); + describe('parseArguments', () => { - const originalArgv = process.argv; - - afterEach(() => { - process.argv = originalArgv; - }); - it('should throw an error when both --prompt and --prompt-interactive are used together', async () => { process.argv = [ 'node', @@ -234,13 +260,13 @@ describe('parseArguments', () => { '@path', './file.md', '--model', - 'gemini-1.5-pro', + 'gemini-2.5-pro', ]; const argv = await parseArguments({} as Settings); expect(argv.query).toBe('@path ./file.md'); expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot expect(argv.promptInteractive).toBeUndefined(); - expect(argv.model).toBe('gemini-1.5-pro'); + expect(argv.model).toBe('gemini-2.5-pro'); }); it('maps unquoted positional @path + arg to prompt (one-shot)', async () => { @@ -493,16 +519,14 @@ describe('parseArguments', () => { }); describe('loadCliConfig', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -537,7 +561,7 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); @@ -578,11 +602,24 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); }); }); + + it('should use default fileFilter options when unconfigured', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = {}; + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getFileFilteringRespectGitIgnore()).toBe( + DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, + ); + expect(config.getFileFilteringRespectGeminiIgnore()).toBe( + DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore, + ); + }); }); describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { @@ -599,7 +636,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -627,15 +664,15 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { ], isActive: true, }, - ]; + ]); const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'session-id', argv); + await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], false, expect.any(Object), - extensions, + expect.any(ExtensionManager), true, 'tree', { @@ -689,7 +726,8 @@ describe('mergeMcpServers', () => { }, }, }; - const extensions: GeminiCLIExtension[] = [ + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -704,11 +742,11 @@ describe('mergeMcpServers', () => { contextFiles: [], isActive: true, }, - ]; + ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); @@ -722,6 +760,7 @@ describe('mergeExcludeTools', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); process.stdin.isTTY = true; }); @@ -731,7 +770,7 @@ describe('mergeExcludeTools', () => { it('should merge excludeTools from settings and extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -750,12 +789,12 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, - extensions, + 'test-session', argv, ); @@ -767,7 +806,7 @@ describe('mergeExcludeTools', () => { it('should handle overlapping excludeTools between settings and extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -777,15 +816,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2', 'tool3']), ); @@ -794,7 +828,7 @@ describe('mergeExcludeTools', () => { it('should handle overlapping excludeTools between extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -813,15 +847,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']), ); @@ -831,30 +860,18 @@ describe('mergeExcludeTools', () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => { process.stdin.isTTY = true; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual([]); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(defaultExcludes); }); @@ -862,13 +879,8 @@ describe('mergeExcludeTools', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2']), ); @@ -877,7 +889,7 @@ describe('mergeExcludeTools', () => { it('should handle extensions with excludeTools but no settings', async () => { const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', @@ -887,15 +899,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2']), ); @@ -904,7 +911,7 @@ describe('mergeExcludeTools', () => { it('should not modify the original settings object', async () => { const settings: Settings = { tools: { exclude: ['tool1'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', @@ -914,11 +921,11 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); @@ -932,6 +939,7 @@ describe('Approval mode tool exclusion logic', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -942,14 +950,7 @@ describe('Approval mode tool exclusion logic', () => { process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -968,14 +969,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -994,14 +989,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -1020,14 +1009,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1039,14 +1022,8 @@ describe('Approval mode tool exclusion logic', () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1069,14 +1046,8 @@ describe('Approval mode tool exclusion logic', () => { process.argv = testCase.args; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1096,14 +1067,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { exclude: ['custom_tool'] } }; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain('custom_tool'); // From settings @@ -1120,11 +1085,8 @@ describe('Approval mode tool exclusion logic', () => { disableYoloMode: true, }, }; - const extensions: GeminiCLIExtension[] = []; - await expect( - loadCliConfig(settings, extensions, 'test-session', argv), - ).rejects.toThrow( + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode when it is disabled by settings', ); }); @@ -1139,14 +1101,8 @@ describe('Approval mode tool exclusion logic', () => { }; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; await expect( - loadCliConfig( - settings, - extensions, - 'test-session', - invalidArgv as CliArgs, - ), + loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', ); @@ -1154,16 +1110,14 @@ describe('Approval mode tool exclusion logic', () => { }); describe('loadCliConfig with allowed-mcp-server-names', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1179,7 +1133,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1191,7 +1145,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server1', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1207,7 +1161,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server3', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server3: { url: 'http://localhost:8082' }, @@ -1224,7 +1178,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server4', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1233,7 +1187,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({}); }); @@ -1244,7 +1198,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, @@ -1258,7 +1212,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server3: { url: 'http://localhost:8082' }, }); @@ -1274,7 +1228,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server1', 'server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, }); @@ -1295,7 +1249,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1318,7 +1272,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { excluded: ['server3'], // Should be ignored }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, @@ -1327,21 +1281,28 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); describe('loadCliConfig model selection', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { model: { - name: 'gemini-9001-ultra', + name: 'gemini-2.5-pro', }, }, - [], 'test-session', argv, ); - expect(config.getModel()).toBe('gemini-9001-ultra'); + expect(config.getModel()).toBe('gemini-2.5-pro'); }); it('uses the default gemini model if nothing is set', async () => { @@ -1351,7 +1312,6 @@ describe('loadCliConfig model selection', () => { { // No model set. }, - [], 'test-session', argv, ); @@ -1360,39 +1320,45 @@ describe('loadCliConfig model selection', () => { }); it('always prefers model from argv', async () => { - process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { model: { - name: 'gemini-9001-ultra', + name: 'gemini-2.5-pro', }, }, - [], 'test-session', argv, ); - expect(config.getModel()).toBe('gemini-8675309-ultra'); + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); it('selects the model from argv if provided', async () => { - process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { // No model provided via settings. }, - [], 'test-session', argv, ); - expect(config.getModel()).toBe('gemini-8675309-ultra'); + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); }); describe('loadCliConfig model selection with model router', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('should use auto model when useModelRouter is true and no model is provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); @@ -1402,7 +1368,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1419,7 +1384,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: false, }, }, - [], 'test-session', argv, ); @@ -1436,7 +1400,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1456,7 +1419,6 @@ describe('loadCliConfig model selection with model router', () => { name: 'gemini-from-settings', }, }, - [], 'test-session', argv, ); @@ -1474,7 +1436,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1484,16 +1445,14 @@ describe('loadCliConfig model selection with model router', () => { }); describe('loadCliConfig folderTrust', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1508,7 +1467,7 @@ describe('loadCliConfig folderTrust', () => { }, }; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1522,7 +1481,7 @@ describe('loadCliConfig folderTrust', () => { }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); @@ -1530,14 +1489,12 @@ describe('loadCliConfig folderTrust', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); }); describe('loadCliConfig with includeDirectories', () => { - const originalArgv = process.argv; - beforeEach(() => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); @@ -1545,11 +1502,10 @@ describe('loadCliConfig with includeDirectories', () => { vi.spyOn(process, 'cwd').mockReturnValue( path.resolve(path.sep, 'home', 'user', 'project'), ); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { - process.argv = originalArgv; - vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1571,7 +1527,7 @@ describe('loadCliConfig with includeDirectories', () => { ], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, path.resolve(path.sep, 'cli', 'path1'), @@ -1590,16 +1546,14 @@ describe('loadCliConfig with includeDirectories', () => { }); describe('loadCliConfig chatCompression', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1614,7 +1568,7 @@ describe('loadCliConfig chatCompression', () => { }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getChatCompression()).toEqual({ contextPercentageThreshold: 0.5, }); @@ -1624,22 +1578,20 @@ describe('loadCliConfig chatCompression', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getChatCompression()).toBeUndefined(); }); }); describe('loadCliConfig useRipgrep', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1648,7 +1600,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1656,7 +1608,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { useRipgrep: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); @@ -1664,7 +1616,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { useRipgrep: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1673,7 +1625,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(true); }); @@ -1681,7 +1633,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { experimental: { useModelRouter: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(true); }); @@ -1689,23 +1641,21 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { experimental: { useModelRouter: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(false); }); }); }); describe('screenReader configuration', () => { - const originalArgv = process.argv; - 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(() => { - process.argv = originalArgv; vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -1716,7 +1666,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: true } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); @@ -1726,7 +1676,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: false } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); @@ -1736,7 +1686,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: false } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); @@ -1744,13 +1694,12 @@ describe('screenReader configuration', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); }); describe('loadCliConfig tool exclusions', () => { - const originalArgv = process.argv; const originalIsTTY = process.stdin.isTTY; beforeEach(() => { @@ -1762,10 +1711,10 @@ describe('loadCliConfig tool exclusions', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { - process.argv = originalArgv; process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -1775,7 +1724,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1785,7 +1734,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1795,7 +1744,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); expect(config.getExcludeTools()).toContain('write_file'); @@ -1805,7 +1754,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1822,7 +1771,7 @@ describe('loadCliConfig tool exclusions', () => { 'ShellTool', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1837,7 +1786,7 @@ describe('loadCliConfig tool exclusions', () => { 'run_shell_command', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1852,13 +1801,12 @@ describe('loadCliConfig tool exclusions', () => { 'ShellTool(wc)', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); describe('loadCliConfig interactive', () => { - const originalArgv = process.argv; const originalIsTTY = process.stdin.isTTY; beforeEach(() => { @@ -1866,10 +1814,10 @@ describe('loadCliConfig interactive', () => { vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.stdin.isTTY = true; + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { - process.argv = originalArgv; process.stdin.isTTY = originalIsTTY; vi.unstubAllEnvs(); vi.restoreAllMocks(); @@ -1879,7 +1827,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1887,7 +1835,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1895,7 +1843,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1903,15 +1851,15 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if positional prompt words are provided with other flags', async () => { process.stdin.isTTY = true; - process.argv = ['node', 'script.js', '--model', 'gemini-1.5-pro', 'Hello']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1921,12 +1869,12 @@ describe('loadCliConfig interactive', () => { 'node', 'script.js', '--model', - 'gemini-1.5-pro', + 'gemini-2.5-pro', '--yolo', 'Hello world', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); // Verify the question is preserved for one-shot execution expect(argv.prompt).toBe('Hello world'); @@ -1937,7 +1885,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-e', 'none', 'hello']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello'); expect(argv.extensions).toEqual(['none']); @@ -1947,7 +1895,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'hello world how are you']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.prompt).toBe('hello world how are you'); @@ -1959,7 +1907,7 @@ describe('loadCliConfig interactive', () => { 'node', 'script.js', '--model', - 'gemini-1.5-pro', + 'gemini-2.5-pro', 'write', 'a', 'function', @@ -1968,17 +1916,17 @@ describe('loadCliConfig interactive', () => { 'array', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('write a function to sort array'); - expect(argv.model).toBe('gemini-1.5-pro'); + expect(argv.model).toBe('gemini-2.5-pro'); }); it('should handle empty positional arguments', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); @@ -1997,7 +1945,7 @@ describe('loadCliConfig interactive', () => { 'you', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); @@ -2005,9 +1953,9 @@ describe('loadCliConfig interactive', () => { it('should be interactive if no positional prompt words are provided with flags', async () => { process.stdin.isTTY = true; - process.argv = ['node', 'script.js', '--model', 'gemini-1.5-pro']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); }); @@ -2024,6 +1972,7 @@ describe('loadCliConfig approval mode', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -2035,42 +1984,42 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -2081,14 +2030,14 @@ describe('loadCliConfig approval mode', () => { const argv = await parseArguments({} as Settings); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -2104,28 +2053,28 @@ describe('loadCliConfig approval mode', () => { it('should override --approval-mode=yolo to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); @@ -2139,6 +2088,7 @@ describe('loadCliConfig fileFiltering', () => { vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.argv = ['node', 'script.js']; // Reset argv for each test + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -2206,17 +2156,25 @@ describe('loadCliConfig fileFiltering', () => { }, }; const argv = await parseArguments(settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); }, ); }); describe('Output format', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('should default to TEXT', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); @@ -2225,7 +2183,6 @@ describe('Output format', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { output: { format: OutputFormat.JSON } }, - [], 'test-session', argv, ); @@ -2237,7 +2194,6 @@ describe('Output format', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { output: { format: OutputFormat.JSON } }, - [], 'test-session', argv, ); @@ -2247,7 +2203,7 @@ describe('Output format', () => { it('should accept stream-json as a valid output format', async () => { process.argv = ['node', 'script.js', '--output-format', 'stream-json']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); @@ -2336,12 +2292,19 @@ describe('parseArguments with positional prompt', () => { }); describe('Telemetry configuration via environment variables', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + afterEach(() => { + vi.resetAllMocks(); + }); + it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2352,7 +2315,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -2363,9 +2326,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.GCP }, }; - await expect( - loadCliConfig(settings, [], 'test-session', argv), - ).rejects.toThrow( + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); vi.unstubAllEnvs(); @@ -2379,7 +2340,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.com' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); @@ -2388,7 +2349,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -2397,7 +2358,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -2408,7 +2369,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { outfile: '/settings/telemetry.log' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); @@ -2417,7 +2378,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { useCollector: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); @@ -2426,7 +2387,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2437,7 +2398,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); @@ -2445,7 +2406,7 @@ describe('Telemetry configuration via environment variables', () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2455,7 +2416,6 @@ describe('Telemetry configuration via environment variables', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { telemetry: { enabled: true } }, - [], 'test-session', argv, ); @@ -2466,7 +2426,7 @@ describe('Telemetry configuration via environment variables', () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); @@ -2476,7 +2436,6 @@ describe('Telemetry configuration via environment variables', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { telemetry: { logPrompts: true } }, - [], 'test-session', argv, ); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f6ae37a0b6..ffc4d95353 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -27,6 +27,7 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, FileDiscoveryService, WRITE_FILE_TOOL_NAME, @@ -47,6 +48,10 @@ import { appEvents } from '../utils/events.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig } from './policy.js'; +import { ExtensionManager } from './extension-manager.js'; +import type { ExtensionLoader } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; +import { requestConsentNonInteractive } from './extensions/consent.js'; +import { promptForSetting } from './extensions/extensionSettings.js'; export interface CliArgs { query: string | undefined; @@ -69,6 +74,7 @@ export interface CliArgs { useWriteTodos: boolean | undefined; outputFormat: string | undefined; fakeResponses: string | undefined; + recordResponses: string | undefined; } export async function parseArguments(settings: Settings): Promise { @@ -197,6 +203,12 @@ export async function parseArguments(settings: Settings): Promise { .option('fake-responses', { type: 'string', description: 'Path to a file with fake model responses for testing.', + hidden: true, + }) + .option('record-responses', { + type: 'string', + description: 'Path to a file to record model responses for testing.', + hidden: true, }) .deprecateOption( 'prompt', @@ -292,7 +304,7 @@ export async function loadHierarchicalGeminiMemory( debugMode: boolean, fileService: FileDiscoveryService, settings: Settings, - extensions: GeminiCLIExtension[], + extensionLoader: ExtensionLoader, folderTrust: boolean, memoryImportFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, @@ -318,7 +330,7 @@ export async function loadHierarchicalGeminiMemory( includeDirectoriesToReadGemini, debugMode, fileService, - extensions, + extensionLoader, folderTrust, memoryImportFormat, fileFilteringOptions, @@ -367,13 +379,16 @@ export function isDebugMode(argv: CliArgs): boolean { export async function loadCliConfig( settings: Settings, - allExtensions: GeminiCLIExtension[], sessionId: string, argv: CliArgs, cwd: string = process.cwd(), ): Promise { const debugMode = isDebugMode(argv); + if (argv.sandbox) { + process.env['GEMINI_SANDBOX'] = 'true'; + } + const memoryImportFormat = settings.context?.importFormat || 'tree'; const ideMode = settings.ide?.enabled ?? false; @@ -394,15 +409,29 @@ export async function loadCliConfig( const fileService = new FileDiscoveryService(cwd); - const fileFiltering = { + const memoryFileFiltering = { ...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, ...settings.context?.fileFiltering, }; + const fileFiltering = { + ...DEFAULT_FILE_FILTERING_OPTIONS, + ...settings.context?.fileFiltering, + }; + const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + const extensionManager = new ExtensionManager({ + settings, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + workspaceDir: cwd, + enabledExtensionOverrides: argv.extensions, + }); + await extensionManager.loadExtensions(); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount, filePaths } = await loadHierarchicalGeminiMemory( @@ -413,13 +442,13 @@ export async function loadCliConfig( debugMode, fileService, settings, - allExtensions, + extensionManager, trustedFolder, memoryImportFormat, - fileFiltering, + memoryFileFiltering, ); - let mcpServers = mergeMcpServers(settings, allExtensions); + let mcpServers = mergeMcpServers(settings, extensionManager.getExtensions()); const question = argv.promptInteractive || argv.prompt || ''; // Determine approval mode with backward compatibility @@ -485,7 +514,31 @@ export async function loadCliConfig( throw err; } - const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode); + const policyEngineConfig = await createPolicyEngineConfig( + settings, + approvalMode, + ); + + // Debug: Log the merged policy configuration + // Only log when message bus integration is enabled (when policies are active) + const enableMessageBusIntegration = + settings.tools?.enableMessageBusIntegration ?? false; + if (enableMessageBusIntegration) { + debugLogger.debug('=== Policy Engine Configuration ==='); + debugLogger.debug( + `Default decision: ${policyEngineConfig.defaultDecision}`, + ); + debugLogger.debug(`Total rules: ${policyEngineConfig.rules?.length || 0}`); + if (policyEngineConfig.rules && policyEngineConfig.rules.length > 0) { + debugLogger.debug('Rules (sorted by priority):'); + policyEngineConfig.rules.forEach((rule, index) => { + debugLogger.debug( + ` [${index}] toolName: ${rule.toolName || '*'}, decision: ${rule.decision}, priority: ${rule.priority}, argsPattern: ${rule.argsPattern ? rule.argsPattern.source : 'none'}`, + ); + }); + } + debugLogger.debug('==================================='); + } const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedToolsSet = new Set(allowedTools); @@ -530,7 +583,7 @@ export async function loadCliConfig( const excludeTools = mergeExcludeTools( settings, - allExtensions, + extensionManager.getExtensions(), extraExcludes.length > 0 ? extraExcludes : undefined, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -626,7 +679,7 @@ export async function loadCliConfig( experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, enabledExtensions: argv.extensions, - extensions: allExtensions, + extensionLoader: extensionManager, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -650,11 +703,11 @@ export async function loadCliConfig( format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, useModelRouter, - enableMessageBusIntegration: - settings.tools?.enableMessageBusIntegration ?? false, + enableMessageBusIntegration, codebaseInvestigatorSettings: settings.experimental?.codebaseInvestigatorSettings, fakeResponses: argv.fakeResponses, + recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors ?? false, ptyInfo: ptyInfo?.name, }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index d175b8382c..9980474e73 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type LoadedSettings, SettingScope } from './settings.js'; +import { type Settings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -50,33 +50,45 @@ import { maybePromptForSettings, type ExtensionSetting, } from './extensions/extensionSettings.js'; +import type { + ExtensionEvents, + ExtensionLoader, +} from '@google/gemini-cli-core/src/utils/extensionLoader.js'; +import { EventEmitter } from 'node:events'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; - loadedSettings: LoadedSettings; + settings: Settings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; } -export class ExtensionManager { +/** + * Actual implementation of an ExtensionLoader. + * + * You must call `loadExtensions` prior to calling other methods on this class. + */ +export class ExtensionManager implements ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; - private loadedSettings: LoadedSettings; + private settings: Settings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) - | null; + | undefined; private telemetryConfig: Config; private workspaceDir: string; + private loadedExtensions: GeminiCLIExtension[] | undefined; + private eventEmitter: EventEmitter; constructor(options: ExtensionManagerParams) { this.workspaceDir = options.workspaceDir; this.extensionEnablementManager = new ExtensionEnablementManager( options.enabledExtensionOverrides, ); - this.loadedSettings = options.loadedSettings; + this.settings = options.settings; this.telemetryConfig = new Config({ - telemetry: options.loadedSettings.merged.telemetry, + telemetry: options.settings.telemetry, interactive: false, sessionId: randomUUID(), targetDir: options.workspaceDir, @@ -85,19 +97,45 @@ export class ExtensionManager { debugMode: false, }); this.requestConsent = options.requestConsent; - this.requestSetting = options.requestSetting; + this.requestSetting = options.requestSetting ?? undefined; + this.eventEmitter = new EventEmitter(); + } + + setRequestConsent( + requestConsent: (consent: string) => Promise, + ): void { + this.requestConsent = requestConsent; + } + + setRequestSetting( + requestSetting?: (setting: ExtensionSetting) => Promise, + ): void { + this.requestSetting = requestSetting; + } + + getExtensions(): GeminiCLIExtension[] { + if (!this.loadedExtensions) { + throw new Error( + 'Extensions not yet loaded, must call `loadExtensions` first', + ); + } + return this.loadedExtensions!; + } + + extensionEvents(): EventEmitter { + return this.eventEmitter; } async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, - ): Promise { + ): Promise { const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; + let extension: GeminiCLIExtension | null; try { - const settings = this.loadedSettings.merged; - if (!isWorkspaceTrusted(settings).isTrusted) { + if (!isWorkspaceTrusted(this.settings).isTrusted) { throw new Error( `Could not install extension from untrusted folder at ${installMetadata.source}`, ); @@ -187,17 +225,17 @@ export class ExtensionManager { } const newExtensionName = newExtensionConfig.name; - if (!isUpdate) { - const installedExtensions = this.loadExtensions(); - if ( - installedExtensions.some( - (installed) => installed.name === newExtensionName, - ) - ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, - ); - } + const previous = this.getExtensions().find( + (installed) => installed.name === newExtensionName, + ); + if (isUpdate && !previous) { + throw new Error( + `Extension "${newExtensionName}" was not already installed, cannot update it.`, + ); + } else if (!isUpdate && previous) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); } await maybeRequestConsentOrFail( @@ -205,12 +243,16 @@ export class ExtensionManager { this.requestConsent, previousExtensionConfig, ); - - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); + const extensionId = getExtensionId(newExtensionConfig, installMetadata); + const destinationPath = new ExtensionStorage( + newExtensionName, + ).getExtensionDir(); let previousSettings: Record | undefined; if (isUpdate) { - previousSettings = getEnvContents(extensionStorage); + previousSettings = await getEnvContents( + previousExtensionConfig, + extensionId, + ); await this.uninstallExtension(newExtensionName, isUpdate); } @@ -219,6 +261,7 @@ export class ExtensionManager { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, + extensionId, this.requestSetting, previousExtensionConfig, previousSettings, @@ -226,6 +269,7 @@ export class ExtensionManager { } else { await maybePromptForSettings( newExtensionConfig, + extensionId, this.requestSetting, ); } @@ -245,39 +289,46 @@ export class ExtensionManager { INSTALL_METADATA_FILENAME, ); await fs.promises.writeFile(metadataPath, metadataString); + + // TODO: Gracefully handle this call failing, we should back up the old + // extension prior to overwriting it and then restore it. + extension = await this.loadExtension(destinationPath)!; + if (!extension) { + throw new Error(`Extension not found`); + } + if (isUpdate) { + logExtensionUpdateEvent( + this.telemetryConfig, + new ExtensionUpdateEvent( + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + previousExtensionConfig.version, + installMetadata.type, + 'success', + ), + ); + this.eventEmitter.emit('extensionUpdated', { extension }); + } else { + logExtensionInstallEvent( + this.telemetryConfig, + new ExtensionInstallEvent( + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + installMetadata.type, + 'success', + ), + ); + this.eventEmitter.emit('extensionInstalled', { extension }); + this.enableExtension(newExtensionConfig.name, SettingScope.User); + } } finally { if (tempDir) { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } - - if (isUpdate) { - logExtensionUpdateEvent( - this.telemetryConfig, - new ExtensionUpdateEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - previousExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - } else { - logExtensionInstallEvent( - this.telemetryConfig, - new ExtensionInstallEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - this.enableExtension(newExtensionConfig.name, SettingScope.User); - } - - return newExtensionConfig!.name; + return extension; } catch (error) { // Attempt to load config from the source path even if installation fails // to get the name and version for logging. @@ -324,7 +375,7 @@ export class ExtensionManager { extensionIdentifier: string, isUpdate: boolean, ): Promise { - const installedExtensions = this.loadExtensions(); + const installedExtensions = this.getExtensions(); const extension = installedExtensions.find( (installed) => installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || @@ -334,6 +385,7 @@ export class ExtensionManager { if (!extension) { throw new Error(`Extension not found.`); } + this.unloadExtension(extension); const storage = new ExtensionStorage(extension.name); await fs.promises.rm(storage.getExtensionDir(), { @@ -355,36 +407,30 @@ export class ExtensionManager { 'success', ), ); + this.eventEmitter.emit('extensionUninstalled', { extension }); } - loadExtensions(): GeminiCLIExtension[] { - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - if (!fs.existsSync(extensionsDir)) { - return []; + async loadExtensions(): Promise { + if (this.loadedExtensions) { + throw new Error('Extensions already loaded, only load extensions once.'); + } + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + this.loadedExtensions = []; + if (!fs.existsSync(extensionsDir)) { + return this.loadedExtensions; } - - const extensions: GeminiCLIExtension[] = []; for (const subdir of fs.readdirSync(extensionsDir)) { const extensionDir = path.join(extensionsDir, subdir); - const extension = this.loadExtension(extensionDir); - if (extension != null) { - extensions.push(extension); - } + await this.loadExtension(extensionDir); } - - const uniqueExtensions = new Map(); - - for (const extension of extensions) { - if (!uniqueExtensions.has(extension.name)) { - uniqueExtensions.set(extension.name, extension); - } - } - - return Array.from(uniqueExtensions.values()); + return this.loadedExtensions; } - loadExtension(extensionDir: string): GeminiCLIExtension | null { + private async loadExtension( + extensionDir: string, + ): Promise { + this.loadedExtensions ??= []; if (!fs.statSync(extensionDir).isDirectory()) { return null; } @@ -398,8 +444,18 @@ export class ExtensionManager { try { let config = this.loadExtensionConfig(effectiveExtensionPath); + if ( + this.getExtensions().find((extension) => extension.name === config.name) + ) { + throw new Error( + `Extension with name ${config.name} already was loaded.`, + ); + } - const customEnv = getEnvContents(new ExtensionStorage(config.name)); + const customEnv = await getEnvContents( + config, + getExtensionId(config, installMetadata), + ); config = resolveEnvVarsInObject(config, customEnv); if (config.mcpServers) { @@ -417,7 +473,7 @@ export class ExtensionManager { ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); - return { + const extension = { name: config.name, version: config.version, path: effectiveExtensionPath, @@ -431,6 +487,9 @@ export class ExtensionManager { ), id: getExtensionId(config, installMetadata), }; + this.eventEmitter.emit('extensionLoaded', { extension }); + this.getExtensions().push(extension); + return extension; } catch (e) { debugLogger.error( `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( @@ -441,24 +500,11 @@ export class ExtensionManager { } } - loadExtensionByName(name: string): GeminiCLIExtension | null { - const userExtensionsDir = ExtensionStorage.getUserExtensionsDir(); - if (!fs.existsSync(userExtensionsDir)) { - return null; - } - - for (const subdir of fs.readdirSync(userExtensionsDir)) { - const extensionDir = path.join(userExtensionsDir, subdir); - if (!fs.statSync(extensionDir).isDirectory()) { - continue; - } - const extension = this.loadExtension(extensionDir); - if (extension && extension.name.toLowerCase() === name.toLowerCase()) { - return extension; - } - } - - return null; + private unloadExtension(extension: GeminiCLIExtension) { + this.loadedExtensions = this.getExtensions().filter( + (entry) => extension !== entry, + ); + this.eventEmitter.emit('extensionUnloaded', { extension }); } loadExtensionConfig(extensionDir: string): ExtensionConfig { @@ -474,11 +520,10 @@ export class ExtensionManager { `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, ); } - const installDir = new ExtensionStorage(rawConfig.name).getExtensionDir(); const config = recursivelyHydrateStrings( rawConfig as unknown as JsonObject, { - extensionPath: installDir, + extensionPath: extensionDir, workspacePath: this.workspaceDir, '/': path.sep, pathSeparator: path.sep, @@ -542,14 +587,16 @@ export class ExtensionManager { return output; } - disableExtension(name: string, scope: SettingScope) { + async disableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const extension = this.loadExtensionByName(name); + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } @@ -561,16 +608,20 @@ export class ExtensionManager { this.telemetryConfig, new ExtensionDisableEvent(hashValue(name), extension.id, scope), ); + extension.isActive = false; + this.eventEmitter.emit('extensionDisabled', { extension }); } - enableExtension(name: string, scope: SettingScope) { + async enableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const extension = this.loadExtensionByName(name); + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } @@ -581,6 +632,8 @@ export class ExtensionManager { this.telemetryConfig, new ExtensionEnableEvent(hashValue(name), extension.id, scope), ); + extension.isActive = true; + this.eventEmitter.emit('extensionEnabled', { extension }); } } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 9d81a26be2..21df5f26de 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -13,6 +13,7 @@ import { ExtensionUninstallEvent, ExtensionDisableEvent, ExtensionEnableEvent, + KeychainTokenStorage, } from '@google/gemini-cli-core'; import { loadSettings, SettingScope } from './settings.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -59,11 +60,13 @@ vi.mock('simple-git', () => ({ }), })); +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, - homedir: vi.fn(), + homedir: mockHomedir, }; }); @@ -94,6 +97,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), ExtensionDisableEvent: vi.fn(), + KeychainTokenStorage: vi.fn().mockImplementation(() => ({ + getSecret: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + listSecrets: vi.fn(), + isAvailable: vi.fn().mockResolvedValue(true), + })), }; }); @@ -105,6 +115,14 @@ vi.mock('child_process', async (importOriginal) => { }; }); +interface MockKeychainStorage { + getSecret: ReturnType; + setSecret: ReturnType; + deleteSecret: ReturnType; + listSecrets: ReturnType; + isAvailable: ReturnType; +} + describe('extension tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; @@ -114,8 +132,32 @@ describe('extension tests', () => { let mockPromptForSettings: MockedFunction< (setting: ExtensionSetting) => Promise >; + let mockKeychainStorage: MockKeychainStorage; + let keychainData: Record; beforeEach(() => { + vi.clearAllMocks(); + keychainData = {}; + mockKeychainStorage = { + getSecret: vi + .fn() + .mockImplementation(async (key: string) => keychainData[key] || null), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + }; + ( + KeychainTokenStorage as unknown as ReturnType + ).mockImplementation(() => mockKeychainStorage); tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); @@ -138,7 +180,7 @@ describe('extension tests', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); @@ -149,7 +191,7 @@ describe('extension tests', () => { }); describe('loadExtensions', () => { - it('should include extension path in loaded extension', () => { + it('should include extension path in loaded extension', async () => { const extensionDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extensionDir, { recursive: true }); @@ -159,13 +201,13 @@ describe('extension tests', () => { version: '1.0.0', }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].name).toBe('test-extension'); }); - it('should load context file path when GEMINI.md is present', () => { + it('should load context file path when GEMINI.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -178,7 +220,7 @@ describe('extension tests', () => { version: '2.0.0', }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(2); const ext1 = extensions.find((e) => e.name === 'ext1'); @@ -189,7 +231,7 @@ describe('extension tests', () => { expect(ext2?.contextFiles).toEqual([]); }); - it('should load context file path from the extension config', () => { + it('should load context file path from the extension config', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -198,7 +240,7 @@ describe('extension tests', () => { contextFileName: 'my-context-file.md', }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const ext1 = extensions.find((e) => e.name === 'ext1'); @@ -207,7 +249,7 @@ describe('extension tests', () => { ]); }); - it('should annotate disabled extensions', () => { + it('should annotate disabled extensions', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'disabled-extension', @@ -218,11 +260,12 @@ describe('extension tests', () => { name: 'enabled-extension', version: '2.0.0', }); - extensionManager.disableExtension( + await extensionManager.loadExtensions(); + await extensionManager.disableExtension( 'disabled-extension', SettingScope.User, ); - const extensions = extensionManager.loadExtensions(); + const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(2); expect(extensions[0].name).toBe('disabled-extension'); expect(extensions[0].isActive).toBe(false); @@ -230,7 +273,7 @@ describe('extension tests', () => { expect(extensions[1].isActive).toBe(true); }); - it('should hydrate variables', () => { + it('should hydrate variables', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', @@ -244,7 +287,7 @@ describe('extension tests', () => { }, }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const expectedCwd = path.join( userExtensionsDir, @@ -263,13 +306,14 @@ describe('extension tests', () => { }); fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - const extensionName = await extensionManager.installOrUpdateExtension({ + await extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); - expect(extensionName).toEqual('my-linked-extension'); - const extensions = extensionManager.loadExtensions(); + expect(extension.name).toEqual('my-linked-extension'); + const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(1); const linkedExt = extensions[0]; @@ -285,7 +329,37 @@ describe('extension tests', () => { ]); }); - it('should resolve environment variables in extension configuration', () => { + 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', + }, + }, + }); + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'link', + }); + + const extensions = extensionManager.getExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].mcpServers?.['test-server'].cwd).toBe( + path.join(sourceExtDir, 'server'), + ); + expect(extensions[0].mcpServers?.['test-server'].args).toEqual([ + path.join(sourceExtDir, 'server', 'index.js'), + ]); + }); + + it('should resolve environment variables in extension configuration', async () => { process.env['TEST_API_KEY'] = 'test-api-key-123'; process.env['TEST_DB_URL'] = 'postgresql://localhost:5432/testdb'; @@ -318,7 +392,7 @@ describe('extension tests', () => { }; fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -339,7 +413,7 @@ describe('extension tests', () => { } }); - it('should resolve environment variables from an extension .env file', () => { + it('should resolve environment variables from an extension .env file', async () => { const extDir = createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', @@ -354,12 +428,19 @@ describe('extension tests', () => { }, }, }, + settings: [ + { + name: 'My API Key', + description: 'API key for testing.', + envVar: 'MY_API_KEY', + }, + ], }); const envFilePath = path.join(extDir, '.env'); fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n'); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -369,7 +450,7 @@ describe('extension tests', () => { expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution'); }); - it('should handle missing environment variables gracefully', () => { + it('should handle missing environment variables gracefully', async () => { const userExtensionsDir = path.join( tempHomeDir, EXTENSIONS_DIRECTORY_NAME, @@ -399,7 +480,7 @@ describe('extension tests', () => { JSON.stringify(extensionConfig), ); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; @@ -409,7 +490,7 @@ describe('extension tests', () => { expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); - it('should skip extensions with invalid JSON and log a warning', () => { + it('should skip extensions with invalid JSON and log a warning', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -427,12 +508,11 @@ describe('extension tests', () => { const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledOnce(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), @@ -441,7 +521,7 @@ describe('extension tests', () => { consoleSpy.mockRestore(); }); - it('should skip extensions with missing name and log a warning', () => { + it('should skip extensions with missing name and log a warning', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -459,12 +539,11 @@ describe('extension tests', () => { const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledOnce(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), @@ -473,7 +552,7 @@ describe('extension tests', () => { consoleSpy.mockRestore(); }); - it('should filter trust out of mcp servers', () => { + it('should filter trust out of mcp servers', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', @@ -487,24 +566,24 @@ describe('extension tests', () => { }, }); - const extensions = extensionManager.loadExtensions(); + const extensions = await extensionManager.loadExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); - it('should throw an error for invalid extension names', () => { + it('should throw an error for invalid extension names', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - const badExtDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'bad_name', version: '1.0.0', }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'bad_name'); - const extension = extensionManager.loadExtension(badExtDir); - - expect(extension).toBeNull(); + expect(extension).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid extension name: "bad_name"'), ); @@ -512,8 +591,8 @@ describe('extension tests', () => { }); describe('id generation', () => { - it('should generate id from source for non-github git urls', () => { - const extensionDir = createExtension({ + it('should generate id from source for non-github git urls', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -522,13 +601,13 @@ describe('extension tests', () => { source: 'http://somehost.com/foo/bar', }, }); - - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar')); }); - it('should generate id from owner/repo for github http urls', () => { - const extensionDir = createExtension({ + it('should generate id from owner/repo for github http urls', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -538,12 +617,13 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); - it('should generate id from owner/repo for github ssh urls', () => { - const extensionDir = createExtension({ + it('should generate id from owner/repo for github ssh urls', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -553,12 +633,13 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); - it('should generate id from source for github-release extension', () => { - const extensionDir = createExtension({ + it('should generate id from source for github-release extension', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -567,13 +648,13 @@ describe('extension tests', () => { source: 'https://github.com/foo/bar', }, }); - - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); - it('should generate id from the original source for local extension', () => { - const extensionDir = createExtension({ + it('should generate id from the original source for local extension', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'local-ext-name', version: '1.0.0', @@ -583,7 +664,8 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'local-ext-name'); expect(extension?.id).toBe(hashValue('/some/path')); }); @@ -594,25 +676,27 @@ describe('extension tests', () => { name: 'link-ext-name', version: '1.0.0', }); - const extensionName = await extensionManager.installOrUpdateExtension({ + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ type: 'link', source: actualExtensionDir, }); - const extension = extensionManager.loadExtension( - new ExtensionStorage(extensionName).getExtensionDir(), - ); + const extension = extensionManager + .getExtensions() + .find((e) => e.name === 'link-ext-name'); expect(extension?.id).toBe(hashValue(actualExtensionDir)); }); - it('should generate id from name for extension with no install metadata', () => { - const extensionDir = createExtension({ + it('should generate id from name for extension with no install metadata', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'no-meta-name', version: '1.0.0', }); - const extension = extensionManager.loadExtension(extensionDir); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'no-meta-name'); expect(extension?.id).toBe(hashValue('no-meta-name')); }); }); @@ -628,6 +712,7 @@ describe('extension tests', () => { const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -649,6 +734,7 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.0.0', }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -742,6 +828,7 @@ describe('extension tests', () => { type: 'github-release', }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', @@ -766,6 +853,7 @@ describe('extension tests', () => { const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', @@ -795,6 +883,7 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.1.0', }); + await extensionManager.loadExtensions(); if (isUpdate) { await extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -868,12 +957,15 @@ describe('extension tests', () => { }, }); + await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ + name: 'my-local-extension', + }); expect(mockRequestConsent).toHaveBeenCalledWith( `Installing extension "my-local-extension". @@ -897,12 +989,13 @@ This extension will run the following MCP servers: }, }); + await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ name: 'my-local-extension' }); }); it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { @@ -918,6 +1011,7 @@ This extension will run the following MCP servers: }, }); mockRequestConsent.mockResolvedValue(false); + await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -935,6 +1029,7 @@ This extension will run the following MCP servers: const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -965,6 +1060,7 @@ This extension will run the following MCP servers: }, }); + await extensionManager.loadExtensions(); // Install it with hard coded consent first. await extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -979,7 +1075,7 @@ This extension will run the following MCP servers: // Provide its own existing config as the previous config. await extensionManager.loadExtensionConfig(sourceExtDir), ), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ name: 'my-local-extension' }); // Still only called once expect(mockRequestConsent).toHaveBeenCalledOnce(); @@ -999,6 +1095,7 @@ This extension will run the following MCP servers: ], }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -1025,9 +1122,10 @@ This extension will run the following MCP servers: workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: null, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -1050,6 +1148,7 @@ This extension will run the following MCP servers: }); mockPromptForSettings.mockResolvedValueOnce('old-api-key'); + await extensionManager.loadExtensions(); // Install it so it exists in the userExtensionsDir await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, @@ -1119,6 +1218,7 @@ This extension will run the following MCP servers: }, ], }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, type: 'local', @@ -1210,6 +1310,7 @@ This extension will run the following MCP servers: join(tempDir, extensionName), ); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release', @@ -1234,6 +1335,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Use github-release to force consent ); @@ -1264,6 +1366,7 @@ This extension will run the following MCP servers: }); mockRequestConsent.mockResolvedValue(false); + await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: gitUrl, @@ -1288,6 +1391,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', @@ -1318,6 +1422,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + await extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Note the type ); @@ -1339,7 +1444,7 @@ This extension will run the following MCP servers: name: 'my-local-extension', version: '1.0.0', }); - + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); @@ -1357,14 +1462,16 @@ This extension will run the following MCP servers: version: '1.0.0', }); + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(extensionManager.loadExtensions()).toHaveLength(1); + expect(extensionManager.getExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); it('should throw an error if the extension does not exist', async () => { + await extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension('nonexistent-extension', false), ).rejects.toThrow('Extension not found.'); @@ -1382,6 +1489,7 @@ This extension will run the following MCP servers: }, }); + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension( 'my-local-extension', isUpdate, @@ -1409,6 +1517,7 @@ This extension will run the following MCP servers: const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('test-extension', true, '/some/scope'); + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension('test-extension', isUpdate); const config = enablementManager.readConfig()['test-extension']; @@ -1433,6 +1542,7 @@ This extension will run the following MCP servers: }, }); + await extensionManager.loadExtensions(); await extensionManager.uninstallExtension(gitUrl, false); expect(fs.existsSync(sourceExtDir)).toBe(false); @@ -1452,6 +1562,7 @@ This extension will run the following MCP servers: // No installMetadata provided }); + await extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension( 'https://github.com/google/no-metadata-extension', @@ -1462,13 +1573,14 @@ This extension will run the following MCP servers: }); describe('disableExtension', () => { - it('should disable an extension at the user scope', () => { + it('should disable an extension at the user scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.User); expect( isEnabled({ @@ -1478,13 +1590,14 @@ This extension will run the following MCP servers: ).toBe(false); }); - it('should disable an extension at the workspace scope', () => { + it('should disable an extension at the workspace scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.Workspace); expect( isEnabled({ @@ -1500,13 +1613,14 @@ This extension will run the following MCP servers: ).toBe(false); }); - it('should handle disabling the same extension twice', () => { + it('should handle disabling the same extension twice', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.User); extensionManager.disableExtension('my-extension', SettingScope.User); expect( @@ -1517,13 +1631,17 @@ This extension will run the following MCP servers: ).toBe(false); }); - it('should throw an error if you request system scope', () => { - expect(() => - extensionManager.disableExtension('my-extension', SettingScope.System), - ).toThrow('System and SystemDefaults scopes are not supported.'); + it('should throw an error if you request system scope', async () => { + await expect( + async () => + await extensionManager.disableExtension( + 'my-extension', + SettingScope.System, + ), + ).rejects.toThrow('System and SystemDefaults scopes are not supported.'); }); - it('should log a disable event', () => { + it('should log a disable event', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -1534,6 +1652,7 @@ This extension will run the following MCP servers: }, }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionDisable).toHaveBeenCalled(); @@ -1551,43 +1670,45 @@ This extension will run the following MCP servers: }); const getActiveExtensions = (): GeminiCLIExtension[] => { - const extensions = extensionManager.loadExtensions(); + const extensions = extensionManager.getExtensions(); return extensions.filter((e) => e.isActive); }; - it('should enable an extension at the user scope', () => { + it('should enable an extension at the user scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.User); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); - extensionManager.enableExtension('ext1', SettingScope.User); - activeExtensions = getActiveExtensions(); + await extensionManager.enableExtension('ext1', SettingScope.User); + activeExtensions = await getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); }); - it('should enable an extension at the workspace scope', () => { + it('should enable an extension at the workspace scope', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', version: '1.0.0', }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); - extensionManager.enableExtension('ext1', SettingScope.Workspace); - activeExtensions = getActiveExtensions(); + await extensionManager.enableExtension('ext1', SettingScope.Workspace); + activeExtensions = await getActiveExtensions(); expect(activeExtensions).toHaveLength(1); expect(activeExtensions[0].name).toBe('ext1'); }); - it('should log an enable event', () => { + it('should log an enable event', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'ext1', @@ -1597,6 +1718,7 @@ This extension will run the following MCP servers: type: 'local', }, }); + await extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); extensionManager.enableExtension('ext1', SettingScope.Workspace); diff --git a/packages/cli/src/config/extensions/extensionEnablement.test.ts b/packages/cli/src/config/extensions/extensionEnablement.test.ts index c42374acac..e26ebdbf66 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.test.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.test.ts @@ -10,7 +10,11 @@ import * as os from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; -import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core'; +import { + coreEvents, + GEMINI_DIR, + type GeminiCLIExtension, +} from '@google/gemini-cli-core'; vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); @@ -272,20 +276,20 @@ describe('ExtensionEnablementManager', () => { }); describe('validateExtensionOverrides', () => { - let consoleErrorSpy: ReturnType; + let coreEventsEmitSpy: ReturnType; beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + coreEventsEmitSpy = vi.spyOn(coreEvents, 'emitFeedback'); }); afterEach(() => { - consoleErrorSpy.mockRestore(); + coreEventsEmitSpy.mockRestore(); }); it('should not log an error if enabledExtensionNamesOverride is empty', () => { const manager = new ExtensionEnablementManager([]); manager.validateExtensionOverrides([]); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); it('should not log an error if all enabledExtensionNamesOverride are valid', () => { @@ -295,7 +299,7 @@ describe('ExtensionEnablementManager', () => { { name: 'ext-two' }, ] as GeminiCLIExtension[]; manager.validateExtensionOverrides(extensions); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => { @@ -309,11 +313,13 @@ describe('ExtensionEnablementManager', () => { { name: 'ext-two' }, ] as GeminiCLIExtension[]; manager.validateExtensionOverrides(extensions); - expect(consoleErrorSpy).toHaveBeenCalledTimes(2); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(coreEventsEmitSpy).toHaveBeenCalledTimes(2); + expect(coreEventsEmitSpy).toHaveBeenCalledWith( + 'error', 'Extension not found: ext-invalid', ); - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(coreEventsEmitSpy).toHaveBeenCalledWith( + 'error', 'Extension not found: ext-another-invalid', ); }); @@ -321,7 +327,7 @@ describe('ExtensionEnablementManager', () => { it('should not log an error if "none" is in enabledExtensionNamesOverride', () => { const manager = new ExtensionEnablementManager(['none']); manager.validateExtensionOverrides([]); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts index 9994a4ecff..a619587342 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import { coreEvents, type GeminiCLIExtension } from '@google/gemini-cli-core'; import { ExtensionStorage } from './storage.js'; export interface ExtensionEnablementConfig { @@ -129,7 +129,7 @@ export class ExtensionEnablementManager { if ( !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase()) ) { - console.error(`Extension not found: ${name}`); + coreEvents.emitFeedback('error', `Extension not found: ${name}`); } } } @@ -188,7 +188,11 @@ export class ExtensionEnablementManager { ) { return {}; } - console.error('Error reading extension enablement config:', error); + coreEvents.emitFeedback( + 'error', + 'Failed to read extension enablement config.', + error, + ); return {}; } } diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index 9beb8a4284..e72ba8ad1a 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -17,6 +17,7 @@ import { ExtensionStorage } from './storage.js'; import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; +import { KeychainTokenStorage } from '@google/gemini-cli-core'; vi.mock('prompts'); vi.mock('os', async (importOriginal) => { @@ -27,11 +28,59 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + KeychainTokenStorage: vi.fn().mockImplementation(() => ({ + getSecret: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + listSecrets: vi.fn(), + isAvailable: vi.fn().mockResolvedValue(true), + })), + }; +}); + +interface MockKeychainStorage { + getSecret: ReturnType; + setSecret: ReturnType; + deleteSecret: ReturnType; + listSecrets: ReturnType; + isAvailable: ReturnType; +} + describe('extensionSettings', () => { let tempHomeDir: string; let extensionDir: string; + let mockKeychainStorage: MockKeychainStorage; + let keychainData: Record; beforeEach(() => { + vi.clearAllMocks(); + keychainData = {}; + mockKeychainStorage = { + getSecret: vi + .fn() + .mockImplementation(async (key: string) => keychainData[key] || null), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + }; + ( + KeychainTokenStorage as unknown as ReturnType + ).mockImplementation(() => mockKeychainStorage); + tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); // Spy and mock the method, but also create the directory so we can write to it. @@ -59,7 +108,13 @@ describe('extensionSettings', () => { it('should do nothing if settings are undefined', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; - await maybePromptForSettings(config, mockRequestSetting); + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); @@ -69,11 +124,17 @@ describe('extensionSettings', () => { version: '1.0.0', settings: [], }; - await maybePromptForSettings(config, mockRequestSetting); + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); - it('should call requestSetting for each setting', async () => { + it('should prompt for all settings if there is no previous config', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', @@ -82,14 +143,25 @@ describe('extensionSettings', () => { { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; - await maybePromptForSettings(config, mockRequestSetting); + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); expect(mockRequestSetting).toHaveBeenCalledTimes(2); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); }); - it('should write the .env file with the correct content', async () => { - const config: ExtensionConfig = { + it('should only prompt for new settings', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ @@ -97,35 +169,151 @@ describe('extensionSettings', () => { { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; - await maybePromptForSettings(config, mockRequestSetting); + const previousSettings = { VAR1: 'previous-VAR1' }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).toHaveBeenCalledTimes(1); + expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); - const expectedContent = 'VAR1=mock-VAR1\nVAR2=mock-VAR2\n'; + const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n'; + expect(actualContent).toBe(expectedContent); + }); + it('should remove settings that are no longer in the config', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const previousSettings = { + VAR1: 'previous-VAR1', + VAR2: 'previous-VAR2', + }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).not.toHaveBeenCalled(); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=previous-VAR1\n'; + expect(actualContent).toBe(expectedContent); + }); + + it('should reprompt if a setting changes sensitivity', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true }, + ], + }; + const previousSettings = { VAR1: 'previous-VAR1' }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).toHaveBeenCalledTimes(1); + expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]); + + // The value should now be in keychain, not the .env file. + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toBe(''); + }); + + it('should not prompt if settings are identical', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const previousSettings = { + VAR1: 'previous-VAR1', + VAR2: 'previous-VAR2', + }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).not.toHaveBeenCalled(); + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n'; expect(actualContent).toBe(expectedContent); }); }); describe('promptForSetting', () => { - // it('should use prompts with type "password" for sensitive settings', async () => { - // const setting: ExtensionSetting = { - // name: 'API Key', - // description: 'Your secret key', - // envVar: 'API_KEY', - // sensitive: true, - // }; - // vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' }); + it('should use prompts with type "password" for sensitive settings', async () => { + const setting: ExtensionSetting = { + name: 'API Key', + description: 'Your secret key', + envVar: 'API_KEY', + sensitive: true, + }; + vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' }); - // const result = await promptForSetting(setting); + const result = await promptForSetting(setting); - // expect(prompts).toHaveBeenCalledWith({ - // type: 'password', - // name: 'value', - // message: 'API Key\nYour secret key', - // }); - // expect(result).toBe('secret-key'); - // }); + expect(prompts).toHaveBeenCalledWith({ + type: 'password', + name: 'value', + message: 'API Key\nYour secret key', + }); + expect(result).toBe('secret-key'); + }); it('should use prompts with type "text" for non-sensitive settings', async () => { const setting: ExtensionSetting = { diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 55eb70b83a..f625ef5ea8 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -12,57 +12,76 @@ import { ExtensionStorage } from './storage.js'; import type { ExtensionConfig } from '../extension.js'; import prompts from 'prompts'; +import { KeychainTokenStorage } from '@google/gemini-cli-core'; export interface ExtensionSetting { name: string; description: string; envVar: string; + // NOTE: If no value is set, this setting will be considered NOT sensitive. + sensitive?: boolean; } export async function maybePromptForSettings( extensionConfig: ExtensionConfig, + extensionId: string, requestSetting: (setting: ExtensionSetting) => Promise, previousExtensionConfig?: ExtensionConfig, previousSettings?: Record, ): Promise { const { name: extensionName, settings } = extensionConfig; + if ( + (!settings || settings.length === 0) && + (!previousExtensionConfig?.settings || + previousExtensionConfig.settings.length === 0) + ) { + return; + } const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); + const keychain = new KeychainTokenStorage(extensionId); if (!settings || settings.length === 0) { - // No settings for this extension. Clear any existing .env file. - if (fsSync.existsSync(envFilePath)) { - await fs.writeFile(envFilePath, ''); - } + await clearSettings(envFilePath, keychain); return; } - let settingsToPrompt = settings; - if (previousExtensionConfig) { - const oldSettings = new Set( - previousExtensionConfig.settings?.map((s) => s.name) || [], - ); - settingsToPrompt = settingsToPrompt.filter((s) => !oldSettings.has(s.name)); - } + const settingsChanges = getSettingsChanges( + settings, + previousExtensionConfig?.settings ?? [], + ); const allSettings: Record = { ...(previousSettings ?? {}) }; - if (settingsToPrompt && settingsToPrompt.length > 0) { - for (const setting of settingsToPrompt) { - const answer = await requestSetting(setting); - allSettings[setting.envVar] = answer; - } + for (const removedEnvSetting of settingsChanges.removeEnv) { + delete allSettings[removedEnvSetting.envVar]; } - const validEnvVars = new Set(settings.map((s) => s.envVar)); - const finalSettings: Record = {}; - for (const [key, value] of Object.entries(allSettings)) { - if (validEnvVars.has(key)) { - finalSettings[key] = value; + for (const removedSensitiveSetting of settingsChanges.removeSensitive) { + await keychain.deleteSecret(removedSensitiveSetting.envVar); + } + + for (const setting of settingsChanges.promptForSensitive.concat( + settingsChanges.promptForEnv, + )) { + const answer = await requestSetting(setting); + allSettings[setting.envVar] = answer; + } + + const nonSensitiveSettings: Record = {}; + for (const setting of settings) { + const value = allSettings[setting.envVar]; + if (value === undefined) { + continue; + } + if (setting.sensitive) { + await keychain.setSecret(setting.envVar, value); + } else { + nonSensitiveSettings[setting.envVar] = value; } } let envContent = ''; - for (const [key, value] of Object.entries(finalSettings)) { + for (const [key, value] of Object.entries(nonSensitiveSettings)) { envContent += `${key}=${value}\n`; } @@ -73,17 +92,22 @@ export async function promptForSetting( setting: ExtensionSetting, ): Promise { const response = await prompts({ - // type: setting.sensitive ? 'password' : 'text', - type: 'text', + type: setting.sensitive ? 'password' : 'text', name: 'value', message: `${setting.name}\n${setting.description}`, }); return response.value; } -export function getEnvContents( - extensionStorage: ExtensionStorage, -): Record { +export async function getEnvContents( + extensionConfig: ExtensionConfig, + extensionId: string, +): Promise> { + if (!extensionConfig.settings || extensionConfig.settings.length === 0) { + return Promise.resolve({}); + } + const extensionStorage = new ExtensionStorage(extensionConfig.name); + const keychain = new KeychainTokenStorage(extensionId); let customEnv: Record = {}; if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { const envFile = fsSync.readFileSync( @@ -92,5 +116,67 @@ export function getEnvContents( ); customEnv = dotenv.parse(envFile); } + + if (extensionConfig.settings) { + for (const setting of extensionConfig.settings) { + if (setting.sensitive) { + const secret = await keychain.getSecret(setting.envVar); + if (secret) { + customEnv[setting.envVar] = secret; + } + } + } + } return customEnv; } + +interface settingsChanges { + promptForSensitive: ExtensionSetting[]; + removeSensitive: ExtensionSetting[]; + promptForEnv: ExtensionSetting[]; + removeEnv: ExtensionSetting[]; +} +function getSettingsChanges( + settings: ExtensionSetting[], + oldSettings: ExtensionSetting[], +): settingsChanges { + const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) => + a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false); + + const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false); + const sensitiveNew = settings.filter((s) => s.sensitive ?? false); + const envOld = oldSettings.filter((s) => !(s.sensitive ?? false)); + const envNew = settings.filter((s) => !(s.sensitive ?? false)); + + return { + promptForSensitive: sensitiveNew.filter( + (s) => !sensitiveOld.some((old) => isSameSetting(s, old)), + ), + removeSensitive: sensitiveOld.filter( + (s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)), + ), + promptForEnv: envNew.filter( + (s) => !envOld.some((old) => isSameSetting(s, old)), + ), + removeEnv: envOld.filter( + (s) => !envNew.some((neu) => isSameSetting(s, neu)), + ), + }; +} + +async function clearSettings( + envFilePath: string, + keychain: KeychainTokenStorage, +) { + if (fsSync.existsSync(envFilePath)) { + await fs.writeFile(envFilePath, ''); + } + if (!keychain.isAvailable()) { + return; + } + const secrets = await keychain.listSecrets(); + for (const secret of secrets) { + await keychain.deleteSecret(secret); + } + return; +} diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 57eaa3e32e..06a43cb93e 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -170,7 +170,7 @@ describe('git extension helpers', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 5e5e5cde7d..f2b1973064 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -157,14 +157,16 @@ export async function checkForExtensionUpdate( ): Promise { const installMetadata = extension.installMetadata; if (installMetadata?.type === 'local') { - const newExtension = extensionManager.loadExtension(installMetadata.source); - if (!newExtension) { + const latestConfig = extensionManager.loadExtensionConfig( + installMetadata.source, + ); + if (!latestConfig) { debugLogger.error( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, ); return ExtensionUpdateState.ERROR; } - if (newExtension.version !== extension.version) { + if (latestConfig.version !== extension.version) { return ExtensionUpdateState.UPDATE_AVAILABLE; } return ExtensionUpdateState.UP_TO_DATE; diff --git a/packages/cli/src/config/extensions/github_fetch.test.ts b/packages/cli/src/config/extensions/github_fetch.test.ts new file mode 100644 index 0000000000..fe6edbedb2 --- /dev/null +++ b/packages/cli/src/config/extensions/github_fetch.test.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import * as https from 'node:https'; +import { EventEmitter } from 'node:events'; +import { fetchJson, getGitHubToken } from './github_fetch.js'; +import type { ClientRequest, IncomingMessage } from 'node:http'; + +vi.mock('node:https'); + +describe('getGitHubToken', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should return the token if GITHUB_TOKEN is set', () => { + process.env['GITHUB_TOKEN'] = 'test-token'; + expect(getGitHubToken()).toBe('test-token'); + }); + + it('should return undefined if GITHUB_TOKEN is not set', () => { + delete process.env['GITHUB_TOKEN']; + expect(getGitHubToken()).toBeUndefined(); + }); +}); + +describe('fetchJson', () => { + const getMock = vi.mocked(https.get); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should fetch and parse JSON successfully', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo":')); + res.emit('data', Buffer.from('"bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({ + foo: 'bar', + }); + }); + + it('should handle redirects (301 and 302)', async () => { + // Test 302 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 302; + res.headers = { location: 'https://example.com/final' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"success": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({ + success: true, + }); + + // Test 301 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 301; + res.headers = { location: 'https://example.com/final-permanent' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final-permanent'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"permanent": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect( + fetchJson('https://example.com/redirect-perm'), + ).resolves.toEqual({ permanent: true }); + }); + + it('should reject on non-200/30x status code', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 404; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Request failed with status code 404', + ); + }); + + it('should reject on request error', async () => { + const error = new Error('Network error'); + getMock.mockImplementationOnce(() => { + const req = new EventEmitter() as ClientRequest; + req.emit('error', error); + return req; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Network error', + ); + }); + + describe('with GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + process.env['GITHUB_TOKEN'] = 'my-secret-token'; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should include Authorization header if token is present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + Authorization: 'token my-secret-token', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); + + describe('without GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + delete process.env['GITHUB_TOKEN']; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } + }); + + it('should not include Authorization header if token is not present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts index 3940275699..a4f9d29b70 100644 --- a/packages/cli/src/config/extensions/github_fetch.ts +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -10,7 +10,10 @@ export function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } -export async function fetchJson(url: string): Promise { +export async function fetchJson( + url: string, + redirectCount: number = 0, +): Promise { const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'gemini-cli', }; @@ -21,6 +24,18 @@ export async function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { https .get(url, { headers }, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + if (redirectCount >= 10) { + return reject(new Error('Too many redirects')); + } + if (!res.headers.location) { + return reject(new Error('No location header in redirect response')); + } + fetchJson(res.headers.location!, redirectCount++) + .then(resolve) + .catch(reject); + return; + } if (res.statusCode !== 200) { return reject( new Error(`Request failed with status code ${res.statusCode}`), diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 176e7ad3fa..c3a1fb64e4 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { checkForAllExtensionUpdates, updateExtension } from './update.js'; -import { GEMINI_DIR } from '@google/gemini-cli-core'; +import { GEMINI_DIR, KeychainTokenStorage } from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from '../trustedFolders.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { createExtension } from '../../test-utils/createExtension.js'; @@ -48,13 +48,9 @@ vi.mock('os', async (importOriginal) => { }; }); -vi.mock('../trustedFolders.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isWorkspaceTrusted: vi.fn(), - }; -}); +vi.mock('../trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); @@ -68,9 +64,24 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { logExtensionUninstall: mockLogExtensionUninstall, ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), + KeychainTokenStorage: vi.fn().mockImplementation(() => ({ + getSecret: vi.fn(), + setSecret: vi.fn(), + deleteSecret: vi.fn(), + listSecrets: vi.fn(), + isAvailable: vi.fn().mockResolvedValue(true), + })), }; }); +interface MockKeychainStorage { + getSecret: ReturnType; + setSecret: ReturnType; + deleteSecret: ReturnType; + listSecrets: ReturnType; + isAvailable: ReturnType; +} + describe('update tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; @@ -80,8 +91,32 @@ describe('update tests', () => { let mockPromptForSettings: MockedFunction< (setting: ExtensionSetting) => Promise >; + let mockKeychainStorage: MockKeychainStorage; + let keychainData: Record; beforeEach(() => { + vi.clearAllMocks(); + keychainData = {}; + mockKeychainStorage = { + getSecret: vi + .fn() + .mockImplementation(async (key: string) => keychainData[key] || null), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + }; + ( + KeychainTokenStorage as unknown as ReturnType + ).mockImplementation(() => mockKeychainStorage); tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); @@ -107,13 +142,14 @@ describe('update tests', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); }); describe('updateExtension', () => { @@ -143,9 +179,10 @@ describe('update tests', () => { ); }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - const extension = extensionManager.loadExtension(targetExtDir)!; + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName)!; const updateInfo = await updateExtension( - extension, + extension!, extensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, () => {}, @@ -168,7 +205,7 @@ describe('update tests', () => { it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { const extensionName = 'test-extension'; - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: extensionName, version: '1.0.0', @@ -190,9 +227,11 @@ describe('update tests', () => { mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); const dispatch = vi.fn(); - const extension = extensionManager.loadExtension(extensionDir)!; + + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName)!; await updateExtension( - extension, + extension!, extensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, dispatch, @@ -216,7 +255,7 @@ describe('update tests', () => { it('should call setExtensionUpdateState with ERROR on failure', async () => { const extensionName = 'test-extension'; - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: extensionName, version: '1.0.0', @@ -230,10 +269,11 @@ describe('update tests', () => { mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); const dispatch = vi.fn(); - const extension = extensionManager.loadExtension(extensionDir)!; + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === extensionName)!; await expect( updateExtension( - extension, + extension!, extensionManager, ExtensionUpdateState.UPDATE_AVAILABLE, dispatch, @@ -259,7 +299,7 @@ describe('update tests', () => { describe('checkForAllExtensionUpdates', () => { it('should return UpdateAvailable for a git extension with updates', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', @@ -268,7 +308,6 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -278,7 +317,7 @@ describe('update tests', () => { const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + await extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -292,7 +331,7 @@ describe('update tests', () => { }); it('should return UpToDate for a git extension with no updates', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', @@ -301,7 +340,6 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -311,7 +349,7 @@ describe('update tests', () => { const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + await extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -332,16 +370,15 @@ describe('update tests', () => { version: '1.0.0', }); - const installedExtensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'local-extension', version: '1.0.0', installMetadata: { source: sourceExtensionDir, type: 'local' }, }); - const extension = extensionManager.loadExtension(installedExtensionDir)!; const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + await extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -358,20 +395,19 @@ describe('update tests', () => { const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); const sourceExtensionDir = createExtension({ extensionsDir: localExtensionSourcePath, - name: 'my-local-ext', + name: 'local-extension', version: '1.1.0', }); - const installedExtensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'local-extension', version: '1.0.0', installMetadata: { source: sourceExtensionDir, type: 'local' }, }); - const extension = extensionManager.loadExtension(installedExtensionDir)!; const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + await extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -385,7 +421,7 @@ describe('update tests', () => { }); it('should return Error when git check fails', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'error-extension', version: '1.0.0', @@ -394,13 +430,12 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockRejectedValue(new Error('Git error')); const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + await extensionManager.loadExtensions(), extensionManager, dispatch, ); diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 141ace88d8..7bfa253651 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -58,23 +58,23 @@ export async function updateExtension( const tempDir = await ExtensionStorage.createTmpDir(); try { - const previousExtensionConfig = await extensionManager.loadExtensionConfig( + const previousExtensionConfig = extensionManager.loadExtensionConfig( extension.path, ); - await extensionManager.installOrUpdateExtension( - installMetadata, - previousExtensionConfig, - ); - const updatedExtensionStorage = new ExtensionStorage(extension.name); - const updatedExtension = extensionManager.loadExtension( - updatedExtensionStorage.getExtensionDir(), - ); - if (!updatedExtension) { + let updatedExtension: GeminiCLIExtension; + try { + updatedExtension = await extensionManager.installOrUpdateExtension( + installMetadata, + previousExtensionConfig, + ); + } catch (e) { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); - throw new Error('Updated extension not found after installation.'); + throw new Error( + `Updated extension not found after installation, got error:\n${e}`, + ); } const updatedVersion = updatedExtension.version; dispatchExtensionStateUpdate({ diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 0c5d54146c..14e56b33a3 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -156,7 +156,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], // App level bindings - [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], + [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], diff --git a/packages/cli/src/config/policies/read-only.toml b/packages/cli/src/config/policies/read-only.toml new file mode 100644 index 0000000000..0c36faf003 --- /dev/null +++ b/packages/cli/src/config/policies/read-only.toml @@ -0,0 +1,56 @@ +# Priority system for policy rules: +# - Higher priority numbers win over lower priority numbers +# - When multiple rules match, the highest priority rule is applied +# - Rules are evaluated in order of priority (highest first) +# +# Priority bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +toolName = "glob" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "search_file_content" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "list_directory" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "read_file" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "read_many_files" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "google_web_search" +decision = "allow" +priority = 50 diff --git a/packages/cli/src/config/policies/write.toml b/packages/cli/src/config/policies/write.toml new file mode 100644 index 0000000000..8e4c1ae70e --- /dev/null +++ b/packages/cli/src/config/policies/write.toml @@ -0,0 +1,63 @@ +# Priority system for policy rules: +# - Higher priority numbers win over lower priority numbers +# - When multiple rules match, the highest priority rule is applied +# - Rules are evaluated in order of priority (highest first) +# +# Priority bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +toolName = "replace" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "replace" +decision = "allow" +priority = 15 +modes = ["autoEdit"] + +[[rule]] +toolName = "save_memory" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "run_shell_command" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "write_file" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "write_file" +decision = "allow" +priority = 15 +modes = ["autoEdit"] + +[[rule]] +toolName = "web_fetch" +decision = "ask_user" +priority = 10 diff --git a/packages/cli/src/config/policies/yolo.toml b/packages/cli/src/config/policies/yolo.toml new file mode 100644 index 0000000000..0c5f9e9221 --- /dev/null +++ b/packages/cli/src/config/policies/yolo.toml @@ -0,0 +1,31 @@ +# Priority system for policy rules: +# - Higher priority numbers win over lower priority numbers +# - When multiple rules match, the highest priority rule is applied +# - Rules are evaluated in order of priority (highest first) +# +# Priority bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +decision = "allow" +priority = 999 +modes = ["yolo"] diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 3b19121d5f..9b8457bc33 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -15,7 +15,7 @@ import type { Settings } from './settings.js'; describe('Policy Engine Integration Tests', () => { describe('Policy configuration produces valid PolicyEngine config', () => { - it('should create a working PolicyEngine from basic settings', () => { + it('should create a working PolicyEngine from basic settings', async () => { const settings: Settings = { tools: { allowed: ['run_shell_command'], @@ -23,7 +23,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Allowed tool should be allowed @@ -43,7 +46,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle MCP server wildcard patterns correctly', () => { + it('should handle MCP server wildcard patterns correctly', async () => { const settings: Settings = { mcp: { allowed: ['allowed-server'], @@ -58,7 +61,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Tools from allowed server should be allowed @@ -91,7 +97,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should correctly prioritize specific tool rules over MCP server wildcards', () => { + it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => { const settings: Settings = { mcp: { allowed: ['my-server'], @@ -101,19 +107,23 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); - // Server is allowed, but specific tool is excluded + // MCP server allowed (priority 2.1) provides general allow for server expect(engine.check({ name: 'my-server__safe-tool' })).toBe( PolicyDecision.ALLOW, ); + // But specific tool exclude (priority 2.4) wins over server allow expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe( PolicyDecision.DENY, ); }); - it('should handle complex mixed configurations', () => { + it('should handle complex mixed configurations', async () => { const settings: Settings = { tools: { autoAccept: true, // Allows read-only tools @@ -133,7 +143,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Read-only tools should be allowed (autoAccept) @@ -171,14 +184,17 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle YOLO mode correctly', () => { + it('should handle YOLO mode correctly', async () => { const settings: Settings = { tools: { exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.YOLO, + ); const engine = new PolicyEngine(config); // Most tools should be allowed in YOLO mode @@ -194,25 +210,26 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle AUTO_EDIT mode correctly', () => { + it('should handle AUTO_EDIT mode correctly', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.AUTO_EDIT, + ); const engine = new PolicyEngine(config); - // Edit tool should be allowed (EditTool.Name = 'replace') + // Edit tools should be allowed in AUTO_EDIT mode expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ALLOW); + expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.ALLOW); // Other tools should follow normal rules expect(engine.check({ name: 'run_shell_command' })).toBe( PolicyDecision.ASK_USER, ); - expect(engine.check({ name: 'write_file' })).toBe( - PolicyDecision.ASK_USER, - ); }); - it('should verify priority ordering works correctly in practice', () => { + it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { tools: { autoAccept: true, // Priority 50 @@ -232,7 +249,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Test that priorities are applied correctly @@ -240,28 +260,29 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(200); + expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(195); + expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(100); + expect(specificToolRule?.priority).toBe(2.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(90); + expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(85); + expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); - expect(readOnlyToolRule?.priority).toBe(50); + // Priority 50 in default tier → 1.05 + expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5); // Verify the engine applies these priorities correctly expect(engine.check({ name: 'blocked-tool' })).toBe(PolicyDecision.DENY); @@ -280,7 +301,7 @@ describe('Policy Engine Integration Tests', () => { expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.ALLOW); }); - it('should handle edge case: MCP server with both trust and exclusion', () => { + it('should handle edge case: MCP server with both trust and exclusion', async () => { const settings: Settings = { mcpServers: { 'conflicted-server': { @@ -294,7 +315,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Exclusion (195) should win over trust (90) @@ -303,7 +327,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle edge case: specific tool allowed but server excluded', () => { + it('should handle edge case: specific tool allowed but server excluded', async () => { const settings: Settings = { mcp: { excluded: ['my-server'], // Priority 195 - DENY @@ -313,7 +337,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Server exclusion (195) wins over specific tool allow (100) @@ -326,10 +353,13 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should verify non-interactive mode transformation', () => { + it('should verify non-interactive mode transformation', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Enable non-interactive mode const engineConfig = { ...config, nonInteractive: true }; const engine = new PolicyEngine(engineConfig); @@ -341,10 +371,13 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle empty settings gracefully', () => { + it('should handle empty settings gracefully', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Should have default rules for write tools @@ -357,7 +390,7 @@ describe('Policy Engine Integration Tests', () => { expect(engine.check({ name: 'unknown' })).toBe(PolicyDecision.ASK_USER); }); - it('should verify rules are created with correct priorities', () => { + it('should verify rules are created with correct priorities', async () => { const settings: Settings = { tools: { autoAccept: true, @@ -370,24 +403,28 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const rules = config.rules || []; // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(200); // Excluded tools + expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(195); // Excluded servers + expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(100); // Allowed tools + expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(85); // Allowed servers + expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); - expect(globRule?.priority).toBe(50); // Auto-accept read-only + // Priority 50 in default tier → 1.05 + expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only // The PolicyEngine will sort these by priority when it's created const engine = new PolicyEngine(config); diff --git a/packages/cli/src/config/policy-toml-loader.test.ts b/packages/cli/src/config/policy-toml-loader.test.ts new file mode 100644 index 0000000000..e05996b16c --- /dev/null +++ b/packages/cli/src/config/policy-toml-loader.test.ts @@ -0,0 +1,982 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApprovalMode, PolicyDecision } from '@google/gemini-cli-core'; +import type { Dirent } from 'node:fs'; +import nodePath from 'node:path'; + +describe('policy-toml-loader', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('node:fs/promises'); + }); + + describe('loadPoliciesFromToml', () => { + it('should load and parse a simple policy file', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'test.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'test.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toEqual({ + toolName: 'glob', + decision: PolicyDecision.ALLOW, + priority: 1.1, // tier 1 + 100/1000 + }); + expect(result.errors).toHaveLength(0); + }); + + it('should expand commandPrefix array to multiple rules', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git log"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(2); + expect(result.rules[0].toolName).toBe('run_shell_command'); + expect(result.rules[1].toolName).toBe('run_shell_command'); + expect( + result.rules[0].argsPattern?.test('{"command":"git status"}'), + ).toBe(true); + expect(result.rules[1].argsPattern?.test('{"command":"git log"}')).toBe( + true, + ); + expect(result.errors).toHaveLength(0); + }); + + it('should transform commandRegex to argsPattern', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|log).*" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + expect( + result.rules[0].argsPattern?.test('{"command":"git status"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git log --all"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git branch"}'), + ).toBe(false); + expect(result.errors).toHaveLength(0); + }); + + it('should expand toolName array', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'tools.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'tools.toml')) + ) { + return ` +[[rule]] +toolName = ["glob", "grep", "read"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(3); + expect(result.rules.map((r) => r.toolName)).toEqual([ + 'glob', + 'grep', + 'read', + ]); + expect(result.errors).toHaveLength(0); + }); + + it('should transform mcpName to composite toolName', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'mcp.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'mcp.toml')) + ) { + return ` +[[rule]] +mcpName = "google-workspace" +toolName = ["calendar.list", "calendar.get"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(2); + expect(result.rules[0].toolName).toBe('google-workspace__calendar.list'); + expect(result.rules[1].toolName).toBe('google-workspace__calendar.get'); + expect(result.errors).toHaveLength(0); + }); + + it('should filter rules by mode', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'modes.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'modes.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +modes = ["default", "yolo"] + +[[rule]] +toolName = "grep" +decision = "allow" +priority = 100 +modes = ["yolo"] +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + // Only the first rule should be included (modes includes "default") + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('glob'); + expect(result.errors).toHaveLength(0); + }); + + it('should handle TOML parse errors', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule] +toolName = "glob" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('toml_parse'); + expect(result.errors[0].fileName).toBe('invalid.toml'); + }); + + it('should handle schema validation errors', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('decision'); + }); + + it('should reject commandPrefix without run_shell_command', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +commandPrefix = "git status" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('rule_validation'); + expect(result.errors[0].details).toContain('run_shell_command'); + }); + + it('should reject commandPrefix + argsPattern combination', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git status" +argsPattern = "test" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('rule_validation'); + expect(result.errors[0].details).toContain('mutually exclusive'); + }); + + it('should handle invalid regex patterns', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|branch" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('regex_compilation'); + expect(result.errors[0].details).toContain('git (status|branch'); + }); + + it('should escape regex special characters in commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git log *.txt" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + // Should match literal asterisk, not wildcard + expect( + result.rules[0].argsPattern?.test('{"command":"git log *.txt"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git log a.txt"}'), + ).toBe(false); + expect(result.errors).toHaveLength(0); + }); + + it('should handle non-existent directory gracefully', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn(async (_path: string): Promise => { + const error: NodeJS.ErrnoException = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/non-existent'], + getPolicyTier, + ); + + // Should not error for missing directories + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('should reject priority >= 1000 with helpful error message', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 1000 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('priority'); + expect(result.errors[0].details).toContain('tier overflow'); + expect(result.errors[0].details).toContain( + 'Priorities >= 1000 would jump to the next tier', + ); + expect(result.errors[0].details).toContain('<= 999'); + }); + + it('should reject negative priority with helpful error message', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = -1 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('priority'); + expect(result.errors[0].details).toContain('>= 0'); + expect(result.errors[0].details).toContain('must be >= 0'); + }); + }); +}); diff --git a/packages/cli/src/config/policy-toml-loader.ts b/packages/cli/src/config/policy-toml-loader.ts new file mode 100644 index 0000000000..fb5a7d1253 --- /dev/null +++ b/packages/cli/src/config/policy-toml-loader.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type PolicyRule, + PolicyDecision, + type ApprovalMode, +} from '@google/gemini-cli-core'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import toml from '@iarna/toml'; +import { z, type ZodError } from 'zod'; + +/** + * Schema for a single policy rule in the TOML file (before transformation). + */ +const PolicyRuleSchema = z.object({ + toolName: z.union([z.string(), z.array(z.string())]).optional(), + mcpName: z.string().optional(), + argsPattern: z.string().optional(), + commandPrefix: z.union([z.string(), z.array(z.string())]).optional(), + commandRegex: z.string().optional(), + decision: z.nativeEnum(PolicyDecision), + // Priority must be in range [0, 999] to prevent tier overflow. + // With tier transformation (tier + priority/1000), this ensures: + // - Tier 1 (default): range [1.000, 1.999] + // - Tier 2 (user): range [2.000, 2.999] + // - Tier 3 (admin): range [3.000, 3.999] + priority: z + .number({ + required_error: 'priority is required', + invalid_type_error: 'priority must be a number', + }) + .int({ message: 'priority must be an integer' }) + .min(0, { message: 'priority must be >= 0' }) + .max(999, { + message: + 'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.', + }), + modes: z.array(z.string()).optional(), +}); + +/** + * Schema for the entire policy TOML file. + */ +const PolicyFileSchema = z.object({ + rule: z.array(PolicyRuleSchema), +}); + +/** + * Type for a raw policy rule from TOML (before transformation). + */ +type PolicyRuleToml = z.infer; + +/** + * Types of errors that can occur while loading policy files. + */ +export type PolicyFileErrorType = + | 'file_read' + | 'toml_parse' + | 'schema_validation' + | 'rule_validation' + | 'regex_compilation'; + +/** + * Detailed error information for policy file loading failures. + */ +export interface PolicyFileError { + filePath: string; + fileName: string; + tier: 'default' | 'user' | 'admin'; + ruleIndex?: number; + errorType: PolicyFileErrorType; + message: string; + details?: string; + suggestion?: string; +} + +/** + * Result of loading policies from TOML files. + */ +export interface PolicyLoadResult { + rules: PolicyRule[]; + errors: PolicyFileError[]; +} + +/** + * Escapes special regex characters in a string for use in a regex pattern. + * This is used for commandPrefix to ensure literal string matching. + * + * @param str The string to escape + * @returns The escaped string safe for use in a regex + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Converts a tier number to a human-readable tier name. + */ +function getTierName(tier: number): 'default' | 'user' | 'admin' { + if (tier === 1) return 'default'; + if (tier === 2) return 'user'; + if (tier === 3) return 'admin'; + return 'default'; +} + +/** + * Formats a Zod validation error into a readable error message. + */ +function formatSchemaError(error: ZodError, ruleIndex: number): string { + const issues = error.issues + .map((issue) => { + const path = issue.path.join('.'); + return ` - Field "${path}": ${issue.message}`; + }) + .join('\n'); + return `Invalid policy rule (rule #${ruleIndex + 1}):\n${issues}`; +} + +/** + * Validates shell command convenience syntax rules. + * Returns an error message if invalid, or null if valid. + */ +function validateShellCommandSyntax( + rule: PolicyRuleToml, + ruleIndex: number, +): string | null { + const hasCommandPrefix = rule.commandPrefix !== undefined; + const hasCommandRegex = rule.commandRegex !== undefined; + const hasArgsPattern = rule.argsPattern !== undefined; + + if (hasCommandPrefix || hasCommandRegex) { + // Must have exactly toolName = "run_shell_command" + if (rule.toolName !== 'run_shell_command' || Array.isArray(rule.toolName)) { + return ( + `Rule #${ruleIndex + 1}: commandPrefix and commandRegex can only be used with toolName = "run_shell_command"\n` + + ` Found: toolName = ${JSON.stringify(rule.toolName)}\n` + + ` Fix: Set toolName = "run_shell_command" (not an array)` + ); + } + + // Can't combine with argsPattern + if (hasArgsPattern) { + return ( + `Rule #${ruleIndex + 1}: cannot use both commandPrefix/commandRegex and argsPattern\n` + + ` These fields are mutually exclusive\n` + + ` Fix: Use either commandPrefix/commandRegex OR argsPattern, not both` + ); + } + + // Can't use both commandPrefix and commandRegex + if (hasCommandPrefix && hasCommandRegex) { + return ( + `Rule #${ruleIndex + 1}: cannot use both commandPrefix and commandRegex\n` + + ` These fields are mutually exclusive\n` + + ` Fix: Use either commandPrefix OR commandRegex, not both` + ); + } + } + + return null; +} + +/** + * Transforms a priority number based on the policy tier. + * Formula: tier + priority/1000 + * + * @param priority The priority value from the TOML file + * @param tier The tier (1=default, 2=user, 3=admin) + * @returns The transformed priority + */ +function transformPriority(priority: number, tier: number): number { + return tier + priority / 1000; +} + +/** + * Loads and parses policies from TOML files in the specified directories. + * + * This function: + * 1. Scans directories for .toml files + * 2. Parses and validates each file + * 3. Transforms rules (commandPrefix, arrays, mcpName, priorities) + * 4. Filters rules by approval mode + * 5. Collects detailed error information for any failures + * + * @param approvalMode The current approval mode (for filtering rules by mode) + * @param policyDirs Array of directory paths to scan for policy files + * @param getPolicyTier Function to determine tier (1-3) for a directory + * @returns Object containing successfully parsed rules and any errors encountered + */ +export async function loadPoliciesFromToml( + approvalMode: ApprovalMode, + policyDirs: string[], + getPolicyTier: (dir: string) => number, +): Promise { + const rules: PolicyRule[] = []; + const errors: PolicyFileError[] = []; + + for (const dir of policyDirs) { + const tier = getPolicyTier(dir); + const tierName = getTierName(tier); + + // Scan directory for all .toml files + let filesToLoad: string[]; + try { + const dirEntries = await fs.readdir(dir, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } catch (e) { + const error = e as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + // Directory doesn't exist, skip it (not an error) + continue; + } + errors.push({ + filePath: dir, + fileName: path.basename(dir), + tier: tierName, + errorType: 'file_read', + message: `Failed to read policy directory`, + details: error.message, + }); + continue; + } + + for (const file of filesToLoad) { + const filePath = path.join(dir, file); + + try { + // Read file + const fileContent = await fs.readFile(filePath, 'utf-8'); + + // Parse TOML + let parsed: unknown; + try { + parsed = toml.parse(fileContent); + } catch (e) { + const error = e as Error; + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'toml_parse', + message: 'TOML parsing failed', + details: error.message, + suggestion: + 'Check for syntax errors like missing quotes, brackets, or commas', + }); + continue; + } + + // Validate schema + const validationResult = PolicyFileSchema.safeParse(parsed); + if (!validationResult.success) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'schema_validation', + message: 'Schema validation failed', + details: formatSchemaError(validationResult.error, 0), + suggestion: + 'Ensure all required fields (decision, priority) are present with correct types', + }); + continue; + } + + // Validate shell command convenience syntax + for (let i = 0; i < validationResult.data.rule.length; i++) { + const rule = validationResult.data.rule[i]; + const validationError = validateShellCommandSyntax(rule, i); + if (validationError) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + ruleIndex: i, + errorType: 'rule_validation', + message: 'Invalid shell command syntax', + details: validationError, + }); + // Continue to next rule, don't skip the entire file + } + } + + // Transform rules + const parsedRules: PolicyRule[] = validationResult.data.rule + .filter((rule) => { + // Filter by mode + if (!rule.modes || rule.modes.length === 0) { + return true; + } + return rule.modes.includes(approvalMode); + }) + .flatMap((rule) => { + // Transform commandPrefix/commandRegex to argsPattern + let effectiveArgsPattern = rule.argsPattern; + const commandPrefixes: string[] = []; + + if (rule.commandPrefix) { + const prefixes = Array.isArray(rule.commandPrefix) + ? rule.commandPrefix + : [rule.commandPrefix]; + commandPrefixes.push(...prefixes); + } else if (rule.commandRegex) { + effectiveArgsPattern = `"command":"${rule.commandRegex}`; + } + + // Expand command prefixes to multiple patterns + const argsPatterns: Array = + commandPrefixes.length > 0 + ? commandPrefixes.map( + (prefix) => `"command":"${escapeRegex(prefix)}`, + ) + : [effectiveArgsPattern]; + + // For each argsPattern, expand toolName arrays + return argsPatterns.flatMap((argsPattern) => { + const toolNames: Array = rule.toolName + ? Array.isArray(rule.toolName) + ? rule.toolName + : [rule.toolName] + : [undefined]; + + // Create a policy rule for each tool name + return toolNames.map((toolName) => { + // Transform mcpName field to composite toolName format + let effectiveToolName: string | undefined; + if (rule.mcpName && toolName) { + effectiveToolName = `${rule.mcpName}__${toolName}`; + } else if (rule.mcpName) { + effectiveToolName = `${rule.mcpName}__*`; + } else { + effectiveToolName = toolName; + } + + const policyRule: PolicyRule = { + toolName: effectiveToolName, + decision: rule.decision, + priority: transformPriority(rule.priority, tier), + }; + + // Compile regex pattern + if (argsPattern) { + try { + policyRule.argsPattern = new RegExp(argsPattern); + } catch (e) { + const error = e as Error; + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'regex_compilation', + message: 'Invalid regex pattern', + details: `Pattern: ${argsPattern}\nError: ${error.message}`, + suggestion: + 'Check regex syntax for errors like unmatched brackets or invalid escape sequences', + }); + // Skip this rule if regex compilation fails + return null; + } + } + + return policyRule; + }); + }); + }) + .filter((rule): rule is PolicyRule => rule !== null); + + rules.push(...parsedRules); + } catch (e) { + const error = e as NodeJS.ErrnoException; + // Catch-all for unexpected errors + if (error.code !== 'ENOENT') { + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'file_read', + message: 'Failed to read policy file', + details: error.message, + }); + } + } + } + } + + return { rules, errors }; +} diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index f6c442a9e6..8589165750 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import { createPolicyEngineConfig } from './policy.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import nodePath from 'node:path'; + import type { Settings } from './settings.js'; import { ApprovalMode, @@ -13,129 +14,191 @@ import { WEB_FETCH_TOOL_NAME, } from '@google/gemini-cli-core'; +afterEach(() => { + vi.clearAllMocks(); +}); + describe('createPolicyEngineConfig', () => { - it('should return ASK_USER for write tools and ALLOW for read-only tools by default', () => { + it('should return ASK_USER for write tools and ALLOW for read-only tools by default', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + // Return empty array for user policies + return [] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER); // The order of the rules is not guaranteed, so we sort them by tool name. config.rules?.sort((a, b) => (a.toolName ?? '').localeCompare(b.toolName ?? ''), ); + // Default policies are transformed to tier 1: 1 + priority/1000 expect(config.rules).toEqual([ { toolName: 'glob', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, // 1 + 50/1000 }, { toolName: 'google_web_search', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'list_directory', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'read_file', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'read_many_files', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'replace', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, // 1 + 10/1000 }, { toolName: 'run_shell_command', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'save_memory', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'search_file_content', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'web_fetch', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'write_file', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, ]); + + vi.doUnmock('node:fs/promises'); }); - it('should allow tools in tools.allowed', () => { + it('should allow tools in tools.allowed', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { allowed: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const rule = config.rules?.find( (r) => r.toolName === 'run_shell_command' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(100); + expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow }); - it('should deny tools in tools.exclude', () => { + it('should deny tools in tools.exclude', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { exclude: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const rule = config.rules?.find( (r) => r.toolName === 'run_shell_command' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(200); + expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude }); - it('should allow tools from allowed MCP servers', () => { + it('should allow tools from allowed MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['my-server'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const rule = config.rules?.find( (r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(85); + expect(rule?.priority).toBe(2.1); // MCP allowed server }); - it('should deny tools from excluded MCP servers', () => { + it('should deny tools from excluded MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { excluded: ['my-server'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const rule = config.rules?.find( (r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(195); + expect(rule?.priority).toBe(2.9); // MCP excluded server }); - it('should allow tools from trusted MCP servers', () => { + it('should allow tools from trusted MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'trusted-server': { @@ -150,7 +213,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const trustedRule = config.rules?.find( (r) => @@ -158,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(90); + expect(trustedRule?.priority).toBe(2.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -169,7 +235,8 @@ describe('createPolicyEngineConfig', () => { expect(untrustedRule).toBeUndefined(); }); - it('should handle multiple MCP server configurations together', () => { + it('should handle multiple MCP server configurations together', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['allowed-server'], @@ -183,7 +250,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Check allowed server const allowedRule = config.rules?.find( @@ -192,7 +262,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(85); + expect(allowedRule?.priority).toBe(2.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -201,7 +271,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(90); + expect(trustedRule?.priority).toBe(2.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -210,33 +280,45 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(195); + expect(excludedRule?.priority).toBe(2.9); // MCP excluded server }); - it('should allow all tools in YOLO mode', () => { + it('should allow all tools in YOLO mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO); const rule = config.rules?.find( - (r) => r.decision === PolicyDecision.ALLOW && r.priority === 0, + (r) => r.decision === PolicyDecision.ALLOW && !r.toolName, ); expect(rule).toBeDefined(); + // Priority 999 in default tier → 1.999 + expect(rule?.priority).toBeCloseTo(1.999, 5); }); - it('should allow edit tool in AUTO_EDIT mode', () => { + it('should allow edit tool in AUTO_EDIT mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.AUTO_EDIT, + ); const rule = config.rules?.find( (r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(15); + // Priority 15 in default tier → 1.015 + expect(rule?.priority).toBeCloseTo(1.015, 5); }); - it('should prioritize exclude over allow', () => { + it('should prioritize exclude over allow', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { allowed: ['run_shell_command'], exclude: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const denyRule = config.rules?.find( (r) => r.toolName === 'run_shell_command' && @@ -252,12 +334,16 @@ describe('createPolicyEngineConfig', () => { expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!); }); - it('should prioritize specific tool allows over MCP server excludes', () => { + it('should prioritize specific tool allows over MCP server excludes', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { excluded: ['my-server'] }, tools: { allowed: ['my-server__specific-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const serverDenyRule = config.rules?.find( (r) => @@ -270,15 +356,16 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(195); + expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBe(100); + expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow - // Tool allow (100) has lower priority than server deny (195), - // so server deny wins - this might be counterintuitive + // Server deny (2.9) has higher priority than tool allow (2.3), + // so server deny wins (this is expected behavior - server-level blocks are security critical) }); - it('should prioritize specific tool excludes over MCP server allows', () => { + it('should handle MCP server allows and tool excludes', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['my-server'] }, mcpServers: { @@ -290,7 +377,10 @@ describe('createPolicyEngineConfig', () => { }, tools: { exclude: ['my-server__dangerous-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const serverAllowRule = config.rules?.find( (r) => @@ -304,19 +394,22 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); + // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); - it('should handle complex priority scenarios correctly', () => { + it('should handle complex priority scenarios correctly', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { - autoAccept: true, // Priority 50 for read-only tools - allowed: ['my-server__tool1', 'other-tool'], // Priority 100 - exclude: ['my-server__tool2', 'glob'], // Priority 200 + autoAccept: true, // Not used in policy system (modes handle this) + allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 + exclude: ['my-server__tool2', 'glob'], // Priority 2.4 }, mcp: { - allowed: ['allowed-server'], // Priority 85 - excluded: ['excluded-server'], // Priority 195 + allowed: ['allowed-server'], // Priority 2.1 + excluded: ['excluded-server'], // Priority 2.9 }, mcpServers: { 'trusted-server': { @@ -326,7 +419,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Verify glob is denied even though autoAccept would allow it const globDenyRule = config.rules?.find( @@ -337,8 +433,10 @@ describe('createPolicyEngineConfig', () => { ); expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); - expect(globDenyRule!.priority).toBe(200); - expect(globAllowRule!.priority).toBe(50); + // Deny from settings (user tier) + expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + // Allow from default TOML: 1 + 50/1000 = 1.05 + expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); // Verify all priority levels are correct const priorities = config.rules @@ -349,16 +447,17 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes + // Check that the highest priority items are the excludes (user tier: 2.4) const highestPriorityExcludes = priorities?.filter( - (p) => p.priority === 200, + (p) => Math.abs(p.priority! - 2.4) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), ).toBe(true); }); - it('should handle MCP servers with undefined trust property', () => { + it('should handle MCP servers with undefined trust property', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'no-trust-property': { @@ -373,7 +472,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Neither server should have an allow rule const noTrustRule = config.rules?.find( @@ -391,20 +493,22 @@ describe('createPolicyEngineConfig', () => { expect(explicitFalseRule).toBeUndefined(); }); - it('should not add write tool rules in YOLO mode', () => { + it('should have YOLO allow-all rule beat write tool rules in YOLO mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { exclude: ['dangerous-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO); - // Should have the wildcard allow rule with priority 0 + // Should have the wildcard allow rule const wildcardRule = config.rules?.find( - (r) => - !r.toolName && r.decision === PolicyDecision.ALLOW && r.priority === 0, + (r) => !r.toolName && r.decision === PolicyDecision.ALLOW, ); expect(wildcardRule).toBeDefined(); + // Priority 999 in default tier → 1.999 + expect(wildcardRule?.priority).toBeCloseTo(1.999, 5); - // Should NOT have any write tool rules (which would have priority 10) + // Write tool ASK_USER rules are present (no modes restriction now) const writeToolRules = config.rules?.filter( (r) => [ @@ -415,18 +519,24 @@ describe('createPolicyEngineConfig', () => { WEB_FETCH_TOOL_NAME, ].includes(r.toolName || '') && r.decision === PolicyDecision.ASK_USER, ); - expect(writeToolRules).toHaveLength(0); + expect(writeToolRules).toBeDefined(); - // Should still have the exclude rule + // But YOLO allow-all rule has higher priority than all write tool rules + writeToolRules?.forEach((writeRule) => { + expect(wildcardRule!.priority).toBeGreaterThan(writeRule.priority!); + }); + + // Should still have the exclude rule (from settings, user tier) const excludeRule = config.rules?.find( (r) => r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBe(200); + expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude }); - it('should handle combination of trusted server and excluded server for same name', () => { + it('should handle combination of trusted server and excluded server for same name', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'conflicted-server': { @@ -439,7 +549,10 @@ describe('createPolicyEngineConfig', () => { excluded: ['conflicted-server'], // Priority 195 }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Both rules should exist const trustRule = config.rules?.find( @@ -454,18 +567,19 @@ describe('createPolicyEngineConfig', () => { ); expect(trustRule).toBeDefined(); - expect(trustRule?.priority).toBe(90); + expect(trustRule?.priority).toBe(2.2); // MCP trusted server expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBe(195); + expect(excludeRule?.priority).toBe(2.9); // MCP excluded server // Exclude (195) should win over trust (90) when evaluated }); - it('should handle all approval modes correctly', () => { + it('should handle all approval modes correctly', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; // Test DEFAULT mode - const defaultConfig = createPolicyEngineConfig( + const defaultConfig = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); @@ -477,16 +591,20 @@ describe('createPolicyEngineConfig', () => { ).toBeUndefined(); // Test YOLO mode - const yoloConfig = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const yoloConfig = await createPolicyEngineConfig( + settings, + ApprovalMode.YOLO, + ); expect(yoloConfig.defaultDecision).toBe(PolicyDecision.ASK_USER); const yoloWildcard = yoloConfig.rules?.find( (r) => !r.toolName && r.decision === PolicyDecision.ALLOW, ); expect(yoloWildcard).toBeDefined(); - expect(yoloWildcard?.priority).toBe(0); + // Priority 999 in default tier → 1.999 + expect(yoloWildcard?.priority).toBeCloseTo(1.999, 5); // Test AUTO_EDIT mode - const autoEditConfig = createPolicyEngineConfig( + const autoEditConfig = await createPolicyEngineConfig( settings, ApprovalMode.AUTO_EDIT, ); @@ -495,6 +613,1044 @@ describe('createPolicyEngineConfig', () => { (r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW, ); expect(editRule).toBeDefined(); - expect(editRule?.priority).toBe(15); + // Priority 15 in default tier → 1.015 + expect(editRule?.priority).toBeCloseTo(1.015, 5); + }); + + it('should support argsPattern in policy rules', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +argsPattern = "\\"command\\":\\"git (status|diff|log)\\"" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(rule?.priority).toBeCloseTo(2.15, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git log"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false); + expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should load and apply user-defined policies', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(rule?.priority).toBeCloseTo(2.15, 5); + + vi.doUnmock('node:fs/promises'); + }); + + it('should load and apply admin policies over user and default policies', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + (nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies/write.toml')) || + path.endsWith('tmp/admin/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "deny" +priority = 200 +`; + } + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const denyRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.DENY, + ); + const allowRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + + expect(denyRule).toBeDefined(); + // Priority 200 in admin tier → 3.200 + expect(denyRule?.priority).toBeCloseTo(3.2, 5); + expect(allowRule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(allowRule?.priority).toBeCloseTo(2.15, 5); + expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should apply priority bands to ensure Admin > User > Default hierarchy', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'admin-policy.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'user-policy.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if (typeof path === 'string') { + // Admin policy with low priority (100) + if ( + nodePath + .normalize(path) + .includes( + nodePath.normalize('/tmp/admin/policies/admin-policy.toml'), + ) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "deny" +priority = 100 +`; + } + // User policy with high priority (900) + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/user-policy.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 900 +`; + } + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const adminRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.DENY, + ); + const userRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + + expect(adminRule).toBeDefined(); + expect(userRule).toBeDefined(); + + // Admin priority should be 3.100 (tier 3 + 100/1000) + expect(adminRule?.priority).toBeCloseTo(3.1, 5); + // User priority should be 2.900 (tier 2 + 900/1000) + expect(userRule?.priority).toBeCloseTo(2.9, 5); + + // Admin rule with low priority should still beat user rule with high priority + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should apply correct priority transformations for each tier', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'admin.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'user.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies/admin.toml')) + ) { + return ` +[[rule]] +toolName = "admin-tool" +decision = "allow" +priority = 500 +`; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/user.toml')) + ) { + return ` +[[rule]] +toolName = "user-tool" +decision = "allow" +priority = 500 +`; + } + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const adminRule = config.rules?.find((r) => r.toolName === 'admin-tool'); + const userRule = config.rules?.find((r) => r.toolName === 'user-tool'); + + expect(adminRule).toBeDefined(); + expect(userRule).toBeDefined(); + + // Priority 500 in admin tier → 3.500 + expect(adminRule?.priority).toBeCloseTo(3.5, 5); + // Priority 500 in user tier → 2.500 + expect(userRule?.priority).toBeCloseTo(2.5, 5); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should support array syntax for toolName in TOML policies', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'array-test.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/array-test.toml')) + ) { + return ` +# Test array syntax for toolName +[[rule]] +toolName = ["tool1", "tool2", "tool3"] +decision = "allow" +priority = 100 + +# Test array syntax with mcpName +[[rule]] +mcpName = "google-workspace" +toolName = ["calendar.findFreeTime", "calendar.getEvent", "calendar.list"] +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile( + path, + options as Parameters[1], + ); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + // Should create separate rules for each tool in the array + const tool1Rule = config.rules?.find((r) => r.toolName === 'tool1'); + const tool2Rule = config.rules?.find((r) => r.toolName === 'tool2'); + const tool3Rule = config.rules?.find((r) => r.toolName === 'tool3'); + + expect(tool1Rule).toBeDefined(); + expect(tool2Rule).toBeDefined(); + expect(tool3Rule).toBeDefined(); + + // All should have the same decision and priority + expect(tool1Rule?.decision).toBe(PolicyDecision.ALLOW); + expect(tool2Rule?.decision).toBe(PolicyDecision.ALLOW); + expect(tool3Rule?.decision).toBe(PolicyDecision.ALLOW); + + // Priority 100 in user tier → 2.100 + expect(tool1Rule?.priority).toBeCloseTo(2.1, 5); + expect(tool2Rule?.priority).toBeCloseTo(2.1, 5); + expect(tool3Rule?.priority).toBeCloseTo(2.1, 5); + + // MCP tools should have composite names + const calendarFreeTime = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.findFreeTime', + ); + const calendarGetEvent = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.getEvent', + ); + const calendarList = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.list', + ); + + expect(calendarFreeTime).toBeDefined(); + expect(calendarGetEvent).toBeDefined(); + expect(calendarList).toBeDefined(); + + // All should have the same decision and priority + expect(calendarFreeTime?.decision).toBe(PolicyDecision.ALLOW); + expect(calendarGetEvent?.decision).toBe(PolicyDecision.ALLOW); + expect(calendarList?.decision).toBe(PolicyDecision.ALLOW); + + // Priority 150 in user tier → 2.150 + expect(calendarFreeTime?.priority).toBeCloseTo(2.15, 5); + expect(calendarGetEvent?.priority).toBeCloseTo(2.15, 5); + expect(calendarList?.priority).toBeCloseTo(2.15, 5); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support commandPrefix syntax for shell commands', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git status" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + expect(rule?.priority).toBeCloseTo(2.1, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + // Should match commands starting with "git status" + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe( + true, + ); + // Should not match other commands + expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support array syntax for commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git branch", "git log"] +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rules = config.rules?.filter( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + + // Should create 3 rules (one for each prefix) + expect(rules?.length).toBe(3); + + // All rules should have the same priority and decision + rules?.forEach((rule) => { + expect(rule.priority).toBeCloseTo(2.1, 5); + expect(rule.decision).toBe(PolicyDecision.ALLOW); + }); + + // Test that each prefix pattern works + const patterns = rules?.map((r) => r.argsPattern); + expect(patterns?.some((p) => p?.test('{"command":"git status"}'))).toBe( + true, + ); + expect(patterns?.some((p) => p?.test('{"command":"git branch"}'))).toBe( + true, + ); + expect(patterns?.some((p) => p?.test('{"command":"git log"}'))).toBe(true); + // Should not match other commands + expect(patterns?.some((p) => p?.test('{"command":"git commit"}'))).toBe( + false, + ); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support commandRegex syntax for shell commands', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|branch|log).*" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + expect(rule?.priority).toBeCloseTo(2.1, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + + // Should match commands matching the regex + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe( + true, + ); + expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git log --all"}')).toBe(true); + // Should not match commands not in the regex + expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false); + expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should escape regex special characters in commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git log *.txt" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Should match the literal string "git log *.txt" (asterisk is escaped) + expect(rule?.argsPattern?.test('{"command":"git log *.txt"}')).toBe(true); + // Should not match "git log a.txt" because * is escaped to literal asterisk + expect(rule?.argsPattern?.test('{"command":"git log a.txt"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); }); }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 0ebe8f06e0..7714780c47 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -8,88 +8,172 @@ import { type PolicyEngineConfig, PolicyDecision, type PolicyRule, - ApprovalMode, - // Read-only tools - GREP_TOOL_NAME, - LS_TOOL_NAME, - READ_MANY_FILES_TOOL_NAME, - READ_FILE_TOOL_NAME, - // Write tools - SHELL_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - GLOB_TOOL_NAME, - EDIT_TOOL_NAME, - MEMORY_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, + type ApprovalMode, type PolicyEngine, type MessageBus, MessageBusType, type UpdatePolicy, + Storage, } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { type Settings, getSystemSettingsPath } from './settings.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + loadPoliciesFromToml, + type PolicyFileError, +} from './policy-toml-loader.js'; -// READ_ONLY_TOOLS is a list of built-in tools that do not modify the user's -// files or system state. -const READ_ONLY_TOOLS = new Set([ - GLOB_TOOL_NAME, - GREP_TOOL_NAME, - LS_TOOL_NAME, - READ_FILE_TOOL_NAME, - READ_MANY_FILES_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -]); +// Get the directory name of the current module +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// WRITE_TOOLS is a list of built-in tools that can modify the user's files or -// system state. These tools have a shouldConfirmExecute method. -// We are keeping this here for visibility and to maintain backwards compatibility -// with the existing tool permissions system. Eventually we'll remove this and -// any tool that isn't read only will require a confirmation unless altered by -// config and policy. -const WRITE_TOOLS = new Set([ - EDIT_TOOL_NAME, - MEMORY_TOOL_NAME, - SHELL_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, -]); +// Store policy loading errors to be displayed after UI is ready +let storedPolicyErrors: string[] = []; -export function createPolicyEngineConfig( +function getPolicyDirectories(): string[] { + const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies'); + const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); + const systemSettingsPath = getSystemSettingsPath(); + const ADMIN_POLICIES_DIR = path.join( + path.dirname(systemSettingsPath), + 'policies', + ); + + return [ + DEFAULT_POLICIES_DIR, + USER_POLICIES_DIR, + ADMIN_POLICIES_DIR, + ].reverse(); +} + +/** + * Determines the policy tier (1=default, 2=user, 3=admin) for a given directory. + * This is used by the TOML loader to assign priority bands. + */ +function getPolicyTier(dir: string): number { + const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies'); + const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); + const systemSettingsPath = getSystemSettingsPath(); + const ADMIN_POLICIES_DIR = path.join( + path.dirname(systemSettingsPath), + 'policies', + ); + + // Normalize paths for comparison + const normalizedDir = path.resolve(dir); + const normalizedDefault = path.resolve(DEFAULT_POLICIES_DIR); + const normalizedUser = path.resolve(USER_POLICIES_DIR); + const normalizedAdmin = path.resolve(ADMIN_POLICIES_DIR); + + if (normalizedDir === normalizedDefault) return 1; + if (normalizedDir === normalizedUser) return 2; + if (normalizedDir === normalizedAdmin) return 3; + + // Default to tier 1 if unknown + return 1; +} + +/** + * Formats a policy file error for console logging. + */ +function formatPolicyError(error: PolicyFileError): string { + const tierLabel = error.tier.toUpperCase(); + let message = `[${tierLabel}] Policy file error in ${error.fileName}:\n`; + message += ` ${error.message}`; + if (error.details) { + message += `\n${error.details}`; + } + if (error.suggestion) { + message += `\n Suggestion: ${error.suggestion}`; + } + return message; +} + +export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, -): PolicyEngineConfig { - const rules: PolicyRule[] = []; +): Promise { + const policyDirs = getPolicyDirectories(); + + // Load policies from TOML files + const { rules: tomlRules, errors } = await loadPoliciesFromToml( + approvalMode, + policyDirs, + getPolicyTier, + ); + + // Store any errors encountered during TOML loading + // These will be emitted by getPolicyErrorsForUI() after the UI is ready. + if (errors.length > 0) { + storedPolicyErrors = errors.map((error) => formatPolicyError(error)); + } + + const rules: PolicyRule[] = [...tomlRules]; // Priority system for policy rules: // - Higher priority numbers win over lower priority numbers // - When multiple rules match, the highest priority rule is applied // - Rules are evaluated in order of priority (highest first) // - // Priority levels used in this configuration: - // 0: Default allow-all (YOLO mode only) - // 10: Write tools default to ASK_USER - // 50: Auto-accept read-only tools - // 85: MCP servers allowed list - // 90: MCP servers with trust=true - // 100: Explicitly allowed individual tools - // 195: Explicitly excluded MCP servers - // 199: Tools that the user has selected as "Always Allow" in the interactive UI. - // 200: Explicitly excluded individual tools (highest priority) + // Priority bands (tiers): + // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) + // - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // + // This ensures Admin > User > Default hierarchy is always preserved, + // while allowing user-specified priorities to work within each tier. + // + // Settings-based and dynamic rules (all in user tier 2.x): + // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI + // 2.9: MCP servers excluded list (security: persistent server blocks) + // 2.4: Command line flag --exclude-tools (explicit temporary blocks) + // 2.3: Command line flag --allowed-tools (explicit temporary allows) + // 2.2: MCP servers with trust=true (persistent trusted servers) + // 2.1: MCP servers allowed list (persistent general server allows) + // + // TOML policy priorities (before transformation): + // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) + // 15: Auto-edit tool override (becomes 1.015 in default tier) + // 50: Read-only tools (becomes 1.050 in default tier) + // 999: YOLO mode allow-all (becomes 1.999 in default tier) - // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 85 (lower than trusted servers) - if (settings.mcp?.allowed) { - for (const serverName of settings.mcp.allowed) { + // MCP servers that are explicitly excluded in settings.mcp.excluded + // Priority: 2.9 (highest in user tier for security - persistent server blocks) + if (settings.mcp?.excluded) { + for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, + decision: PolicyDecision.DENY, + priority: 2.9, + }); + } + } + + // Tools that are explicitly excluded in the settings. + // Priority: 2.4 (user tier - explicit temporary blocks) + if (settings.tools?.exclude) { + for (const tool of settings.tools.exclude) { + rules.push({ + toolName: tool, + decision: PolicyDecision.DENY, + priority: 2.4, + }); + } + } + + // Tools that are explicitly allowed in the settings. + // Priority: 2.3 (user tier - explicit temporary allows) + if (settings.tools?.allowed) { + for (const tool of settings.tools.allowed) { + rules.push({ + toolName: tool, decision: PolicyDecision.ALLOW, - priority: 85, + priority: 2.3, }); } } // MCP servers that are trusted in the settings. - // Priority: 90 (higher than general allowed servers but lower than explicit tool allows) + // Priority: 2.2 (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -100,83 +184,24 @@ export function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 90, + priority: 2.2, }); } } } - // Tools that are explicitly allowed in the settings. - // Priority: 100 - if (settings.tools?.allowed) { - for (const tool of settings.tools.allowed) { - rules.push({ - toolName: tool, - decision: PolicyDecision.ALLOW, - priority: 100, - }); - } - } - - // Tools that are explicitly excluded in the settings. - // Priority: 200 - if (settings.tools?.exclude) { - for (const tool of settings.tools.exclude) { - rules.push({ - toolName: tool, - decision: PolicyDecision.DENY, - priority: 200, - }); - } - } - - // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 195 (high priority to block servers) - if (settings.mcp?.excluded) { - for (const serverName of settings.mcp.excluded) { + // MCP servers that are explicitly allowed in settings.mcp.allowed + // Priority: 2.1 (user tier - persistent general server allows) + if (settings.mcp?.allowed) { + for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, - decision: PolicyDecision.DENY, - priority: 195, + decision: PolicyDecision.ALLOW, + priority: 2.1, }); } } - // Allow all read-only tools. - // Priority: 50 - for (const tool of READ_ONLY_TOOLS) { - rules.push({ - toolName: tool, - decision: PolicyDecision.ALLOW, - priority: 50, - }); - } - - // Only add write tool rules if not in YOLO mode - // In YOLO mode, the wildcard ALLOW rule handles everything - if (approvalMode !== ApprovalMode.YOLO) { - for (const tool of WRITE_TOOLS) { - rules.push({ - toolName: tool, - decision: PolicyDecision.ASK_USER, - priority: 10, - }); - } - } - - if (approvalMode === ApprovalMode.YOLO) { - rules.push({ - decision: PolicyDecision.ALLOW, - priority: 0, // Lowest priority - catches everything not explicitly configured - }); - } else if (approvalMode === ApprovalMode.AUTO_EDIT) { - rules.push({ - toolName: EDIT_TOOL_NAME, - decision: PolicyDecision.ALLOW, - priority: 15, // Higher than write tools (10) to override ASK_USER - }); - } - return { rules, defaultDecision: PolicyDecision.ASK_USER, @@ -195,8 +220,23 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - priority: 199, // High priority, but lower than explicit DENY (200) + // User tier (2) + high priority (950/1000) = 2.95 + // This ensures user "always allow" selections are high priority + // but still lose to admin policies (3.xxx) and settings excludes (200) + priority: 2.95, }); }, ); } + +/** + * Gets and clears any policy errors that were stored during config loading. + * This should be called once the UI is ready to display errors. + * + * @returns Array of formatted error messages, or empty array if no errors + */ +export function getPolicyErrorsForUI(): string[] { + const errors = [...storedPolicyErrors]; + storedPolicyErrors = []; // Clear after retrieving + return errors; +} diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 3c79657b14..6ca94c14c3 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2159,7 +2159,7 @@ describe('Settings Loading and Merging', () => { }, ui: {}, model: { - name: 'gemini-1.5-pro', + name: 'gemini-2.5-pro', }, unrecognized: 'value', }; @@ -2168,7 +2168,7 @@ describe('Settings Loading and Merging', () => { expect(v1Settings).toEqual({ vimMode: false, - model: 'gemini-1.5-pro', + model: 'gemini-2.5-pro', unrecognized: 'value', }); }); @@ -2433,7 +2433,7 @@ describe('Settings Loading and Merging', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); const extensionManager = new ExtensionManager({ - loadedSettings, + settings: loadedSettings.merged, workspaceDir: MOCK_WORKSPACE_DIR, requestConsent: vi.fn(), requestSetting: vi.fn(), @@ -2442,7 +2442,7 @@ describe('Settings Loading and Merging', () => { extensionManager, 'disableExtension', ); - mockDisableExtension.mockImplementation(() => {}); + mockDisableExtension.mockImplementation(async () => {}); migrateDeprecatedSettings(loadedSettings, extensionManager); @@ -2506,7 +2506,7 @@ describe('Settings Loading and Merging', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); const extensionManager = new ExtensionManager({ - loadedSettings, + settings: loadedSettings.merged, workspaceDir: MOCK_WORKSPACE_DIR, requestConsent: vi.fn(), requestSetting: vi.fn(), @@ -2515,7 +2515,7 @@ describe('Settings Loading and Merging', () => { extensionManager, 'disableExtension', ); - mockDisableExtension.mockImplementation(() => {}); + mockDisableExtension.mockImplementation(async () => {}); migrateDeprecatedSettings(loadedSettings, extensionManager); diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 089e0fb505..b7b2c6be16 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -10,6 +10,8 @@ import { IdeConnectionType, logIdeConnection, type Config, + StartSessionEvent, + logCliConfiguration, } from '@google/gemini-cli-core'; import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; @@ -42,6 +44,11 @@ export async function initializeApp( const shouldOpenAuthDialog = settings.merged.security?.auth?.selectedType === undefined || !!authError; + logCliConfiguration( + config, + new StartSessionEvent(config, config.getToolRegistry()), + ); + if (config.getIdeMode()) { const ideClient = await IdeClient.getInstance(); await ideClient.connect(); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index e1c04e2cfd..f8e16d9313 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -174,6 +174,18 @@ describe('gemini.tsx main function', () => { getMessageBus: () => ({ subscribe: vi.fn(), }), + getToolRegistry: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-001', + getApprovalMode: () => 'default', + getCoreTools: () => [], + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getExtensions: () => [], + getUsageStatisticsEnabled: () => false, } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -309,6 +321,18 @@ describe('gemini.tsx main function kitty protocol', () => { getMessageBus: () => ({ subscribe: vi.fn(), }), + getToolRegistry: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-001', + getApprovalMode: () => 'default', + getCoreTools: () => [], + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getExtensions: () => [], + getUsageStatisticsEnabled: () => false, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -340,6 +364,7 @@ describe('gemini.tsx main function kitty protocol', () => { useWriteTodos: undefined, outputFormat: undefined, fakeResponses: undefined, + recordResponses: undefined, }); await main(); @@ -377,8 +402,7 @@ describe('validateDnsResolutionOrder', () => { it('should return the default "ipv4first" and log a warning for an invalid string', () => { expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); - expect(consoleWarnSpy).toHaveBeenCalledOnce(); - expect(consoleWarnSpy).toHaveBeenCalledWith( + expect(consoleWarnSpy).toHaveBeenCalledExactlyOnceWith( 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', ); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 05388524f3..8aa68e72c2 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -67,8 +67,8 @@ import { } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; import { ExtensionManager } from './config/extension-manager.js'; -import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { createPolicyUpdater } from './config/policy.js'; +import { requestConsentNonInteractive } from './config/extensions/consent.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -230,7 +230,7 @@ export async function main() { // Temporary extension manager only used during this non-interactive UI phase. new ExtensionManager({ workspaceDir: process.cwd(), - loadedSettings: settings, + settings: settings.merged, enabledExtensionOverrides: [], requestConsent: requestConsentNonInteractive, requestSetting: null, @@ -299,7 +299,6 @@ export async function main() { if (sandboxConfig) { const partialConfig = await loadCliConfig( settings.merged, - [], sessionId, argv, ); @@ -370,23 +369,7 @@ export async function main() { // to run Gemini CLI. It is now safe to perform expensive initialization that // may have side effects. { - // Eventually, `extensions` should move off of `config` entirely and into - // the UI state instead. - const extensionManager = new ExtensionManager({ - loadedSettings: settings, - workspaceDir: process.cwd(), - // At this stage, we still don't have an interactive UI. - requestConsent: requestConsentNonInteractive, - requestSetting: null, - enabledExtensionOverrides: argv.extensions, - }); - const extensions = extensionManager.loadExtensions(); - const config = await loadCliConfig( - settings.merged, - extensions, - sessionId, - argv, - ); + const config = await loadCliConfig(settings.merged, sessionId, argv); const policyEngine = config.getPolicyEngine(); const messageBus = config.getMessageBus(); @@ -397,7 +380,7 @@ export async function main() { if (config.getListExtensions()) { debugLogger.log('Installed extensions:'); - for (const extension of extensions) { + for (const extension of config.getExtensions()) { debugLogger.log(`- ${extension.name}`); } process.exit(0); @@ -434,7 +417,7 @@ export async function main() { } if (config.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, extensions, argv); + return runZedIntegration(config, settings, argv); } let input = config.getQuestion(); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index da5d097c64..cff544305d 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -190,6 +190,9 @@ describe('runNonInteractive', () => { } } + const getWrittenOutput = () => + processStdoutSpy.mock.calls.map((c) => c[0]).join(''); + it('should process input and write text output', async () => { const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Hello' }, @@ -215,9 +218,7 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), 'prompt-id-1', ); - expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); - expect(processStdoutSpy).toHaveBeenCalledWith(' World'); - expect(processStdoutSpy).toHaveBeenCalledWith('\n'); + expect(getWrittenOutput()).toBe('Hello World\n'); expect(mockShutdownTelemetry).toHaveBeenCalled(); }); @@ -285,8 +286,77 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), 'prompt-id-2', ); - expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); - expect(processStdoutSpy).toHaveBeenCalledWith('\n'); + expect(getWrittenOutput()).toBe('Final answer\n'); + }); + + it('should write a single newline between sequential text outputs from the model', async () => { + // This test simulates a multi-turn conversation to ensure that a single newline + // is printed between each block of text output from the model. + + // 1. Define the tool requests that the model will ask the CLI to run. + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'mock-tool', + name: 'mockTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-multi', + }, + }; + + // 2. Mock the execution of the tools. We just need them to succeed. + mockCoreExecuteToolCall.mockResolvedValue({ + status: 'success', + request: toolCallEvent.value, // This is generic enough for both calls + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'mock-tool', + }, + }); + + // 3. Define the sequence of events streamed from the mock model. + // Turn 1: Model outputs text, then requests a tool call. + const modelTurn1: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool' }, + toolCallEvent, + ]; + // Turn 2: Model outputs more text, then requests another tool call. + const modelTurn2: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool again' }, + toolCallEvent, + ]; + // Turn 3: Model outputs a final answer. + const modelTurn3: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Finished.' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(modelTurn1)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn2)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); + + // 4. Run the command. + await runNonInteractive( + mockConfig, + mockSettings, + 'Use mock tool multiple times', + 'prompt-id-multi', + ); + + // 5. Verify the output. + // The rendered output should contain the text from each turn, separated by a + // single newline, with a final newline at the end. + expect(getWrittenOutput()).toMatchSnapshot(); + + // Also verify the tools were called as expected. + expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2); }); it('should handle error during tool execution and should send error back to the model', async () => { @@ -369,7 +439,7 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), 'prompt-id-3', ); - expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); + expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); }); it('should exit with error if sendMessageStream throws initially', async () => { @@ -444,9 +514,7 @@ describe('runNonInteractive', () => { 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', ); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); - expect(processStdoutSpy).toHaveBeenCalledWith( - "Sorry, I can't find that tool.", - ); + expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n"); }); it('should exit when max session turns are exceeded', async () => { @@ -506,7 +574,7 @@ describe('runNonInteractive', () => { ); // 6. Assert the final output is correct - expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.'); + expect(getWrittenOutput()).toBe('Summary complete.\n'); }); it('should process input and write JSON output with stats', async () => { @@ -850,7 +918,7 @@ describe('runNonInteractive', () => { 'prompt-id-slash', ); - expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); + expect(getWrittenOutput()).toBe('Response from command\n'); }); it('should throw FatalInputError if a command requires confirmation', async () => { @@ -905,7 +973,7 @@ describe('runNonInteractive', () => { 'prompt-id-unknown', ); - expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); + expect(getWrittenOutput()).toBe('Response to unknown\n'); }); it('should throw for unhandled command result types', async () => { @@ -962,7 +1030,7 @@ describe('runNonInteractive', () => { expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); - expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged'); + expect(getWrittenOutput()).toBe('Acknowledged\n'); }); it('should instantiate CommandService with correct loaders for slash commands', async () => { @@ -1073,7 +1141,7 @@ describe('runNonInteractive', () => { expect.objectContaining({ name: 'ShellTool' }), expect.any(AbortSignal), ); - expect(processStdoutSpy).toHaveBeenCalledWith('file.txt'); + expect(getWrittenOutput()).toBe('file.txt\n'); }); describe('CoreEvents Integration', () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 7b89732b10..efb0e3186d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -40,6 +40,7 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; +import { TextOutput } from './ui/utils/textOutput.js'; export async function runNonInteractive( config: Config, @@ -52,6 +53,7 @@ export async function runNonInteractive( stderr: true, debugMode: config.getDebugMode(), }); + const textOutput = new TextOutput(); const handleUserFeedback = (payload: UserFeedbackPayload) => { const prefix = payload.severity.toUpperCase(); @@ -183,7 +185,9 @@ export async function runNonInteractive( } else if (config.getOutputFormat() === OutputFormat.JSON) { responseText += event.value; } else { - process.stdout.write(event.value); + if (event.value) { + textOutput.write(event.value); + } } } else if (event.type === GeminiEventType.ToolCallRequest) { if (streamFormatter) { @@ -220,6 +224,7 @@ export async function runNonInteractive( } if (toolCallRequests.length > 0) { + textOutput.ensureTrailingNewline(); const toolResponseParts: Part[] = []; const completedToolCalls: CompletedToolCall[] = []; @@ -297,9 +302,9 @@ export async function runNonInteractive( } else if (config.getOutputFormat() === OutputFormat.JSON) { const formatter = new JsonFormatter(); const stats = uiTelemetryService.getMetrics(); - process.stdout.write(formatter.format(responseText, stats)); + textOutput.write(formatter.format(responseText, stats)); } else { - process.stdout.write('\n'); // Ensure a final newline + textOutput.ensureTrailingNewline(); // Ensure a final newline } return; } diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx new file mode 100644 index 0000000000..b705c2a5e1 --- /dev/null +++ b/packages/cli/src/test-utils/render.test.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { useState, useEffect } from 'react'; +import { renderHook } from './render.js'; + +describe('renderHook', () => { + it('should rerender with previous props when called without arguments', async () => { + const useTestHook = ({ value }: { value: number }) => { + const [count, setCount] = useState(0); + useEffect(() => { + setCount((c) => c + 1); + }, [value]); + return { count, value }; + }; + + const { result, rerender } = renderHook(useTestHook, { + initialProps: { value: 1 }, + }); + + expect(result.current.value).toBe(1); + await vi.waitFor(() => expect(result.current.count).toBe(1)); + + // Rerender with new props + rerender({ value: 2 }); + expect(result.current.value).toBe(2); + await vi.waitFor(() => expect(result.current.count).toBe(2)); + + // Rerender without arguments should use previous props (value: 2) + // This would previously crash or pass undefined if not fixed + rerender(); + expect(result.current.value).toBe(2); + // Count should not increase because value didn't change + await vi.waitFor(() => expect(result.current.count).toBe(2)); + }); + + it('should handle initial render without props', () => { + const useTestHook = () => { + const [count, setCount] = useState(0); + return { count, increment: () => setCount((c) => c + 1) }; + }; + + const { result, rerender } = renderHook(useTestHook); + + expect(result.current.count).toBe(0); + + rerender(); + expect(result.current.count).toBe(0); + }); + + it('should update props if undefined is passed explicitly', () => { + const useTestHook = (val: string | undefined) => val; + const { result, rerender } = renderHook(useTestHook, { + initialProps: 'initial', + }); + + expect(result.current).toBe('initial'); + + rerender(undefined); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index d07f6663cb..1eb00406c5 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -6,6 +6,7 @@ import { render } from 'ink-testing-library'; import type React from 'react'; +import { act } from 'react'; import { LoadedSettings, type Settings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; @@ -64,6 +65,7 @@ const baseMockUiState = { streamingState: StreamingState.Idle, mainAreaWidth: 100, terminalWidth: 120, + currentModel: 'gemini-pro', }; export const renderWithProviders = ( @@ -127,3 +129,59 @@ export const renderWithProviders = ( , ); }; + +export function renderHook( + renderCallback: (props: Props) => Result, + options?: { + initialProps?: Props; + wrapper?: React.ComponentType<{ children: React.ReactNode }>; + }, +): { + result: { current: Result }; + rerender: (props?: Props) => void; + unmount: () => void; +} { + const result = { current: undefined as unknown as Result }; + let currentProps = options?.initialProps as Props; + + function TestComponent({ + renderCallback, + props, + }: { + renderCallback: (props: Props) => Result; + props: Props; + }) { + result.current = renderCallback(props); + return null; + } + + const Wrapper = options?.wrapper || (({ children }) => <>{children}); + + let inkRerender: (tree: React.ReactElement) => void = () => {}; + let unmount: () => void = () => {}; + + act(() => { + const renderResult = render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + }); + + function rerender(props?: Props) { + if (arguments.length > 0) { + currentProps = props as Props; + } + act(() => { + inkRerender( + + + , + ); + }); + } + + return { result, rerender, unmount }; +} diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5864437880..0337a6bc1a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -12,6 +12,7 @@ import { beforeEach, afterEach, type Mock, + type MockedObject, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; import { AppContainer } from './AppContainer.js'; @@ -131,11 +132,13 @@ import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; +import { type ExtensionManager } from '../config/extension-manager.js'; describe('AppContainer State Management', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockInitResult: InitializationResult; + let mockExtensionManager: MockedObject; // Create typed mocks for all hooks const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; @@ -282,6 +285,15 @@ describe('AppContainer State Management', () => { // Mock config's getTargetDir to return consistent workspace directory vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + mockExtensionManager = vi.mockObject({ + getExtensions: vi.fn().mockReturnValue([]), + setRequestConsent: vi.fn(), + setRequestSetting: vi.fn(), + } as unknown as ExtensionManager); + vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + // Mock LoadedSettings mockSettings = { merged: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 426543d772..eef68e4e03 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -50,6 +50,7 @@ import { } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; +import { getPolicyErrorsForUI } from '../config/policy.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; @@ -98,7 +99,7 @@ import { useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; -import { ExtensionManager } from '../config/extension-manager.js'; +import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -168,21 +169,12 @@ export const AppContainer = (props: AppContainerProps) => { null, ); - const extensions = config.getExtensions(); - const [extensionManager] = useState( - new ExtensionManager({ - enabledExtensionOverrides: config.getEnabledExtensions(), - workspaceDir: config.getWorkingDir(), - requestConsent: (description) => - requestConsentInteractive( - description, - addConfirmUpdateExtensionRequest, - ), - // TODO: Support requesting settings in the interactive CLI - requestSetting: null, - loadedSettings: settings, - }), + const extensionManager = config.getExtensionLoader() as ExtensionManager; + // We are in the interactive CLI, update how we request consent and settings. + extensionManager.setRequestConsent((description) => + requestConsentInteractive(description, addConfirmUpdateExtensionRequest), ); + extensionManager.setRequestSetting(); const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = useConfirmUpdateRequests(); @@ -190,7 +182,7 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, extensionsUpdateStateInternal, dispatchExtensionStateUpdate, - } = useExtensionUpdates(extensions, extensionManager, historyManager.addItem); + } = useExtensionUpdates(extensionManager, historyManager.addItem); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback( @@ -261,20 +253,18 @@ export const AppContainer = (props: AppContainerProps) => { [historyManager.addItem], ); - // Watch for model changes (e.g., from Flash fallback) + // Subscribe to fallback mode changes from core useEffect(() => { - const checkModelChange = () => { + const handleFallbackModeChanged = () => { const effectiveModel = getEffectiveModel(); - if (effectiveModel !== currentModel) { - setCurrentModel(effectiveModel); - } + setCurrentModel(effectiveModel); }; - checkModelChange(); - const interval = setInterval(checkModelChange, 1000); // Check every second - - return () => clearInterval(interval); - }, [config, currentModel, getEffectiveModel]); + coreEvents.on(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); + return () => { + coreEvents.off(CoreEvent.FallbackModeChanged, handleFallbackModeChanged); + }; + }, [getEffectiveModel]); const { consoleMessages, @@ -550,7 +540,7 @@ Logging in with Google... Please restart Gemini CLI to continue. config.getDebugMode(), config.getFileService(), settings.merged, - config.getExtensions(), + config.getExtensionLoader(), config.isTrustedFolder(), settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), @@ -590,7 +580,7 @@ Logging in with Google... Please restart Gemini CLI to continue. }, Date.now(), ); - console.error('Error refreshing memory:', error); + debugLogger.warn('Error refreshing memory:', error); } }, [config, historyManager, settings.merged]); @@ -896,11 +886,23 @@ Logging in with Google... Please restart Gemini CLI to continue. }; appEvents.on(AppEvent.LogError, logErrorHandler); + // Emit any policy errors that were stored during config loading + // Only show these when message bus integration is enabled, as policies + // are only active when the message bus is being used. + if (config.getEnableMessageBusIntegration()) { + const policyErrors = getPolicyErrorsForUI(); + if (policyErrors.length > 0) { + for (const error of policyErrors) { + appEvents.emit(AppEvent.LogError, error); + } + } + } + return () => { appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); appEvents.off(AppEvent.LogError, logErrorHandler); }; - }, [handleNewMessage]); + }, [handleNewMessage, config]); useEffect(() => { if (ctrlCTimerRef.current) { diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index b174b1d8d5..ee078356c5 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -103,7 +103,7 @@ export const directoryCommand: SlashCommand = { ], config.getDebugMode(), config.getFileService(), - config.getExtensions(), + config.getExtensionLoader(), config.getFolderTrust(), context.services.settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 562744a0de..c10b3896d1 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -9,9 +9,14 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import { extensionsCommand } from './extensionsCommand.js'; import { type CommandContext } from './types.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; +import open from 'open'; +vi.mock('open', () => ({ + default: vi.fn(), +})); + vi.mock('../../config/extensions/update.js', () => ({ updateExtension: vi.fn(), checkForAllExtensionUpdates: vi.fn(), @@ -26,6 +31,7 @@ describe('extensionsCommand', () => { beforeEach(() => { vi.resetAllMocks(); mockGetExtensions.mockReturnValue([]); + vi.mocked(open).mockClear(); mockContext = createMockCommandContext({ services: { config: { @@ -39,6 +45,11 @@ describe('extensionsCommand', () => { }); }); + afterEach(() => { + // Restore any stubbed environment variables, similar to docsCommand.test.ts + vi.unstubAllEnvs(); + }); + describe('list', () => { it('should add an EXTENSIONS_LIST item to the UI', async () => { if (!extensionsCommand.action) throw new Error('Action not defined'); @@ -302,4 +313,89 @@ describe('extensionsCommand', () => { }); }); }); + + describe('explore', () => { + const exploreAction = extensionsCommand.subCommands?.find( + (cmd) => cmd.name === 'explore', + )?.action; + + if (!exploreAction) { + throw new Error('Explore action not found'); + } + + it("should add an info message and call 'open' in a non-sandbox environment", async () => { + // Ensure no special environment variables that would affect behavior + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', ''); + + await exploreAction(mockContext, ''); + + const extensionsUrl = 'https://geminicli.com/extensions/'; + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }, + expect.any(Number), + ); + + expect(open).toHaveBeenCalledWith(extensionsUrl); + }); + + it('should only add an info message in a sandbox environment', async () => { + // Simulate a sandbox environment + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', 'gemini-sandbox'); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }, + expect.any(Number), + ); + + // Ensure 'open' was not called in the sandbox + expect(open).not.toHaveBeenCalled(); + }); + + it('should add an info message and not call open in NODE_ENV test environment', async () => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SANDBOX', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }, + expect.any(Number), + ); + + // Ensure 'open' was not called in test environment + expect(open).not.toHaveBeenCalled(); + }); + + it('should handle errors when opening the browser', async () => { + vi.stubEnv('NODE_ENV', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + const errorMessage = 'Failed to open browser'; + vi.mocked(open).mockRejectedValue(new Error(errorMessage)); + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }, + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 612de23cc6..45ea3e47b6 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -13,6 +13,8 @@ import { type SlashCommand, CommandKind, } from './types.js'; +import open from 'open'; +import process from 'node:process'; async function listAction(context: CommandContext) { const historyItem: HistoryItemExtensionsList = { @@ -112,6 +114,51 @@ function updateAction(context: CommandContext, args: string): Promise { return updateComplete.then((_) => {}); } +async function exploreAction(context: CommandContext) { + const extensionsUrl = 'https://geminicli.com/extensions/'; + + // Only check for NODE_ENV for explicit test mode, not for unit test framework + if (process.env['NODE_ENV'] === 'test') { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }, + Date.now(), + ); + } else if ( + process.env['SANDBOX'] && + process.env['SANDBOX'] !== 'sandbox-exec' + ) { + context.ui.addItem( + { + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }, + Date.now(), + ); + } else { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }, + Date.now(), + ); + try { + await open(extensionsUrl); + } catch (_error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }, + Date.now(), + ); + } + } +} + const listExtensionsCommand: SlashCommand = { name: 'list', description: 'List active extensions', @@ -141,11 +188,22 @@ const updateExtensionsCommand: SlashCommand = { }, }; +const exploreExtensionsCommand: SlashCommand = { + name: 'explore', + description: 'Open extensions page in your browser', + kind: CommandKind.BUILT_IN, + action: exploreAction, +}; + export const extensionsCommand: SlashCommand = { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, - subCommands: [listExtensionsCommand, updateExtensionsCommand], + subCommands: [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + ], action: (context, args) => // Default to list if no subcommand is provided listExtensionsCommand.action!(context, args), diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index b1f65a8a5f..523e0be0f1 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -13,6 +13,7 @@ import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { getErrorMessage, + SimpleExtensionLoader, type FileDiscoveryService, } from '@google/gemini-cli-core'; import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js'; @@ -72,6 +73,7 @@ describe('memoryCommand', () => { config: { getUserMemory: mockGetUserMemory, getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), }, }, }); @@ -176,6 +178,7 @@ describe('memoryCommand', () => { getWorkingDir: () => '/test/dir', getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, + getExtensionLoader: () => new SimpleExtensionLoader([]), getExtensions: () => [], shouldLoadMemoryFromIncludeDirectories: () => false, getWorkspaceContext: () => ({ diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 988c611291..ffe04fbe08 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -91,7 +91,7 @@ export const memoryCommand: SlashCommand = { config.getDebugMode(), config.getFileService(), settings.merged, - config.getExtensions(), + config.getExtensionLoader(), config.isTrustedFolder(), settings.merged.context?.importFormat || 'tree', config.getFileFilteringOptions(), diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx index 2f2f8a2a75..9d19683e22 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx @@ -27,7 +27,7 @@ export const ConsoleSummaryDisplay: React.FC = ({ {errorCount > 0 && ( {errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '} - (ctrl+o for details) + (F12 for details) )} diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index b31d088005..acc3f0622f 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -37,8 +37,7 @@ export const DetailedMessagesDisplay: React.FC< > - Debug Console{' '} - (ctrl+o to close) + Debug Console (F12 to close) diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 11676cf2f6..588f39653e 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,7 +5,7 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; -import { waitFor, act } from '@testing-library/react'; +import { act } from 'react'; import { vi } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; import * as processUtils from '../../utils/processUtils.js'; @@ -54,12 +54,12 @@ describe('FolderTrustDialog', () => { stdin.write('\u001b[27u'); // Press kitty escape key }); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain( 'A folder trust level must be selected to continue. Exiting since escape was pressed.', ); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedExit).toHaveBeenCalledWith(1); }); expect(onSelect).not.toHaveBeenCalled(); @@ -93,7 +93,7 @@ describe('FolderTrustDialog', () => { stdin.write('r'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedExit).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index a27f6b26d1..f5ef617e0d 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -256,3 +256,31 @@ describe('