mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 17:21:01 -07:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30cc4a8a9 | |||
| 0e169ee968 | |||
| 6c23f0c7e1 | |||
| 83792d51c1 | |||
| 07ab16dbbe | |||
| afc1d50c20 | |||
| ae123c547c | |||
| c2705e8332 | |||
| 9574855435 | |||
| bf6dae4690 | |||
| b5529c2475 | |||
| 9e74a7ec18 | |||
| 4034c030e7 | |||
| 765fb67011 | |||
| 97c99f263a | |||
| f1a3c35dee | |||
| ebe98fdee9 | |||
| ba71ffa736 | |||
| 320c8aba4c | |||
| e7dccabf14 | |||
| a84d4d876e | |||
| 29031ea7cf | |||
| f3977392e6 | |||
| 535667baf6 | |||
| 33cf2da1df | |||
| 104587bae8 | |||
| aca8e1af05 | |||
| 6f92642524 | |||
| 8413dd62ef | |||
| 750dec5d8d | |||
| 335b36893b | |||
| 25a20f8e4e | |||
| b5ba88b001 | |||
| 8868b34c75 | |||
| 73dd7328df |
@@ -65,8 +65,6 @@ accessible.
|
||||
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
||||
snippets, commands, and API elements. Focus on the task when discussing
|
||||
interaction.
|
||||
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
|
||||
makes sense out of context.
|
||||
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
||||
tables).
|
||||
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
||||
@@ -100,6 +98,18 @@ accessible.
|
||||
> This is an example of a multi-line note that will be preserved
|
||||
> by Prettier.
|
||||
|
||||
### Links
|
||||
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
|
||||
link makes sense out of context, such as when being read by a screen reader.
|
||||
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
|
||||
to ensure portability. Use paths relative to the current file's directory
|
||||
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
|
||||
section of a path, but do verify that the resulting relative link exists. This
|
||||
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
|
||||
- **When changing headings, check for deep links:** If a user is changing a
|
||||
heading, check for deep links to that heading in other pages and update
|
||||
accordingly.
|
||||
|
||||
### Structure
|
||||
- **BLUF:** Start with an introduction explaining what to expect.
|
||||
- **Experimental features:** If a feature is clearly noted as experimental,
|
||||
@@ -157,7 +167,6 @@ documentation.
|
||||
- **Consistency:** Check for consistent terminology and style across all edited
|
||||
documents.
|
||||
|
||||
|
||||
## Phase 4: Verification and finalization
|
||||
Perform a final quality check to ensure that all changes are correctly formatted
|
||||
and that all links are functional.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Latest stable release: v0.35.1
|
||||
# Latest stable release: v0.35.2
|
||||
|
||||
Released: March 26, 2026
|
||||
|
||||
@@ -29,6 +29,11 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(core): allow disabling environment variable redaction by @galz10 in
|
||||
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@keith.schaab in
|
||||
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- feat(cli): customizable keyboard shortcuts by @scidomino in
|
||||
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
|
||||
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
|
||||
@@ -380,4 +385,4 @@ npm install -g @google/gemini-cli
|
||||
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.36.0-preview.3
|
||||
# Preview release: v0.36.0-preview.5
|
||||
|
||||
Released: March 25, 2026
|
||||
Released: March 27, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -31,6 +31,13 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
|
||||
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
|
||||
- docs(core): document agent_card_json string literal options for remote agents
|
||||
by @adamfweidman in
|
||||
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
|
||||
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
|
||||
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
|
||||
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
|
||||
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
@@ -379,4 +386,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# ACP Mode
|
||||
|
||||
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
|
||||
designed for programmatic control, primarily for IDE and other developer tool
|
||||
integrations. It uses a JSON-RPC protocol over stdio to communicate between
|
||||
Gemini CLI agent and a client.
|
||||
|
||||
To start Gemini CLI in ACP mode, use the `--acp` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp
|
||||
```
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### Existing integrations using ACP
|
||||
|
||||
The ACP Agent Registry simplifies the distribution and management of
|
||||
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
|
||||
and can be found in this registry.
|
||||
|
||||
For more general information about the registry, and how to use it with specific
|
||||
IDEs like JetBrains and Zed, refer to the
|
||||
[IDE Integration](../ide-integration/index.md) documentation.
|
||||
|
||||
You can also find more information on the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
|
||||
## Architecture and protocol basics
|
||||
|
||||
ACP mode establishes a client-server relationship between your tool (the client)
|
||||
and Gemini CLI (the server).
|
||||
|
||||
- **Communication:** The entire communication happens over standard input/output
|
||||
(stdio) using the JSON-RPC 2.0 protocol.
|
||||
- **Client's role:** The client is responsible for sending requests (e.g.,
|
||||
prompts) and handling responses and notifications from Gemini CLI.
|
||||
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
|
||||
requests, processes them, and sends back responses.
|
||||
|
||||
The core of the ACP implementation can be found in
|
||||
`packages/cli/src/acp/acpClient.ts`.
|
||||
|
||||
### Extending with MCP
|
||||
|
||||
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
|
||||
(like an IDE) expose its own functionality as "tools" that the Gemini model can
|
||||
use.
|
||||
|
||||
1. The client implements an **MCP server** that advertises its tools.
|
||||
2. During the ACP `initialize` handshake, the client provides the connection
|
||||
details for its MCP server.
|
||||
3. Gemini CLI connects to the MCP server, discovers the available tools, and
|
||||
makes them available to the AI model.
|
||||
4. When the model decides to use one of these tools, Gemini CLI sends a tool
|
||||
call request to the MCP server.
|
||||
|
||||
This mechanism lets for a powerful, two-way integration where the agent can
|
||||
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
|
||||
`packages/core/src/tools/mcp-client.ts`.
|
||||
|
||||
## Capabilities and supported methods
|
||||
|
||||
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
|
||||
control Gemini CLI.
|
||||
|
||||
### Core methods
|
||||
|
||||
- `initialize`: Establishes the initial connection and lets the client to
|
||||
register its MCP server.
|
||||
- `authenticate`: Authenticates the user.
|
||||
- `newSession`: Starts a new chat session.
|
||||
- `loadSession`: Loads a previous session.
|
||||
- `prompt`: Sends a prompt to the agent.
|
||||
- `cancel`: Cancels an ongoing prompt.
|
||||
|
||||
### Session control
|
||||
|
||||
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
|
||||
`auto-approve`).
|
||||
- `unstable_setSessionModel`: Changes the model for the current session.
|
||||
|
||||
### File system proxy
|
||||
|
||||
ACP includes a proxied file system service. This means that when the agent needs
|
||||
to read or write files, it does so through the ACP client. This is a security
|
||||
feature that ensures the agent only has access to the files that the client (and
|
||||
by extension, the user) has explicitly allowed.
|
||||
|
||||
## Debugging and telemetry
|
||||
|
||||
You can get insights into the ACP communication and the agent's behavior through
|
||||
debugging logs and telemetry.
|
||||
|
||||
### Debugging logs
|
||||
|
||||
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
|
||||
|
||||
```bash
|
||||
gemini --acp --debug
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
|
||||
For more detailed telemetry, you can use the following environment variables to
|
||||
capture telemetry data to a file:
|
||||
|
||||
- `GEMINI_TELEMETRY_ENABLED=true`
|
||||
- `GEMINI_TELEMETRY_TARGET=local`
|
||||
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
|
||||
|
||||
This will write a JSON log file containing detailed information about all the
|
||||
events happening within the agent, including ACP requests and responses. The
|
||||
integration test `integration-tests/acp-telemetry.test.ts` provides a working
|
||||
example of how to set this up.
|
||||
+15
-11
@@ -155,17 +155,21 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
|
||||
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
|
||||
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
|
||||
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
|
||||
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
|
||||
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
# IDE integration
|
||||
# IDE Integration
|
||||
|
||||
Gemini CLI can integrate with your IDE to provide a more seamless and
|
||||
context-aware experience. This integration allows the CLI to understand your
|
||||
workspace better and enables powerful features like native in-editor diffing.
|
||||
|
||||
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
|
||||
support VS Code extensions. To build support for other editors, see the
|
||||
[IDE Companion Extension Spec](./ide-companion-spec.md).
|
||||
There are two primary ways to integrate Gemini CLI with an IDE:
|
||||
|
||||
## Features
|
||||
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
|
||||
extension on [Antigravity](https://antigravity.google),
|
||||
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
|
||||
compatible editors.
|
||||
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
|
||||
between AI coding agents and IDEs. This method is used for integrations with
|
||||
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
|
||||
discovery and installation of compatible agents like Gemini CLI.
|
||||
|
||||
## VS Code companion extension
|
||||
|
||||
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
|
||||
VS Code compatible IDEs and improves your experience by providing real-time
|
||||
context such as open files, cursor positions, and text selection. The extension
|
||||
also enables a native diffing interface so you can seamlessly review and apply
|
||||
AI-generated code changes directly within your editor.
|
||||
|
||||
### Features
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
|
||||
truncated).
|
||||
|
||||
- **Native diffing:** When Gemini suggests code modifications, you can view the
|
||||
changes directly within your IDE's native diff viewer. This allows you to
|
||||
review, edit, and accept or reject the suggested changes seamlessly.
|
||||
changes directly within your IDE's native diff viewer. This lets you review,
|
||||
edit, and accept or reject the suggested changes seamlessly.
|
||||
|
||||
- **VS Code commands:** You can access Gemini CLI features directly from the VS
|
||||
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
|
||||
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
|
||||
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
|
||||
the extension.
|
||||
|
||||
## Installation and setup
|
||||
### Installation and setup
|
||||
|
||||
There are three ways to set up the IDE integration:
|
||||
|
||||
### 1. Automatic nudge (recommended)
|
||||
#### 1. Automatic nudge (recommended)
|
||||
|
||||
When you run Gemini CLI inside a supported editor, it will automatically detect
|
||||
your environment and prompt you to connect. Answering "Yes" will automatically
|
||||
run the necessary setup, which includes installing the companion extension and
|
||||
enabling the connection.
|
||||
|
||||
### 2. Manual installation from CLI
|
||||
#### 2. Manual installation from CLI
|
||||
|
||||
If you previously dismissed the prompt or want to install the extension
|
||||
manually, you can run the following command inside Gemini CLI:
|
||||
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
|
||||
|
||||
This will find the correct extension for your IDE and install it.
|
||||
|
||||
### 3. Manual installation from a marketplace
|
||||
#### 3. Manual installation from a marketplace
|
||||
|
||||
You can also install the extension directly from a marketplace.
|
||||
|
||||
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
|
||||
> After manually installing the extension, you must run `/ide enable` in the CLI
|
||||
> to activate the integration.
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
### Enabling and disabling
|
||||
#### Enabling and disabling
|
||||
|
||||
You can control the IDE integration from within the CLI:
|
||||
|
||||
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
|
||||
When enabled, Gemini CLI will automatically attempt to connect to the IDE
|
||||
companion extension.
|
||||
|
||||
### Checking the status
|
||||
#### Checking the status
|
||||
|
||||
To check the connection status and see the context the CLI has received from the
|
||||
IDE, run:
|
||||
@@ -108,9 +122,9 @@ recently opened files it is aware of.
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> The file list is limited to 10 recently accessed files within your
|
||||
> workspace and only includes local files on disk.)
|
||||
> workspace and only includes local files on disk.
|
||||
|
||||
### Working with diffs
|
||||
#### Working with diffs
|
||||
|
||||
When you ask Gemini to modify a file, it can open a diff view directly in your
|
||||
editor.
|
||||
@@ -135,6 +149,63 @@ accepting them.
|
||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||
up in the IDE as they will be auto-accepted.
|
||||
|
||||
## Agent Client Protocol (ACP)
|
||||
|
||||
ACP is an open protocol that standardizes how AI coding agents communicate with
|
||||
code editors and IDEs. It addresses the challenge of fragmented distribution,
|
||||
where agents traditionally needed custom integrations for each client. With ACP,
|
||||
developers can implement their agent once, and it becomes compatible with any
|
||||
ACP-compliant editor.
|
||||
|
||||
For a comprehensive introduction to ACP, including its architecture and
|
||||
benefits, refer to the official
|
||||
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
documentation.
|
||||
|
||||
### The ACP Agent Registry
|
||||
|
||||
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
|
||||
you to install and update Gemini CLI directly within supporting IDEs and
|
||||
eliminates the need for manual downloads or IDE-specific extensions.
|
||||
|
||||
Using the registry ensures:
|
||||
|
||||
- **Ease of use**: Discover and install agents directly within your IDE
|
||||
settings.
|
||||
- **Latest versions**: Ensures users always have access to the most up-to-date
|
||||
agent implementations.
|
||||
|
||||
For more details on how the registry works, visit the official
|
||||
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
|
||||
You can learn about how specific IDEs leverage this integration in the following
|
||||
section.
|
||||
|
||||
### IDE-specific integration
|
||||
|
||||
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
|
||||
Here’s how different IDEs leverage the ACP and the registry:
|
||||
|
||||
#### JetBrains IDEs
|
||||
|
||||
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
|
||||
support, allowing users to find and install ACP-compatible agents directly.
|
||||
|
||||
For more details, refer to the official
|
||||
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
|
||||
|
||||
#### Zed
|
||||
|
||||
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
|
||||
allows Zed users to easily browse, install, and manage ACP agents.
|
||||
|
||||
Learn more about Zed's integration with the ACP Registry in their
|
||||
[blog post](https://zed.dev/blog/acp-registry).
|
||||
|
||||
#### Other ACP-compatible IDEs
|
||||
|
||||
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
|
||||
directly through their in-built registry features.
|
||||
|
||||
## Using with sandboxing
|
||||
|
||||
If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues with IDE integration, here are some common error
|
||||
messages and how to resolve them.
|
||||
### VS Code companion extension errors
|
||||
|
||||
### Connection errors
|
||||
#### Connection errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
@@ -174,7 +244,7 @@ messages and how to resolve them.
|
||||
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
|
||||
continues, open a new terminal window or restart your IDE.
|
||||
|
||||
### Manual PID override
|
||||
#### Manual PID override
|
||||
|
||||
If automatic IDE detection fails, or if you are running Gemini CLI in a
|
||||
standalone terminal and want to manually associate it with a specific IDE
|
||||
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
|
||||
When this variable is set, Gemini CLI will skip automatic detection and attempt
|
||||
to connect using the provided PID.
|
||||
|
||||
### Configuration errors
|
||||
#### Configuration errors
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
@@ -210,7 +280,7 @@ to connect using the provided PID.
|
||||
- **Cause:** You have no workspace open in your IDE.
|
||||
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
||||
|
||||
### General errors
|
||||
#### General errors
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
@@ -220,9 +290,14 @@ to connect using the provided PID.
|
||||
IDE, like Antigravity or VS Code.
|
||||
|
||||
- **Message:**
|
||||
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
|
||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
|
||||
installer for your specific IDE.
|
||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
|
||||
Companion", and
|
||||
[install it manually](#3-manual-installation-from-a-marketplace).
|
||||
|
||||
### ACP integration errors
|
||||
|
||||
For issues related to ACP integration, please refer to the debugging and
|
||||
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
|
||||
|
||||
+100
-62
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"agent-history-provider-summarizer": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Maximum number of directories to search for memory.
|
||||
- **Default:** `200`
|
||||
|
||||
- **`context.memoryBoundaryMarkers`** (array):
|
||||
- **Description:** File or directory names that mark the boundary for
|
||||
GEMINI.md discovery. The upward traversal stops at the first directory
|
||||
containing any of these markers. An empty array disables parent traversal.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
[".git"]
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.includeDirectories`** (array):
|
||||
- **Description:** Additional directories to include in the workspace context.
|
||||
Missing directories will be skipped with a warning.
|
||||
@@ -1677,6 +1694,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncation`** (boolean):
|
||||
- **Description:** Enable truncation window logic for the Agent History
|
||||
Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryTruncationThreshold`** (number):
|
||||
- **Description:** The maximum number of messages before history is truncated.
|
||||
- **Default:** `30`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistoryRetainedMessages`** (number):
|
||||
- **Description:** The number of recent messages to retain after truncation.
|
||||
- **Default:** `15`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.agentHistorySummarization`** (boolean):
|
||||
- **Description:** Enable summarization of truncated content via a small model
|
||||
for the Agent History Provider.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.topicUpdateNarration`** (boolean):
|
||||
- **Description:** Enable the experimental Topic & Update communication model
|
||||
for reduced chattiness and structured progress reporting.
|
||||
@@ -2160,37 +2199,14 @@ You can customize this behavior in your `settings.json` file:
|
||||
Arguments passed directly when running the CLI can override other configurations
|
||||
for that specific session.
|
||||
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
- **`--acp`**:
|
||||
- Starts the agent in Agent Communication Protocol (ACP) mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- A comma-separated list of MCP server names to allow for the session.
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--approval-mode <mode>`**:
|
||||
- Sets the approval mode for tool calls. Available modes:
|
||||
- `default`: Prompt for approval on each tool call (default behavior)
|
||||
@@ -2204,35 +2220,24 @@ for that specific session.
|
||||
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
|
||||
`--yolo` for the new unified approach.
|
||||
- Example: `gemini --approval-mode auto_edit`
|
||||
- **`--allowed-tools <tool1,tool2,...>`**:
|
||||
- A comma-separated list of tool names that will bypass the confirmation
|
||||
dialog.
|
||||
- Example: `gemini --allowed-tools "ShellTool(git status)"`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--debug`** (**`-d`**):
|
||||
- Enables debug mode for this session, providing more verbose output. Open the
|
||||
debug console with F12 to see the additional logging.
|
||||
- **`--delete-session <identifier>`**:
|
||||
- Delete a specific chat session by its index number or full session UUID.
|
||||
- Use `--list-sessions` first to see available sessions, their indices, and
|
||||
UUIDs.
|
||||
- Example: `gemini --delete-session 3` or
|
||||
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
|
||||
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
|
||||
- Specifies a list of extensions to use for the session. If not provided, all
|
||||
available extensions are used.
|
||||
- Use the special term `gemini -e none` to disable all extensions.
|
||||
- Example: `gemini -e my-extension -e my-other-extension`
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--help`** (or **`-h`**):
|
||||
- Displays help information about command-line arguments.
|
||||
- **`--include-directories <dir1,dir2,...>`**:
|
||||
- Includes additional directories in the workspace for multi-directory
|
||||
support.
|
||||
@@ -2240,19 +2245,52 @@ for that specific session.
|
||||
- 5 directories can be added at maximum.
|
||||
- Example: `--include-directories /path/to/project1,/path/to/project2` or
|
||||
`--include-directories /path/to/project1 --include-directories /path/to/project2`
|
||||
- **`--list-extensions`** (**`-l`**):
|
||||
- Lists all available extensions and exits.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--model <model_name>`** (**`-m <model_name>`**):
|
||||
- Specifies the Gemini model to use for this session.
|
||||
- Example: `npm start -- --model gemini-3-pro-preview`
|
||||
- **`--output-format <format>`**:
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive
|
||||
mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
- Starts an interactive session with the provided prompt as the initial input.
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `gemini -i "explain this code"`
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--screen-reader`**:
|
||||
- Enables screen reader mode, which adjusts the TUI for better compatibility
|
||||
with screen readers.
|
||||
- **`--version`**:
|
||||
- Displays the version of the CLI.
|
||||
- **`--experimental-acp`**:
|
||||
- Starts the agent in ACP mode.
|
||||
- **`--allowed-mcp-server-names`**:
|
||||
- Allowed MCP server names.
|
||||
- **`--fake-responses`**:
|
||||
- Path to a file with fake model responses for testing.
|
||||
- **`--record-responses`**:
|
||||
- Path to a file to record model responses for testing.
|
||||
- **`--yolo`**:
|
||||
- Enables YOLO mode, which automatically approves all tool calls.
|
||||
|
||||
## Context files (hierarchical instructional context)
|
||||
|
||||
|
||||
@@ -86,12 +86,13 @@ available combinations.
|
||||
|
||||
#### Text Input
|
||||
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
| Command | Action | Keys |
|
||||
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `input.submit` | Submit the current prompt. | `Enter` |
|
||||
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
|
||||
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
|
||||
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
|
||||
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
|
||||
+11
-1
@@ -111,7 +111,17 @@
|
||||
{ "label": "Reference", "slug": "docs/hooks/reference" }
|
||||
]
|
||||
},
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE integration",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "Developer guide: ACP mode",
|
||||
"slug": "docs/cli/acp-mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
|
||||
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
|
||||
import { env } from 'node:process';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const itIf = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
describe('extension reloading', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
const sandboxEnv = env['GEMINI_SANDBOX'];
|
||||
// Fails in linux non-sandbox e2e tests
|
||||
// always fails
|
||||
// TODO(#14527): Re-enable this once fixed
|
||||
// Fails in sandbox mode, can't check for local extension updates.
|
||||
itIf(
|
||||
(!sandboxEnv || sandboxEnv === 'false') &&
|
||||
platform() !== 'win32' &&
|
||||
platform() !== 'linux',
|
||||
)(
|
||||
'installs a local extension, updates it, checks it was reloaded properly',
|
||||
async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
|
||||
const serverA = new TestMcpServer();
|
||||
const portA = await serverA.start({
|
||||
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
const extension = {
|
||||
name: 'test-extension',
|
||||
version: '0.0.1',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
httpUrl: `http://localhost:${portA}/mcp`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
'test-extension (v0.0.1) - active (update available)',
|
||||
);
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
rig.setup('extension reload test', {
|
||||
settings: {
|
||||
experimental: { extensionReloading: true },
|
||||
},
|
||||
});
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
// defensive cleanup from previous tests.
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
// Now create the update, but its not installed yet
|
||||
const serverB = new TestMcpServer();
|
||||
const portB = await serverB.start({
|
||||
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
|
||||
});
|
||||
extension.version = '0.0.2';
|
||||
extension.mcpServers['test-server'].httpUrl =
|
||||
`http://localhost:${portB}/mcp`;
|
||||
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
|
||||
|
||||
// Start the CLI.
|
||||
const run = await rig.runInteractive({ args: '--debug' });
|
||||
await run.expectText('You have 1 extension with an update available');
|
||||
// See the outdated extension
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
await run.expectText('test-extension (v0.0.1) - active (update available)');
|
||||
// Wait for the UI to settle and retry the command until we see the update
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_hello')
|
||||
);
|
||||
},
|
||||
30000, // 30s timeout
|
||||
);
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
await run.type('\r'); // consent
|
||||
await run.expectText(
|
||||
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
|
||||
);
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
),
|
||||
30000,
|
||||
);
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
output.includes(
|
||||
'test-server (from test-extension) - Ready (1 tool)',
|
||||
) && output.includes('- mcp_test-server_goodbye')
|
||||
);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+6
-5
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -10089,9 +10089,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
|
||||
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -10116,6 +10116,7 @@
|
||||
"type-fest": "^4.27.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -17550,7 +17551,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
+3
-3
@@ -52,7 +52,7 @@
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "eslint . --cache --max-warnings 0",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
@@ -68,7 +68,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -136,7 +136,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -109,6 +109,12 @@ export function createMockConfig(
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.11",
|
||||
"ink": "npm:@jrichman/ink@6.5.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
LlmRole,
|
||||
type GitService,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SettingScope,
|
||||
@@ -99,6 +100,8 @@ vi.mock(
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
createPolicyUpdater: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
setClientName: vi.fn(),
|
||||
},
|
||||
setClientName: vi.fn(),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
@@ -687,7 +707,10 @@ describe('Session', () => {
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
@@ -763,6 +786,32 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
@@ -1026,6 +1075,166 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: false },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
|
||||
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
const customSettings = {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const localSession = new Session(
|
||||
'session-2',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockConnection,
|
||||
customSettings,
|
||||
);
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValueOnce({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await localSession.prompt({
|
||||
sessionId: 'session-2',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'edit',
|
||||
@@ -1154,6 +1363,56 @@ describe('Session', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updatePolicy when tool permission triggers always allow', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
const { updatePolicy } = await import('@google/gemini-cli-core');
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
|
||||
expect(updatePolicy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use filePath for ACP diff content in tool result', async () => {
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
|
||||
@@ -48,7 +48,9 @@ import {
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -133,6 +136,7 @@ export class GeminiAgent {
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -322,6 +326,7 @@ export class GeminiAgent {
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
@@ -512,6 +517,12 @@ export class GeminiAgent {
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -841,6 +852,37 @@ export class Session {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof InvalidStreamError ||
|
||||
(error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
error.type === 'NO_RESPONSE_TEXT')
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new acp.RequestError(
|
||||
getErrorStatus(error) || 500,
|
||||
getAcpErrorMessage(error),
|
||||
@@ -1012,6 +1054,7 @@ export class Session {
|
||||
options: toPermissionOptions(
|
||||
confirmationDetails,
|
||||
this.context.config,
|
||||
this.settings.merged.security.enablePermanentToolApproval,
|
||||
),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
@@ -1036,6 +1079,16 @@ export class Session {
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
|
||||
// Update policy to enable Always Allow persistence
|
||||
await updatePolicy(
|
||||
tool,
|
||||
outcome,
|
||||
confirmationDetails,
|
||||
this.context,
|
||||
this.context.messageBus,
|
||||
invocation,
|
||||
);
|
||||
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.Cancel:
|
||||
return errorResponse(
|
||||
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200, // maxDirs
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
200,
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1122,12 +1125,7 @@ describe('mergeExcludeTools', () => {
|
||||
]);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
);
|
||||
|
||||
@@ -642,6 +642,7 @@ export async function loadCliConfig(
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
settings.context?.memoryBoundaryMarkers,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
@@ -896,6 +897,7 @@ export async function loadCliConfig(
|
||||
loadMemoryFromIncludeDirectories:
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
|
||||
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
@@ -975,6 +977,14 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAgentHistoryTruncation:
|
||||
settings.experimental?.agentHistoryTruncation,
|
||||
experimentalAgentHistoryTruncationThreshold:
|
||||
settings.experimental?.agentHistoryTruncationThreshold,
|
||||
experimentalAgentHistoryRetainedMessages:
|
||||
settings.experimental?.agentHistoryRetainedMessages,
|
||||
experimentalAgentHistorySummarization:
|
||||
settings.experimental?.agentHistorySummarization,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
|
||||
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
getDiscoveryMaxDirs: () => 200,
|
||||
getMemoryBoundaryMarkers: () => ['.git'],
|
||||
getMcpClientManager: () => ({
|
||||
getMcpInstructions: () => '',
|
||||
startExtension: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'lxc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'sandbox-exec',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'podman',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'env/image',
|
||||
});
|
||||
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
command: 'podman',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
|
||||
enabled: true,
|
||||
image: 'custom/image',
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/settings-path'],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
networkAccess: true,
|
||||
command: 'runsc',
|
||||
image: 'default/image',
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
|
||||
|
||||
let sandboxValue: boolean | string | null | undefined;
|
||||
let allowedPaths: string[] = [];
|
||||
let networkAccess = false;
|
||||
let networkAccess = true;
|
||||
let customImage: string | undefined;
|
||||
|
||||
if (
|
||||
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
|
||||
const config = sandboxOption;
|
||||
sandboxValue = config.enabled ? (config.command ?? true) : false;
|
||||
allowedPaths = config.allowedPaths ?? [];
|
||||
networkAccess = config.networkAccess ?? false;
|
||||
networkAccess = config.networkAccess ?? true;
|
||||
customImage = config.image;
|
||||
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
|
||||
sandboxValue = sandboxOption;
|
||||
|
||||
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Maximum number of directories to search for memory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
memoryBoundaryMarkers: {
|
||||
type: 'array',
|
||||
label: 'Memory Boundary Markers',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: ['.git'] as string[],
|
||||
description:
|
||||
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
|
||||
'The upward traversal stops at the first directory containing any of these markers. ' +
|
||||
'An empty array disables parent traversal.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
includeDirectories: {
|
||||
type: 'array',
|
||||
label: 'Include Directories',
|
||||
@@ -2141,6 +2154,46 @@ const SETTINGS_SCHEMA = {
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Truncation',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable truncation window logic for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryTruncationThreshold: {
|
||||
type: 'number',
|
||||
label: 'Agent History Truncation Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30,
|
||||
description:
|
||||
'The maximum number of messages before history is truncated.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistoryRetainedMessages: {
|
||||
type: 'number',
|
||||
label: 'Agent History Retained Messages',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 15,
|
||||
description:
|
||||
'The number of recent messages to retain after truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
agentHistorySummarization: {
|
||||
type: 'boolean',
|
||||
label: 'Agent History Summarization',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable summarization of truncated content via a small model for the Agent History Provider.',
|
||||
showInDialog: true,
|
||||
},
|
||||
topicUpdateNarration: {
|
||||
type: 'boolean',
|
||||
label: 'Topic & Update Narration',
|
||||
|
||||
@@ -671,11 +671,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register SessionEnd hook for graceful exit
|
||||
registerCleanup(async () => {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
if (!input) {
|
||||
debugLogger.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
SessionEndReason,
|
||||
type Config,
|
||||
type HookSystem,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
errors: [],
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as ReturnType<typeof loadSettings>);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
} as unknown as Config);
|
||||
|
||||
await main();
|
||||
|
||||
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
expect.objectContaining({ message: 'Cleanup failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
|
||||
const mockHookSystem = {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as HookSystem;
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getHookSystem: vi.fn(() => mockHookSystem),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
await main();
|
||||
|
||||
const registeredCallbacks = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map(([fn]) => fn);
|
||||
for (const fn of registeredCallbacks) await fn();
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Exit,
|
||||
);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => 'test'),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getAcpMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
getUseAlternateBuffer: vi.fn(() => false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
|
||||
export function mockInkSpinner() {
|
||||
vi.mock('ink-spinner', async () => {
|
||||
const { Text } = await import('ink');
|
||||
const cliSpinners = (await import('cli-spinners')).default;
|
||||
|
||||
return {
|
||||
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
|
||||
const spinner = cliSpinners[type];
|
||||
const frame = spinner ? spinner.frames[0] : '⠋';
|
||||
return <Text>{frame}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
|
||||
handleOverageMenuChoice: vi.fn(),
|
||||
handleEmptyWalletChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
addMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
|
||||
@@ -726,7 +726,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||
const handleDeleteSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
handleDeleteSessionSync(session);
|
||||
await handleDeleteSessionSync(session);
|
||||
},
|
||||
[handleDeleteSessionSync],
|
||||
);
|
||||
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
setQueueErrorMessage,
|
||||
addMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
flushTelemetry,
|
||||
resetBrowserSession,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
|
||||
// Close persistent browser sessions before resetting chat
|
||||
await resetBrowserSession();
|
||||
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
|
||||
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
onQueueMessage={uiActions.addMessage}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? vimMode === 'INSERT'
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
|
||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||
addMessage: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
onQueueMessage: vi.fn(),
|
||||
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
userMessages: [],
|
||||
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('queues a message when Tab is pressed during generation', async () => {
|
||||
props.buffer.setText('A new prompt');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
|
||||
expect(props.buffer.text).toBe('');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a slash command', async () => {
|
||||
props.buffer.setText('/clear');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Slash commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an error when attempting to queue a shell command', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls');
|
||||
props.streamingState = StreamingState.Responding;
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
|
||||
'Shell commands cannot be queued',
|
||||
);
|
||||
expect(props.onQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface InputPromptProps {
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
streamingState: StreamingState;
|
||||
popAllMessages?: () => string | undefined;
|
||||
onQueueMessage?: (message: string) => void;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
copyModeEnabled?: boolean;
|
||||
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
onQueueMessage,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
copyModeEnabled = false,
|
||||
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation;
|
||||
|
||||
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
|
||||
const isPlainTab =
|
||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||
const hasTabCompletionInteraction =
|
||||
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
|
||||
if (
|
||||
isGenerating &&
|
||||
isQueueMessageKey &&
|
||||
!hasTabCompletionInteraction &&
|
||||
buffer.text.trim().length > 0
|
||||
) {
|
||||
const trimmedMessage = buffer.text.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
if (isSlash || shellModeActive) {
|
||||
setQueueErrorMessage(
|
||||
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
} else if (onQueueMessage) {
|
||||
onQueueMessage(buffer.text);
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
}
|
||||
resetPlainTabPress();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlainTab && shellModeActive) {
|
||||
resetPlainTabPress();
|
||||
if (!shouldShowSuggestions) {
|
||||
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
onQueueMessage,
|
||||
setQueueErrorMessage,
|
||||
resetReverseSearchCompletionState,
|
||||
setBannerVisible,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { StatusRow } from './StatusRow.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { type ThoughtSummary } from '../types.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../hooks/useComposerStatus.js', () => ({
|
||||
useComposerStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('<StatusRow />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultUiState: Partial<UIState> = {
|
||||
currentTip: undefined,
|
||||
thought: null,
|
||||
elapsedTime: 0,
|
||||
currentWittyPhrase: undefined,
|
||||
activeHooks: [],
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
|
||||
shortcutsHelpVisible: false,
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
allowPlanMode: false,
|
||||
shellModeActive: false,
|
||||
renderMarkdown: true,
|
||||
currentModel: 'gemini-3',
|
||||
};
|
||||
|
||||
it('renders status and tip correctly when they both fit', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: true,
|
||||
showTips: true,
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
currentTip: 'Test Tip',
|
||||
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
|
||||
elapsedTime: 5,
|
||||
currentWittyPhrase: 'I am witty',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('I am witty');
|
||||
expect(output).toContain('Tip: Test Tip');
|
||||
});
|
||||
|
||||
it('renders correctly when interactive shell is waiting', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: true,
|
||||
showLoadingIndicator: false,
|
||||
showTips: false,
|
||||
showWit: false,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={true}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState: defaultUiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
|
||||
});
|
||||
|
||||
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
|
||||
(useComposerStatus as Mock).mockReturnValue({
|
||||
isInteractiveShellWaiting: false,
|
||||
showLoadingIndicator: true,
|
||||
showTips: true,
|
||||
showWit: true,
|
||||
modeContentObj: null,
|
||||
showMinimalContext: false,
|
||||
});
|
||||
|
||||
const uiState: Partial<UIState> = {
|
||||
...defaultUiState,
|
||||
currentTip: 'Test Tip',
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<StatusRow
|
||||
showUiDetails={false}
|
||||
isNarrow={false}
|
||||
terminalWidth={100}
|
||||
hideContextSummary={false}
|
||||
hideUiDetailsForSuggestions={false}
|
||||
hasPendingActionRequired={false}
|
||||
/>,
|
||||
{
|
||||
width: 100,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||
});
|
||||
});
|
||||
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setTipWidth(Math.round(entry.contentRect.width));
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
// Only update if width > 0 to prevent layout feedback loops
|
||||
// when the tip is hidden. This ensures we always use the
|
||||
// intrinsic width for collision detection.
|
||||
if (width > 0) {
|
||||
setTipWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
const showRow1 = showUiDetails || showRow1Minimal;
|
||||
const showRow2 = showUiDetails || showRow2Minimal;
|
||||
|
||||
const onStatusResize = useCallback((width: number) => {
|
||||
if (width > 0) setStatusWidth(width);
|
||||
}, []);
|
||||
|
||||
const statusNode = (
|
||||
<StatusNode
|
||||
showTips={showTips}
|
||||
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
errorVerbosity={
|
||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||
}
|
||||
onResize={setStatusWidth}
|
||||
onResize={onStatusResize}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
||||
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||
marginRight={
|
||||
isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
showTipLine
|
||||
? isNarrow
|
||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||
: 0
|
||||
}
|
||||
position={showTipLine ? 'relative' : 'absolute'}
|
||||
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||
>
|
||||
{/*
|
||||
We always render the tip node so it can be measured by ResizeObserver,
|
||||
but we control its visibility based on the collision detection.
|
||||
We always render the tip node so it can be measured by ResizeObserver.
|
||||
When hidden, we use absolute positioning so it can still be measured
|
||||
but doesn't affect the layout of Row 1. This prevents layout loops.
|
||||
*/}
|
||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
{!isNarrow && tipContentStr && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: () => <Text>⠋</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface UIActions {
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
addMessage: (message: string) => void;
|
||||
popAllMessages: () => string | undefined;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
|
||||
@@ -98,7 +98,7 @@ export const useSessionBrowser = (
|
||||
* Deletes a session by ID using the ChatRecordingService.
|
||||
*/
|
||||
handleDeleteSession: useCallback(
|
||||
(session: SessionInfo) => {
|
||||
async (session: SessionInfo) => {
|
||||
// Note: Chat sessions are stored on disk using a filename derived from
|
||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||
// The ChatRecordingService.deleteSession API expects this file basename
|
||||
@@ -108,7 +108,7 @@ export const useSessionBrowser = (
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
await chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||
|
||||
@@ -74,6 +74,7 @@ export enum Command {
|
||||
|
||||
// Text Input
|
||||
SUBMIT = 'input.submit',
|
||||
QUEUE_MESSAGE = 'input.queueMessage',
|
||||
NEWLINE = 'input.newline',
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// Text Input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
[Command.SUBMIT, [new KeyBinding('enter')]],
|
||||
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
|
||||
[
|
||||
Command.NEWLINE,
|
||||
[
|
||||
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
title: 'Text Input',
|
||||
commands: [
|
||||
Command.SUBMIT,
|
||||
Command.QUEUE_MESSAGE,
|
||||
Command.NEWLINE,
|
||||
Command.OPEN_EXTERNAL_EDITOR,
|
||||
Command.PASTE_CLIPBOARD,
|
||||
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
|
||||
// Text Input
|
||||
[Command.SUBMIT]: 'Submit the current prompt.',
|
||||
[Command.QUEUE_MESSAGE]:
|
||||
'Queue the current prompt to be processed after the current task finishes.',
|
||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||
'Open the current prompt or the plan in an external editor.',
|
||||
|
||||
@@ -803,7 +803,26 @@ function setupNetworkLogging(
|
||||
// Flush buffered logs
|
||||
flushBuffer();
|
||||
break;
|
||||
|
||||
case 'trigger-debugger': {
|
||||
import('node:inspector')
|
||||
.then((inspector) => {
|
||||
inspector.open();
|
||||
debugLogger.log(
|
||||
'Node debugger attached. Open chrome://inspect in Chrome to start debugging.',
|
||||
);
|
||||
return import('./events.js');
|
||||
})
|
||||
.then(({ appEvents, AppEvent, TransientMessageType }) => {
|
||||
appEvents.emit(AppEvent.TransientMessage, {
|
||||
message: 'Debugger attached from DevTools.',
|
||||
type: TransientMessageType.Hint,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
debugLogger.debug('Failed to trigger debugger:', err),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'ping':
|
||||
sendMessage({ type: 'pong', timestamp: Date.now() });
|
||||
break;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
shutdownTelemetry,
|
||||
isTelemetrySdkInitialized,
|
||||
ExitCodes,
|
||||
resetBrowserSession,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -72,6 +73,13 @@ export async function runExitCleanup() {
|
||||
}
|
||||
cleanupFunctions.length = 0; // Clear the array
|
||||
|
||||
// Close persistent browser sessions before disposing config
|
||||
try {
|
||||
await resetBrowserSession();
|
||||
} catch (_) {
|
||||
// Ignore errors during browser cleanup
|
||||
}
|
||||
|
||||
if (configForTelemetry) {
|
||||
try {
|
||||
await configForTelemetry.dispose();
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
getErrorMessage,
|
||||
getErrorType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runSyncCleanup } from './cleanup.js';
|
||||
|
||||
@@ -82,7 +83,7 @@ export function handleError(
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: {
|
||||
type: error instanceof Error ? error.constructor.name : 'Error',
|
||||
type: getErrorType(error),
|
||||
message: errorMessage,
|
||||
},
|
||||
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||
@@ -177,7 +178,7 @@ export function handleCancellationError(config: Config): never {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: {
|
||||
type: 'FatalCancellationError',
|
||||
type: getErrorType(cancellationError),
|
||||
message: cancellationError.message,
|
||||
},
|
||||
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||
@@ -218,7 +219,7 @@ export function handleMaxTurnsExceededError(config: Config): never {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: {
|
||||
type: 'FatalTurnLimitedError',
|
||||
type: getErrorType(maxTurnsError),
|
||||
message: maxTurnsError.message,
|
||||
},
|
||||
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('Session Cleanup (Refactored)', () => {
|
||||
);
|
||||
// Session directory
|
||||
await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true });
|
||||
// Subagent chats directory
|
||||
await fs.mkdir(path.join(chatsDir, sessionId), { recursive: true });
|
||||
}
|
||||
|
||||
async function seedSessions() {
|
||||
@@ -274,6 +276,7 @@ describe('Session Cleanup (Refactored)', () => {
|
||||
existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)),
|
||||
).toBe(false);
|
||||
expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted
|
||||
expect(existsSync(path.join(chatsDir, sessions[1].id))).toBe(false); // Subagent chats directory should be deleted
|
||||
});
|
||||
|
||||
it('should NOT delete sessions within the cutoff date', async () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Storage,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
type Config,
|
||||
deleteSessionArtifactsAsync,
|
||||
deleteSubagentSessionDirAndArtifactsAsync,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings, SessionRetentionSettings } from '../config/settings.js';
|
||||
import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js';
|
||||
@@ -59,48 +61,18 @@ function deriveShortIdFromFileName(fileName: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the log path for a session ID.
|
||||
*/
|
||||
function getSessionLogPath(tempDir: string, safeSessionId: string): string {
|
||||
return path.join(tempDir, 'logs', `session-${safeSessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up associated artifacts (logs, tool-outputs, directory) for a session.
|
||||
*/
|
||||
async function deleteSessionArtifactsAsync(
|
||||
async function cleanupSessionAndSubagentsAsync(
|
||||
sessionId: string,
|
||||
config: Config,
|
||||
): Promise<void> {
|
||||
const tempDir = config.storage.getProjectTempDir();
|
||||
const chatsDir = path.join(tempDir, 'chats');
|
||||
|
||||
// Cleanup logs
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const logPath = getSessionLogPath(tempDir, safeSessionId);
|
||||
if (logPath.startsWith(logsDir)) {
|
||||
await fs.unlink(logPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup tool outputs
|
||||
const toolOutputDir = path.join(
|
||||
tempDir,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR);
|
||||
if (toolOutputDir.startsWith(toolOutputsBase)) {
|
||||
await fs
|
||||
.rm(toolOutputDir, { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup session directory
|
||||
const sessionDir = path.join(tempDir, safeSessionId);
|
||||
if (safeSessionId && sessionDir.startsWith(tempDir + path.sep)) {
|
||||
await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
await deleteSessionArtifactsAsync(sessionId, tempDir);
|
||||
await deleteSubagentSessionDirAndArtifactsAsync(sessionId, chatsDir, tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +173,7 @@ export async function cleanupExpiredSessions(
|
||||
await fs.unlink(filePath);
|
||||
|
||||
if (fullSessionId) {
|
||||
await deleteSessionArtifactsAsync(fullSessionId, config);
|
||||
await cleanupSessionAndSubagentsAsync(fullSessionId, config);
|
||||
}
|
||||
result.deleted++;
|
||||
} else {
|
||||
@@ -230,7 +202,7 @@ export async function cleanupExpiredSessions(
|
||||
|
||||
const sessionId = sessionToDelete.sessionInfo?.id;
|
||||
if (sessionId) {
|
||||
await deleteSessionArtifactsAsync(sessionId, config);
|
||||
await cleanupSessionAndSubagentsAsync(sessionId, config);
|
||||
}
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function deleteSession(
|
||||
try {
|
||||
// Use ChatRecordingService to delete the session
|
||||
const chatRecordingService = new ChatRecordingService(config);
|
||||
chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
await chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
|
||||
const time = formatRelativeTime(sessionToDelete.lastUpdated);
|
||||
writeToStdout(
|
||||
|
||||
@@ -8,6 +8,10 @@ import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { themeManager } from './src/ui/themes/theme-manager.js';
|
||||
import { mockInkSpinner } from './src/test-utils/mockSpinner.js';
|
||||
|
||||
// Globally mock ink-spinner to prevent non-deterministic snapshot/act flakes.
|
||||
mockInkSpinner();
|
||||
|
||||
// Unset CI environment variable so that ink renders dynamically as it does in a real terminal
|
||||
if (process.env.CI !== undefined) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createBrowserAgentDefinition,
|
||||
cleanupBrowserAgent,
|
||||
resetBrowserSession,
|
||||
} from './browserAgentFactory.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { makeFakeConfig } from '../../test-utils/config.js';
|
||||
@@ -15,7 +15,6 @@ import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../../policy/types.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../../policy/policy-engine.js';
|
||||
import type { BrowserManager } from './browserManager.js';
|
||||
|
||||
// Create mock browser manager
|
||||
const mockBrowserManager = {
|
||||
@@ -35,9 +34,17 @@ const mockBrowserManager = {
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./browserManager.js', () => ({
|
||||
BrowserManager: vi.fn(() => mockBrowserManager),
|
||||
}));
|
||||
vi.mock('./browserManager.js', () => {
|
||||
const instancesMap = new Map();
|
||||
const MockBrowserManager = vi.fn() as unknown as Record<string, unknown>;
|
||||
// Add static methods — use mockImplementation for lazy eval (hoisting-safe)
|
||||
MockBrowserManager['getInstance'] = vi.fn();
|
||||
MockBrowserManager['resetAll'] = vi.fn().mockResolvedValue(undefined);
|
||||
MockBrowserManager['instances'] = instancesMap;
|
||||
return {
|
||||
BrowserManager: MockBrowserManager,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./automationOverlay.js', () => ({
|
||||
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -60,9 +67,16 @@ describe('browserAgentFactory', () => {
|
||||
let mockConfig: Config;
|
||||
let mockMessageBus: MessageBus;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up getInstance to return mockBrowserManager
|
||||
// (Can't do this in vi.mock factory due to hoisting)
|
||||
const { BrowserManager: MockBM } = await import('./browserManager.js');
|
||||
(MockBM as unknown as Record<string, ReturnType<typeof vi.fn>>)[
|
||||
'getInstance'
|
||||
].mockReturnValue(mockBrowserManager);
|
||||
|
||||
vi.mocked(injectAutomationOverlay).mockClear();
|
||||
|
||||
// Reset mock implementations
|
||||
@@ -99,7 +113,7 @@ describe('browserAgentFactory', () => {
|
||||
} as unknown as MessageBus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -302,6 +316,23 @@ describe('browserAgentFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetBrowserSession', () => {
|
||||
it('should delegate to BrowserManager.resetAll', async () => {
|
||||
const { BrowserManager: MockBrowserManager } = await import(
|
||||
'./browserManager.js'
|
||||
);
|
||||
await resetBrowserSession();
|
||||
expect(
|
||||
(
|
||||
MockBrowserManager as unknown as Record<
|
||||
string,
|
||||
ReturnType<typeof vi.fn>
|
||||
>
|
||||
)['resetAll'],
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Policy Registration', () => {
|
||||
let mockPolicyEngine: {
|
||||
addRule: ReturnType<typeof vi.fn>;
|
||||
@@ -421,25 +452,6 @@ describe('browserAgentFactory', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBrowserAgent', () => {
|
||||
it('should call close on browser manager', async () => {
|
||||
await cleanupBrowserAgent(
|
||||
mockBrowserManager as unknown as BrowserManager,
|
||||
);
|
||||
|
||||
expect(mockBrowserManager.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during cleanup gracefully', async () => {
|
||||
const errorManager = {
|
||||
close: vi.fn().mockRejectedValue(new Error('Close failed')),
|
||||
} as unknown as BrowserManager;
|
||||
|
||||
// Should not throw
|
||||
await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBrowserSystemPrompt', () => {
|
||||
|
||||
@@ -62,8 +62,8 @@ export async function createBrowserAgentDefinition(
|
||||
'Creating browser agent definition with isolated MCP tools...',
|
||||
);
|
||||
|
||||
// Create and initialize browser manager with isolated MCP client
|
||||
const browserManager = new BrowserManager(config);
|
||||
// Get or create browser manager singleton for this session mode/profile
|
||||
const browserManager = BrowserManager.getInstance(config);
|
||||
await browserManager.ensureConnection();
|
||||
|
||||
if (printOutput) {
|
||||
@@ -242,19 +242,10 @@ export async function createBrowserAgentDefinition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up browser resources after agent execution.
|
||||
* Closes all persistent browser sessions and cleans up resources.
|
||||
*
|
||||
* @param browserManager The browser manager to clean up
|
||||
* Call this on /clear commands and CLI exit to reset browser state.
|
||||
*/
|
||||
export async function cleanupBrowserAgent(
|
||||
browserManager: BrowserManager,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browserManager.close();
|
||||
debugLogger.log('Browser agent cleanup complete');
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
export async function resetBrowserSession(): Promise<void> {
|
||||
await BrowserManager.resetAll();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
|
||||
|
||||
vi.mock('./browserAgentFactory.js', () => ({
|
||||
createBrowserAgentDefinition: vi.fn(),
|
||||
cleanupBrowserAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./inputBlocker.js', () => ({
|
||||
removeInputBlocker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../local-executor.js', () => ({
|
||||
@@ -35,10 +38,8 @@ vi.mock('../local-executor.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createBrowserAgentDefinition,
|
||||
cleanupBrowserAgent,
|
||||
} from './browserAgentFactory.js';
|
||||
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
|
||||
import { removeInputBlocker } from './inputBlocker.js';
|
||||
import { LocalAgentExecutor } from '../local-executor.js';
|
||||
import type { ToolLiveOutput } from '../../tools/tools.js';
|
||||
|
||||
@@ -190,7 +191,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
vi.mocked(LocalAgentExecutor.create).mockResolvedValue(
|
||||
mockExecutor as never,
|
||||
);
|
||||
vi.mocked(cleanupBrowserAgent).mockClear();
|
||||
vi.mocked(removeInputBlocker).mockClear();
|
||||
});
|
||||
|
||||
it('should return result text and call cleanup on success', async () => {
|
||||
@@ -209,7 +210,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
expect((result.llmContent as Array<{ text: string }>)[0].text).toContain(
|
||||
'Browser agent finished',
|
||||
);
|
||||
expect(cleanupBrowserAgent).toHaveBeenCalled();
|
||||
expect(removeInputBlocker).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without updateOutput (fire-and-forget)', async () => {
|
||||
@@ -239,7 +240,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
const result = await invocation.execute(controller.signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(cleanupBrowserAgent).toHaveBeenCalled();
|
||||
expect(removeInputBlocker).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── Structured SubagentProgress emission tests ───────────────────────
|
||||
|
||||
@@ -33,10 +33,7 @@ import {
|
||||
isToolActivityError,
|
||||
} from '../types.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import {
|
||||
createBrowserAgentDefinition,
|
||||
cleanupBrowserAgent,
|
||||
} from './browserAgentFactory.js';
|
||||
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
|
||||
import { removeInputBlocker } from './inputBlocker.js';
|
||||
import {
|
||||
sanitizeThoughtContent,
|
||||
@@ -368,10 +365,9 @@ ${displayResult}
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
// Always cleanup browser resources
|
||||
// Clean up input blocker, but keep browserManager alive for persistent sessions
|
||||
if (browserManager) {
|
||||
await removeInputBlocker(browserManager);
|
||||
await cleanupBrowserAgent(browserManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,10 @@ describe('BrowserManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
// Clear singleton cache to avoid cross-test leakage
|
||||
await BrowserManager.resetAll();
|
||||
});
|
||||
|
||||
describe('MCP bundled path resolution', () => {
|
||||
@@ -700,6 +702,137 @@ describe('BrowserManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstance', () => {
|
||||
it('should return the same instance for the same session mode', () => {
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
const instance2 = BrowserManager.getInstance(mockConfig);
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should return different instances for different session modes', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should return different instances for different profile paths', () => {
|
||||
const config1 = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { profilePath: '/path/a' },
|
||||
},
|
||||
});
|
||||
const config2 = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { profilePath: '/path/b' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(config1);
|
||||
const instance2 = BrowserManager.getInstance(config2);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetAll', () => {
|
||||
it('should close all instances and clear the cache', async () => {
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
await instance1.ensureConnection();
|
||||
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
await instance2.ensureConnection();
|
||||
|
||||
await BrowserManager.resetAll();
|
||||
|
||||
// After resetAll, getInstance should return new instances
|
||||
const instance3 = BrowserManager.getInstance(mockConfig);
|
||||
expect(instance3).not.toBe(instance1);
|
||||
});
|
||||
|
||||
it('should handle errors during cleanup gracefully', async () => {
|
||||
const instance = BrowserManager.getInstance(mockConfig);
|
||||
await instance.ensureConnection();
|
||||
|
||||
// Make close throw by overriding the client's close method
|
||||
const client = await instance.getRawMcpClient();
|
||||
vi.mocked(client.close).mockRejectedValueOnce(new Error('close failed'));
|
||||
|
||||
// Should not throw
|
||||
await expect(BrowserManager.resetAll()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConnected', () => {
|
||||
it('should return false before connection', () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after successful connection', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
expect(manager.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after close', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
await manager.close();
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnection', () => {
|
||||
it('should reconnect after unexpected disconnect', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.ensureConnection();
|
||||
|
||||
// Simulate transport closing unexpectedly via the onclose callback
|
||||
const transportInstance =
|
||||
vi.mocked(StdioClientTransport).mock.results[0]?.value;
|
||||
if (transportInstance?.onclose) {
|
||||
transportInstance.onclose();
|
||||
}
|
||||
|
||||
// Manager should recognize disconnection
|
||||
expect(manager.isConnected()).toBe(false);
|
||||
|
||||
// ensureConnection should reconnect
|
||||
await manager.ensureConnection();
|
||||
expect(manager.isConnected()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency', () => {
|
||||
it('should not call connectMcp twice when ensureConnection is called concurrently', async () => {
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
|
||||
// Call ensureConnection twice simultaneously without awaiting the first
|
||||
const [p1, p2] = [manager.ensureConnection(), manager.ensureConnection()];
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
// connectMcp (via StdioClientTransport constructor) should only have been called once
|
||||
// Each connection attempt creates a new StdioClientTransport
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay re-injection in callTool', () => {
|
||||
it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => {
|
||||
// Enable input disabling in config
|
||||
@@ -822,8 +955,6 @@ describe('BrowserManager', () => {
|
||||
|
||||
const manager = new BrowserManager(mockConfig);
|
||||
await manager.callTool('click', { uid: 'bad' });
|
||||
|
||||
expect(injectAutomationOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile';
|
||||
// Default timeout for MCP operations
|
||||
const MCP_TIMEOUT_MS = 60_000;
|
||||
|
||||
// Maximum reconnection attempts before giving up
|
||||
const MAX_RECONNECT_RETRIES = 3;
|
||||
|
||||
// Base delay (ms) for exponential backoff between reconnection attempts
|
||||
const RECONNECT_BASE_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* Tools that can cause a full-page navigation (explicitly or implicitly).
|
||||
*
|
||||
@@ -92,10 +98,73 @@ export interface McpToolCallResult {
|
||||
* in the main ToolRegistry. Tools are kept local to the browser agent.
|
||||
*/
|
||||
export class BrowserManager {
|
||||
// --- Static singleton management ---
|
||||
private static instances = new Map<string, BrowserManager>();
|
||||
|
||||
/**
|
||||
* Returns the cache key for a given config.
|
||||
* Uses `sessionMode:profilePath` so different profiles get separate instances.
|
||||
*/
|
||||
private static getInstanceKey(config: Config): string {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
|
||||
const profilePath = browserConfig.customConfig.profilePath ?? 'default';
|
||||
return `${sessionMode}:${profilePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an existing BrowserManager for the current config's session mode
|
||||
* and profile, or creates a new one.
|
||||
*/
|
||||
static getInstance(config: Config): BrowserManager {
|
||||
const key = BrowserManager.getInstanceKey(config);
|
||||
let instance = BrowserManager.instances.get(key);
|
||||
if (!instance) {
|
||||
instance = new BrowserManager(config);
|
||||
BrowserManager.instances.set(key, instance);
|
||||
debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Reusing existing BrowserManager singleton (key: ${key})`,
|
||||
);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all cached BrowserManager instances and clears the cache.
|
||||
* Called on /clear commands and CLI exit.
|
||||
*/
|
||||
static async resetAll(): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(BrowserManager.instances.values()).map((instance) =>
|
||||
instance.close(),
|
||||
),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
debugLogger.error(
|
||||
`Error during BrowserManager cleanup: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
BrowserManager.instances.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for resetAll — used by CLI exit cleanup for clarity.
|
||||
*/
|
||||
static async closeAll(): Promise<void> {
|
||||
await BrowserManager.resetAll();
|
||||
}
|
||||
|
||||
// --- Instance state ---
|
||||
// Raw MCP SDK Client - NOT the wrapper McpClient
|
||||
private rawMcpClient: Client | undefined;
|
||||
private mcpTransport: StdioClientTransport | undefined;
|
||||
private discoveredTools: McpTool[] = [];
|
||||
private disconnected = false;
|
||||
private connectionPromise: Promise<void> | undefined;
|
||||
|
||||
/** State for action rate limiting */
|
||||
private actionCounter = 0;
|
||||
@@ -266,14 +335,53 @@ export class BrowserManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the MCP client is currently connected and healthy.
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.rawMcpClient !== undefined && !this.disconnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures browser and MCP client are connected.
|
||||
* If a previous connection was lost (e.g., user closed the browser),
|
||||
* this will reconnect with exponential backoff (up to MAX_RECONNECT_RETRIES).
|
||||
*
|
||||
* Concurrent callers share a single in-flight connection promise so that
|
||||
* two subagents racing at startup do not trigger duplicate connectMcp() calls.
|
||||
*/
|
||||
async ensureConnection(): Promise<void> {
|
||||
if (this.rawMcpClient) {
|
||||
// Already connected and healthy — nothing to do
|
||||
if (this.rawMcpClient && !this.disconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A connection is already being established — wait for it instead of racing
|
||||
if (this.connectionPromise) {
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
// If previously connected but transport died, clean up before reconnecting
|
||||
if (this.disconnected) {
|
||||
debugLogger.log(
|
||||
'Previous browser connection was lost. Cleaning up before reconnecting...',
|
||||
);
|
||||
await this.close();
|
||||
this.disconnected = false;
|
||||
}
|
||||
|
||||
// Start connecting; store the promise so concurrent callers can join it
|
||||
this.connectionPromise = this.connectWithRetry().finally(() => {
|
||||
this.connectionPromise = undefined;
|
||||
});
|
||||
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to chrome-devtools-mcp with exponential backoff retry.
|
||||
*/
|
||||
private async connectWithRetry(): Promise<void> {
|
||||
// Request browser consent if needed (first-run privacy notice)
|
||||
const consentGranted = await getBrowserConsentIfNeeded();
|
||||
if (!consentGranted) {
|
||||
@@ -283,7 +391,23 @@ export class BrowserManager {
|
||||
);
|
||||
}
|
||||
|
||||
await this.connectMcp();
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt < MAX_RECONNECT_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.connectMcp();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt < MAX_RECONNECT_RETRIES - 1) {
|
||||
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt);
|
||||
debugLogger.log(
|
||||
`Connection attempt ${attempt + 1} failed, retrying in ${delay}ms...`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,6 +441,7 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
this.discoveredTools = [];
|
||||
this.connectionPromise = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,7 +567,7 @@ export class BrowserManager {
|
||||
'chrome-devtools-mcp transport closed unexpectedly. ' +
|
||||
'The MCP server process may have crashed.',
|
||||
);
|
||||
this.rawMcpClient = undefined;
|
||||
this.disconnected = true;
|
||||
};
|
||||
this.mcpTransport.onerror = (error: Error) => {
|
||||
debugLogger.error(
|
||||
|
||||
@@ -69,6 +69,10 @@ import {
|
||||
type FunctionDeclaration,
|
||||
} from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { SandboxManager } from '../services/sandboxManager.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
import { getDirectoryContextString } from '../utils/environmentContext.js';
|
||||
import { z } from 'zod';
|
||||
@@ -377,10 +381,8 @@ describe('LocalAgentExecutor', () => {
|
||||
describe('create (Initialization and Validation)', () => {
|
||||
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const mockGeminiClient =
|
||||
{} as unknown as import('../core/client.js').GeminiClient;
|
||||
const mockSandboxManager =
|
||||
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
|
||||
const mockGeminiClient = {} as unknown as GeminiClient;
|
||||
const mockSandboxManager = {} as unknown as SandboxManager;
|
||||
const extendedContext = {
|
||||
config: mockConfig,
|
||||
promptId: mockConfig.promptId,
|
||||
@@ -391,7 +393,7 @@ describe('LocalAgentExecutor', () => {
|
||||
geminiClient: mockGeminiClient,
|
||||
sandboxManager: mockSandboxManager,
|
||||
unintendedProperty: 'should not be here',
|
||||
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
@@ -414,7 +416,7 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
expect(executionContext).toBeDefined();
|
||||
expect(executionContext.config).toBe(extendedContext.config);
|
||||
expect(executionContext.promptId).toBe(extendedContext.promptId);
|
||||
expect(executionContext.promptId).toBeDefined();
|
||||
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
|
||||
expect(executionContext.sandboxManager).toBe(
|
||||
extendedContext.sandboxManager,
|
||||
@@ -445,7 +447,99 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
|
||||
});
|
||||
|
||||
it('should create successfully with allowed tools', async () => {
|
||||
it('should propagate parentSessionId from context when creating executionContext', async () => {
|
||||
const parentSessionId = 'top-level-session-id';
|
||||
const currentPromptId = 'subagent-a-id';
|
||||
const mockGeminiClient = {} as unknown as GeminiClient;
|
||||
const mockSandboxManager = {} as unknown as SandboxManager;
|
||||
const mockMessageBus = {
|
||||
derive: () => ({}),
|
||||
} as unknown as MessageBus;
|
||||
const mockToolRegistry = {
|
||||
getMessageBus: () => mockMessageBus,
|
||||
getAllToolNames: () => [],
|
||||
sortTools: () => {},
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const context = {
|
||||
config: mockConfig,
|
||||
promptId: currentPromptId,
|
||||
parentSessionId,
|
||||
toolRegistry: mockToolRegistry,
|
||||
promptRegistry: {} as unknown as PromptRegistry,
|
||||
resourceRegistry: {} as unknown as ResourceRegistry,
|
||||
geminiClient: mockGeminiClient,
|
||||
sandboxManager: mockSandboxManager,
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
const definition = createTestDefinition([]);
|
||||
const executor = await LocalAgentExecutor.create(definition, context);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
const chatConstructorArgs =
|
||||
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
|
||||
const executionContext = chatConstructorArgs[0];
|
||||
|
||||
expect(executionContext.parentSessionId).toBe(parentSessionId);
|
||||
expect(executionContext.promptId).toBe(executor['agentId']);
|
||||
});
|
||||
|
||||
it('should fall back to promptId if parentSessionId is missing (top-level subagent)', async () => {
|
||||
const rootSessionId = 'root-session-id';
|
||||
const mockGeminiClient = {} as unknown as GeminiClient;
|
||||
const mockSandboxManager = {} as unknown as SandboxManager;
|
||||
const mockMessageBus = {
|
||||
derive: () => ({}),
|
||||
} as unknown as MessageBus;
|
||||
const mockToolRegistry = {
|
||||
getMessageBus: () => mockMessageBus,
|
||||
getAllToolNames: () => [],
|
||||
sortTools: () => {},
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const context = {
|
||||
config: mockConfig,
|
||||
promptId: rootSessionId,
|
||||
// parentSessionId is undefined
|
||||
toolRegistry: mockToolRegistry,
|
||||
promptRegistry: {} as unknown as PromptRegistry,
|
||||
resourceRegistry: {} as unknown as ResourceRegistry,
|
||||
geminiClient: mockGeminiClient,
|
||||
sandboxManager: mockSandboxManager,
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
const definition = createTestDefinition([]);
|
||||
const executor = await LocalAgentExecutor.create(definition, context);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
const chatConstructorArgs =
|
||||
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
|
||||
const executionContext = chatConstructorArgs[0];
|
||||
|
||||
expect(executionContext.parentSessionId).toBe(rootSessionId);
|
||||
expect(executionContext.promptId).toBe(executor['agentId']);
|
||||
});
|
||||
it('should successfully with allowed tools', async () => {
|
||||
const definition = createTestDefinition([LS_TOOL_NAME]);
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
@@ -500,9 +594,7 @@ describe('LocalAgentExecutor', () => {
|
||||
onActivity,
|
||||
);
|
||||
|
||||
expect(executor['agentId']).toMatch(
|
||||
new RegExp(`^${parentId}-${definition.name}-`),
|
||||
);
|
||||
expect(executor['agentId']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should correctly apply templates to initialMessages', async () => {
|
||||
|
||||
@@ -121,7 +121,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
private get executionContext(): AgentLoopContext {
|
||||
return {
|
||||
config: this.context.config,
|
||||
promptId: this.context.promptId,
|
||||
promptId: this.agentId,
|
||||
parentSessionId: this.context.parentSessionId || this.context.promptId, // Always preserve the main agent session ID
|
||||
geminiClient: this.context.geminiClient,
|
||||
sandboxManager: this.context.sandboxManager,
|
||||
toolRegistry: this.toolRegistry,
|
||||
@@ -255,9 +256,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
agentToolRegistry.sortTools();
|
||||
|
||||
// Get the parent prompt ID from context
|
||||
const parentPromptId = context.promptId;
|
||||
|
||||
// Get the parent tool call ID from context
|
||||
const toolContext = getToolCallContext();
|
||||
const parentCallId = toolContext?.callId;
|
||||
@@ -265,7 +263,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
return new LocalAgentExecutor(
|
||||
definition,
|
||||
context,
|
||||
parentPromptId,
|
||||
agentToolRegistry,
|
||||
agentPromptRegistry,
|
||||
agentResourceRegistry,
|
||||
@@ -283,7 +280,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
private constructor(
|
||||
definition: LocalAgentDefinition<TOutput>,
|
||||
context: AgentLoopContext,
|
||||
parentPromptId: string | undefined,
|
||||
toolRegistry: ToolRegistry,
|
||||
promptRegistry: PromptRegistry,
|
||||
resourceRegistry: ResourceRegistry,
|
||||
@@ -299,11 +295,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.parentCallId = parentCallId;
|
||||
|
||||
const randomIdPart = Math.random().toString(36).slice(2, 8);
|
||||
// parentPromptId will be undefined if this agent is invoked directly
|
||||
// (top-level), rather than as a sub-agent.
|
||||
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
|
||||
this.agentId = `${parentPrefix}${this.definition.name}-${randomIdPart}`;
|
||||
this.agentId = Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ export class ProjectIdRequiredError extends Error {
|
||||
super(
|
||||
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
|
||||
);
|
||||
this.name = 'ProjectIdRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export class ProjectIdRequiredError extends Error {
|
||||
export class ValidationCancelledError extends Error {
|
||||
constructor() {
|
||||
super('User cancelled account validation');
|
||||
this.name = 'ValidationCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ export class IneligibleTierError extends Error {
|
||||
constructor(ineligibleTiers: IneligibleTier[]) {
|
||||
const reasons = ineligibleTiers.map((t) => t.reasonMessage).join(', ');
|
||||
super(reasons);
|
||||
this.name = 'IneligibleTierError';
|
||||
this.ineligibleTiers = ineligibleTiers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface AgentLoopContext {
|
||||
/** The unique ID for the current user turn or agent thought loop. */
|
||||
readonly promptId: string;
|
||||
|
||||
/** The unique ID for the parent session if this is a subagent. */
|
||||
readonly parentSessionId?: string;
|
||||
|
||||
/** The registry of tools available to the agent in this context. */
|
||||
readonly toolRegistry: ToolRegistry;
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => {
|
||||
ToolRegistryMock.prototype.sortTools = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
|
||||
ToolRegistryMock.prototype.getTool = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
|
||||
return { ToolRegistry: ToolRegistryMock };
|
||||
});
|
||||
@@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.getSandboxNetworkAccess()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have independent TopicState across instances', () => {
|
||||
const config1 = new Config(baseParams);
|
||||
const config2 = new Config(baseParams);
|
||||
|
||||
config1.topicState.setTopic('Topic 1');
|
||||
config2.topicState.setTopic('Topic 2');
|
||||
|
||||
expect(config1.topicState.getTopic()).toBe('Topic 1');
|
||||
expect(config2.topicState.getTopic()).toBe('Topic 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GemmaModelRouterSettings', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
@@ -681,6 +682,11 @@ export interface ConfigParameters {
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
experimentalAgentHistorySummarization?: boolean;
|
||||
memoryBoundaryMarkers?: string[];
|
||||
topicUpdateNarration?: boolean;
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
@@ -723,6 +729,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private clientVersion: string;
|
||||
private fileSystemService: FileSystemService;
|
||||
private trackerService?: TrackerService;
|
||||
readonly topicState = new TopicState();
|
||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||
private contentGenerator!: ContentGenerator;
|
||||
readonly modelConfigService: ModelConfigService;
|
||||
@@ -909,6 +916,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalAgentHistoryTruncation: boolean;
|
||||
private readonly experimentalAgentHistoryTruncationThreshold: number;
|
||||
private readonly experimentalAgentHistoryRetainedMessages: number;
|
||||
private readonly experimentalAgentHistorySummarization: boolean;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
@@ -944,7 +956,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
networkAccess: false,
|
||||
};
|
||||
|
||||
this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir);
|
||||
this._sandboxManager = createSandboxManager(this.sandbox, {
|
||||
workspace: params.targetDir,
|
||||
});
|
||||
|
||||
if (
|
||||
!(this._sandboxManager instanceof NoopSandboxManager) &&
|
||||
@@ -965,8 +979,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
'default';
|
||||
this._sandboxManager = createSandboxManager(
|
||||
this.sandbox,
|
||||
params.targetDir,
|
||||
this._sandboxPolicyManager,
|
||||
{
|
||||
workspace: params.targetDir,
|
||||
policyManager: this._sandboxPolicyManager,
|
||||
},
|
||||
initialApprovalMode,
|
||||
);
|
||||
|
||||
@@ -1114,6 +1130,15 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? true;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalAgentHistoryTruncation =
|
||||
params.experimentalAgentHistoryTruncation ?? false;
|
||||
this.experimentalAgentHistoryTruncationThreshold =
|
||||
params.experimentalAgentHistoryTruncationThreshold ?? 30;
|
||||
this.experimentalAgentHistoryRetainedMessages =
|
||||
params.experimentalAgentHistoryRetainedMessages ?? 15;
|
||||
this.experimentalAgentHistorySummarization =
|
||||
params.experimentalAgentHistorySummarization ?? false;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.topicUpdateNarration = params.topicUpdateNarration ?? false;
|
||||
this.modelSteering = params.modelSteering ?? false;
|
||||
this.injectionService = new InjectionService(() =>
|
||||
@@ -1618,8 +1643,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private refreshSandboxManager(): void {
|
||||
this._sandboxManager = createSandboxManager(
|
||||
this.sandbox,
|
||||
this.targetDir,
|
||||
this._sandboxPolicyManager,
|
||||
{
|
||||
workspace: this.targetDir,
|
||||
policyManager: this._sandboxPolicyManager,
|
||||
},
|
||||
this.getApprovalMode(),
|
||||
);
|
||||
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
|
||||
@@ -2288,10 +2315,30 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalJitContext;
|
||||
}
|
||||
|
||||
getMemoryBoundaryMarkers(): readonly string[] {
|
||||
return this.memoryBoundaryMarkers;
|
||||
}
|
||||
|
||||
isMemoryManagerEnabled(): boolean {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
isExperimentalAgentHistoryTruncationEnabled(): boolean {
|
||||
return this.experimentalAgentHistoryTruncation;
|
||||
}
|
||||
|
||||
getExperimentalAgentHistoryTruncationThreshold(): number {
|
||||
return this.experimentalAgentHistoryTruncationThreshold;
|
||||
}
|
||||
|
||||
getExperimentalAgentHistoryRetainedMessages(): number {
|
||||
return this.experimentalAgentHistoryRetainedMessages;
|
||||
}
|
||||
|
||||
isExperimentalAgentHistorySummarizationEnabled(): boolean {
|
||||
return this.experimentalAgentHistorySummarization;
|
||||
}
|
||||
|
||||
isTopicUpdateNarrationEnabled(): boolean {
|
||||
return this.topicUpdateNarration;
|
||||
}
|
||||
@@ -3309,6 +3356,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
};
|
||||
|
||||
maybeRegister(UpdateTopicTool, () =>
|
||||
registry.registerTool(new UpdateTopicTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
maybeRegister(LSTool, () =>
|
||||
registry.registerTool(new LSTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
@@ -243,6 +243,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-3-pro-preview',
|
||||
},
|
||||
},
|
||||
'agent-history-provider-summarizer': {
|
||||
modelConfig: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -279,6 +279,16 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getActiveModel: vi.fn().mockReturnValue('test-model'),
|
||||
setActiveModel: vi.fn(),
|
||||
resetTurn: vi.fn(),
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
getExperimentalAgentHistoryTruncationThreshold: vi
|
||||
.fn()
|
||||
.mockReturnValue(30),
|
||||
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(15),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
getModelAvailabilityService: vi
|
||||
.fn()
|
||||
.mockReturnValue(createAvailabilityServiceMock()),
|
||||
@@ -704,6 +714,43 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
it('calls AgentHistoryProvider.manageHistory when history truncation is enabled', async () => {
|
||||
// Arrange
|
||||
mockConfig.isExperimentalAgentHistoryTruncationEnabled = vi
|
||||
.fn()
|
||||
.mockReturnValue(true);
|
||||
const manageHistorySpy = vi
|
||||
.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(client as any).agentHistoryProvider,
|
||||
'manageHistory',
|
||||
)
|
||||
.mockResolvedValue([
|
||||
{ role: 'user', parts: [{ text: 'preserved message' }] },
|
||||
]);
|
||||
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'content', value: 'Hello' };
|
||||
})(),
|
||||
);
|
||||
|
||||
// Act
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
'prompt-id-1',
|
||||
);
|
||||
|
||||
await fromAsync(stream);
|
||||
|
||||
// Assert
|
||||
expect(manageHistorySpy).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a compression event when the context was automatically compressed', async () => {
|
||||
// Arrange
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { AgentHistoryProvider } from '../services/agentHistoryProvider.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import {
|
||||
logContentRetryFailure,
|
||||
@@ -98,6 +99,7 @@ export class GeminiClient {
|
||||
|
||||
private readonly loopDetector: LoopDetectionService;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private readonly agentHistoryProvider: AgentHistoryProvider;
|
||||
private readonly toolOutputMaskingService: ToolOutputMaskingService;
|
||||
private lastPromptId: string;
|
||||
private currentSequenceModel: string | null = null;
|
||||
@@ -113,6 +115,12 @@ export class GeminiClient {
|
||||
constructor(private readonly context: AgentLoopContext) {
|
||||
this.loopDetector = new LoopDetectionService(this.config);
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.agentHistoryProvider = new AgentHistoryProvider(this.config, {
|
||||
truncationThreshold:
|
||||
this.config.getExperimentalAgentHistoryTruncationThreshold(),
|
||||
retainedMessages:
|
||||
this.config.getExperimentalAgentHistoryRetainedMessages(),
|
||||
});
|
||||
this.toolOutputMaskingService = new ToolOutputMaskingService();
|
||||
this.lastPromptId = this.config.getSessionId();
|
||||
|
||||
@@ -613,10 +621,20 @@ export class GeminiClient {
|
||||
// Check for context window overflow
|
||||
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
||||
|
||||
const compressed = await this.tryCompressChat(prompt_id, false, signal);
|
||||
if (this.config.isExperimentalAgentHistoryTruncationEnabled()) {
|
||||
const newHistory = await this.agentHistoryProvider.manageHistory(
|
||||
this.getHistory(),
|
||||
signal,
|
||||
);
|
||||
if (newHistory.length !== this.getHistory().length) {
|
||||
this.getChat().setHistory(newHistory);
|
||||
}
|
||||
} else {
|
||||
const compressed = await this.tryCompressChat(prompt_id, false, signal);
|
||||
|
||||
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||
}
|
||||
}
|
||||
|
||||
const remainingTokenCount =
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
AuthType,
|
||||
createContentGeneratorConfig,
|
||||
type ContentGenerator,
|
||||
validateBaseUrl,
|
||||
} from './contentGenerator.js';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
@@ -605,122 +604,6 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass GOOGLE_GEMINI_BASE_URL as httpOptions.baseUrl for Gemini API', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://my-gemini-proxy.example.com');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://my-gemini-proxy.example.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass GOOGLE_VERTEX_BASE_URL as httpOptions.baseUrl for Vertex AI', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://my-vertex-proxy.example.com');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://my-vertex-proxy.example.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include baseUrl in httpOptions when GOOGLE_GEMINI_BASE_URL is not set', async () => {
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', '');
|
||||
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an insecure GOOGLE_GEMINI_BASE_URL for non-local hosts', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'http://evil-proxy.example.com');
|
||||
|
||||
await expect(
|
||||
createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
},
|
||||
mockConfig,
|
||||
),
|
||||
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
|
||||
});
|
||||
|
||||
it('should pass apiVersion for Vertex AI when GOOGLE_GENAI_API_VERSION is set', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
@@ -861,33 +744,3 @@ describe('createContentGeneratorConfig', () => {
|
||||
expect(config.vertexai).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBaseUrl', () => {
|
||||
it('should accept a valid HTTPS URL', () => {
|
||||
expect(() => validateBaseUrl('https://my-proxy.example.com')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept HTTP for localhost', () => {
|
||||
expect(() => validateBaseUrl('http://localhost:8080')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept HTTP for 127.0.0.1', () => {
|
||||
expect(() => validateBaseUrl('http://127.0.0.1:3000')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept HTTP for ::1', () => {
|
||||
expect(() => validateBaseUrl('http://[::1]:8080')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject HTTP for non-local hosts', () => {
|
||||
expect(() => validateBaseUrl('http://my-proxy.example.com')).toThrow(
|
||||
'Custom base URL must use HTTPS unless it is localhost.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid URL', () => {
|
||||
expect(() => validateBaseUrl('not-a-url')).toThrow(
|
||||
'Invalid custom base URL: not-a-url',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,25 +273,13 @@ export async function createContentGenerator(
|
||||
'x-gemini-api-privileged-user-id': `${installationId}`,
|
||||
};
|
||||
}
|
||||
let baseUrl = config.baseUrl;
|
||||
if (!baseUrl) {
|
||||
const envBaseUrl = config.vertexai
|
||||
? process.env['GOOGLE_VERTEX_BASE_URL']
|
||||
: process.env['GOOGLE_GEMINI_BASE_URL'];
|
||||
if (envBaseUrl) {
|
||||
validateBaseUrl(envBaseUrl);
|
||||
baseUrl = envBaseUrl;
|
||||
}
|
||||
} else {
|
||||
validateBaseUrl(baseUrl);
|
||||
}
|
||||
const httpOptions: {
|
||||
baseUrl?: string;
|
||||
headers: Record<string, string>;
|
||||
} = { headers };
|
||||
|
||||
if (baseUrl) {
|
||||
httpOptions.baseUrl = baseUrl;
|
||||
if (config.baseUrl) {
|
||||
httpOptions.baseUrl = config.baseUrl;
|
||||
}
|
||||
|
||||
const googleGenAI = new GoogleGenAI({
|
||||
@@ -313,17 +301,3 @@ export async function createContentGenerator(
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
|
||||
|
||||
export function validateBaseUrl(baseUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid custom base URL: ${baseUrl}`);
|
||||
}
|
||||
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
|
||||
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ describe('Core System Prompt Substitution', () => {
|
||||
getSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
isModelSteeringEnabled: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export * from './core/geminiRequest.js';
|
||||
export * from './scheduler/scheduler.js';
|
||||
export * from './scheduler/types.js';
|
||||
export * from './scheduler/tool-executor.js';
|
||||
export * from './scheduler/policy.js';
|
||||
export * from './core/recordingContentGenerator.js';
|
||||
|
||||
export * from './fallback/types.js';
|
||||
@@ -83,6 +84,7 @@ export * from './utils/authConsent.js';
|
||||
export * from './utils/googleQuotaErrors.js';
|
||||
export * from './utils/googleErrors.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/sessionOperations.js';
|
||||
export * from './utils/planUtils.js';
|
||||
export * from './utils/approvalModeUtils.js';
|
||||
export * from './utils/fileDiffUtils.js';
|
||||
@@ -184,6 +186,8 @@ export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
|
||||
// Export browser session management
|
||||
export { resetBrowserSession } from './agents/browser/browserAgentFactory.js';
|
||||
// Export agent session interface
|
||||
export * from './agent/agent-session.js';
|
||||
export * from './agent/legacy-agent-session.js';
|
||||
|
||||
@@ -113,6 +113,13 @@ decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
[[rule]]
|
||||
toolName = "update_topic"
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = ["ask_user", "save_memory"]
|
||||
decision = "ask_user"
|
||||
|
||||
@@ -55,4 +55,10 @@ priority = 50
|
||||
[[rule]]
|
||||
toolName = ["codebase_investigator", "cli_help", "get_internal_docs"]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
[[rule]]
|
||||
toolName = "update_topic"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
@@ -7,13 +7,14 @@ allowOverrides = false
|
||||
[modes.default]
|
||||
network = false
|
||||
readonly = true
|
||||
approvedTools = []
|
||||
approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo']
|
||||
allowOverrides = true
|
||||
|
||||
[modes.accepting_edits]
|
||||
network = false
|
||||
readonly = false
|
||||
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
|
||||
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo', 'Add-Content', 'Set-Content']
|
||||
allowOverrides = true
|
||||
|
||||
[commands]
|
||||
|
||||
|
||||
@@ -375,6 +375,7 @@ describe('PolicyEngine', () => {
|
||||
isKnownSafeCommand: vi
|
||||
.fn()
|
||||
.mockImplementation((args) => args[0] === 'npm'),
|
||||
parseDenials: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as SandboxManager;
|
||||
|
||||
engine = new PolicyEngine({
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadPoliciesFromToml } from './toml-loader.js';
|
||||
import { PolicyEngine } from './policy-engine.js';
|
||||
import { ApprovalMode, PolicyDecision } from './types.js';
|
||||
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
|
||||
describe('Topic Tool Policy', () => {
|
||||
async function loadDefaultPolicies() {
|
||||
// Path relative to packages/core root
|
||||
const policiesDir = path.resolve(process.cwd(), 'src/policy/policies');
|
||||
const getPolicyTier = () => 1; // Default tier
|
||||
const result = await loadPoliciesFromToml([policiesDir], getPolicyTier);
|
||||
return result.rules;
|
||||
}
|
||||
|
||||
it('should allow update_topic in DEFAULT mode', async () => {
|
||||
const rules = await loadDefaultPolicies();
|
||||
const engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: UPDATE_TOPIC_TOOL_NAME },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should allow update_topic in PLAN mode', async () => {
|
||||
const rules = await loadDefaultPolicies();
|
||||
const engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: UPDATE_TOPIC_TOOL_NAME },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should allow update_topic in YOLO mode', async () => {
|
||||
const rules = await loadDefaultPolicies();
|
||||
const engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{ name: UPDATE_TOPIC_TOOL_NAME },
|
||||
undefined,
|
||||
);
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { PREVIEW_GEMINI_MODEL } from '../config/models.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { TopicState } from '../tools/topicTool.js';
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
@@ -53,6 +55,7 @@ describe('PromptProvider', () => {
|
||||
).getToolRegistry?.() as unknown as ToolRegistry;
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
topicState: new TopicState(),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
@@ -73,6 +76,8 @@ describe('PromptProvider', () => {
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
getApprovalMode: vi.fn(),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -234,4 +239,67 @@ describe('PromptProvider', () => {
|
||||
expect(prompt).not.toContain('### APPROVED PLAN PRESERVATION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Topic & Update Narration', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.topicState.reset();
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
|
||||
(mockConfig.getToolRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getAllToolNames: vi.fn().mockReturnValue([UPDATE_TOPIC_TOOL_NAME]),
|
||||
getAllTools: vi.fn().mockReturnValue([
|
||||
new MockTool({
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
displayName: 'Topic',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(mockConfig.getHasAccessToPreviewModel).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getGemini31LaunchedSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should include active topic context when narration is enabled', () => {
|
||||
mockConfig.topicState.setTopic('Active Chapter');
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain('[Active Topic: Active Chapter]');
|
||||
});
|
||||
|
||||
it('should NOT include active topic context when narration is disabled', () => {
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
mockConfig.topicState.setTopic('Active Chapter');
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).not.toContain('[Active Topic: Active Chapter]');
|
||||
});
|
||||
|
||||
it('should filter out update_topic tool when narration is disabled', () => {
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(
|
||||
false,
|
||||
);
|
||||
// Simulate registry behavior where it filters out update_topic
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue(
|
||||
[],
|
||||
);
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue([]);
|
||||
|
||||
const provider = new PromptProvider();
|
||||
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
expect(prompt).not.toContain(UPDATE_TOPIC_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should NOT filter out update_topic tool when narration is enabled', () => {
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.isTopicUpdateNarrationEnabled).mockReturnValue(true);
|
||||
const provider = new PromptProvider();
|
||||
const prompt = provider.getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).toContain(`<tool>\`${UPDATE_TOPIC_TOOL_NAME}\`</tool>`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ export class PromptProvider {
|
||||
const skills = context.config.getSkillManager().getSkills();
|
||||
const toolNames = context.toolRegistry.getAllToolNames();
|
||||
const enabledToolNames = new Set(toolNames);
|
||||
|
||||
const approvedPlanPath = context.config.getApprovedPlanPath();
|
||||
|
||||
const desiredModel = resolveModel(
|
||||
@@ -71,7 +72,6 @@ export class PromptProvider {
|
||||
const activeSnippets = isModernModel ? snippets : legacySnippets;
|
||||
const contextFilenames = getAllGeminiMdFilenames();
|
||||
|
||||
// --- Context Gathering ---
|
||||
let planModeToolsList = '';
|
||||
if (isPlanMode) {
|
||||
const allTools = context.toolRegistry.getAllTools();
|
||||
@@ -232,7 +232,18 @@ export class PromptProvider {
|
||||
);
|
||||
|
||||
// Sanitize erratic newlines from composition
|
||||
const sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
|
||||
let sanitizedPrompt = finalPrompt.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Context Reinjection (Active Topic)
|
||||
if (context.config.isTopicUpdateNarrationEnabled()) {
|
||||
const activeTopic = context.config.topicState.getTopic();
|
||||
if (activeTopic) {
|
||||
const sanitizedTopic = activeTopic
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/\]/g, '');
|
||||
sanitizedPrompt += `\n\n[Active Topic: ${sanitizedTopic}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file if requested
|
||||
this.maybeWriteSystemMd(
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
EDIT_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
@@ -230,6 +233,7 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
|
||||
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
|
||||
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
|
||||
- **Multi-line File Creation:** ALWAYS use the ${formatToolName(WRITE_FILE_TOOL_NAME)} tool for creating or overwriting files with multiple lines of content. DO NOT use ${formatToolName(SHELL_TOOL_NAME)} with \`cat << 'EOF'\` or similar heredoc patterns, as they are prone to shell parsing errors and internal buffer limits.
|
||||
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
|
||||
- **Proactiveness:** When executing a Directive, persist through errors and obstacles by diagnosing failures in the execution phase and, if necessary, backtracking to the research or strategy phases to adjust your approach until a successful, verified outcome is achieved. Fulfill the user's request thoroughly, including adding tests when adding features or fixing bugs. Take reasonable liberties to fulfill broad goals while staying within the requested scope; however, prioritize simplicity and the removal of redundant logic over providing "just-in-case" alternatives that diverge from the established path.
|
||||
- **Testing:** ALWAYS search for and update related tests after making a code change. You must add a new test case to the existing test file (if one exists) or create a new test file to verify your changes.${mandateConflictResolution(options.hasHierarchicalMemory)}
|
||||
@@ -239,7 +243,9 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
? mandateTopicUpdateModel()
|
||||
: mandateExplainBeforeActing()
|
||||
}
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(options.hasSkills)}${mandateContinueWork(options.interactive)}
|
||||
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandateSkillGuidance(
|
||||
options.hasSkills,
|
||||
)}${mandateContinueWork(options.interactive)}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
@@ -361,7 +367,7 @@ export function renderOperationalGuidelines(
|
||||
- **Role:** A senior software engineer and collaborative peer programmer.
|
||||
- **High-Signal Output:** Focus exclusively on **intent** and **technical rationale**. Avoid conversational filler, apologies, and ${
|
||||
options.topicUpdateNarration
|
||||
? 'per-tool explanations.'
|
||||
? 'unnecessary per-tool explanations.'
|
||||
: 'mechanical tool-use narration (e.g., "I will now call...").'
|
||||
}
|
||||
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
|
||||
@@ -614,46 +620,19 @@ function mandateConfirm(interactive: boolean): string {
|
||||
|
||||
function mandateTopicUpdateModel(): string {
|
||||
return `
|
||||
- **Protocol: Topic Model**
|
||||
You are an agentic system. You must maintain a visible state log that tracks broad logical phases using a specific header format.
|
||||
## Topic Updates
|
||||
As you work, the user follows along by reading topic updates that you publish with ${UPDATE_TOPIC_TOOL_NAME}. Keep them informed by doing the following:
|
||||
|
||||
- **1. Topic Initialization & Persistence:**
|
||||
- **The Trigger:** You MUST issue a \`Topic: <Phase> : <Brief Summary>\` header ONLY when beginning a task or when the broad logical nature of the task changes (e.g., transitioning from research to implementation).
|
||||
- **The Format:** Use exactly \`Topic: <Phase> : <Brief Summary>\` (e.g., \`Topic: <Research> : Researching Agent Skills in the repo\`).
|
||||
- **Persistence:** Once a Topic is declared, do NOT repeat it for subsequent tool calls or in subsequent messages within that same phase.
|
||||
- **Start of Task:** Your very first tool execution must be preceded by a Topic header.
|
||||
- Always call ${UPDATE_TOPIC_TOOL_NAME} in your first and last turn. The final turn should always recap what was done.
|
||||
- Each topic update should give a concise description of what you are doing for the next few turns in the \`${TOPIC_PARAM_SUMMARY}\` parameter.
|
||||
- Provide topic updates whenever you change "topics". A topic is typically a discrete subgoal and will be every 3 to 10 turns. Do not use ${UPDATE_TOPIC_TOOL_NAME} on every turn.
|
||||
- The typical user message should call ${UPDATE_TOPIC_TOOL_NAME} 3 or more times. Each corresponds to a distinct phase of the task, such as "Researching X", "Researching Y", "Implementing Z with X", and "Testing Z".
|
||||
- Remember to call ${UPDATE_TOPIC_TOOL_NAME} when you experience an unexpected event (e.g., a test failure, compilation error, environment issue, or unexpected learning) that requires a strategic detour.
|
||||
- **Examples:**
|
||||
- \`update_topic(${TOPIC_PARAM_TITLE}="Researching Parser", ${TOPIC_PARAM_SUMMARY}="I am starting an investigation into the parser timeout bug. My goal is to first understand the current test coverage and then attempt to reproduce the failure. This phase will focus on identifying the bottleneck in the main loop before we move to implementation.")\`
|
||||
- \`update_topic(${TOPIC_PARAM_TITLE}="Implementing Buffer Fix", ${TOPIC_PARAM_SUMMARY}="I have completed the research phase and identified a race condition in the tokenizer's buffer management. I am now transitioning to implementation. This new chapter will focus on refactoring the buffer logic to handle async chunks safely, followed by unit testing the fix.")\`
|
||||
|
||||
- **2. Tool Execution Protocol (Zero-Noise):**
|
||||
- **No Per-Tool Headers:** It is a violation of protocol to print "Topic:" before every tool call.
|
||||
- **Silent Mode:** No conversational filler, no "I will now...", and no summaries between tools.
|
||||
- Only the Topic header at the start of a broad phase is permitted to break the silence. Everything in between must be silent.
|
||||
|
||||
- **3. Thinking Protocol:**
|
||||
- Use internal thought blocks to keep track of what tools you have called, plan your next steps, and reason about the task.
|
||||
- Without reasoning and tracking in thought blocks, you may lose context.
|
||||
- Always use the required syntax for thought blocks to ensure they remain hidden from the user interface.
|
||||
|
||||
- **4. Completion:**
|
||||
- Only when the entire task is finalized do you provide a **Final Summary**.
|
||||
|
||||
**IMPORTANT: Topic Headers vs. Thoughts**
|
||||
The \`Topic: <Phase> : <Brief Summary>\` header must **NOT** be placed inside a thought block. It must be standard text output so that it is properly rendered and displayed in the UI.
|
||||
|
||||
**Correct State Log Example:**
|
||||
\`\`\`
|
||||
Topic: <Research> : Researching Agent Skills in the repo
|
||||
<tool_call 1>
|
||||
<tool_call 2>
|
||||
<tool_call 3>
|
||||
|
||||
Topic: <Implementation> : Implementing the skill-creator logic
|
||||
<tool_call 1>
|
||||
<tool_call 2>
|
||||
|
||||
The task is complete. [Final Summary]
|
||||
\`\`\`
|
||||
|
||||
- **Constraint Enforcement:** If you repeat a "Topic:" line without a fundamental shift in work, or if you provide a Topic for every tool call, you have failed the system integrity protocol.`;
|
||||
`;
|
||||
}
|
||||
|
||||
function mandateExplainBeforeActing(): string {
|
||||
|
||||
@@ -362,16 +362,21 @@ describe('LinuxSandboxManager', () => {
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
|
||||
},
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
const cacheIndex = bwrapArgs.indexOf('/tmp/cache');
|
||||
expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs');
|
||||
|
||||
@@ -389,16 +394,21 @@ describe('LinuxSandboxManager', () => {
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: ['/tmp/forbidden-symlink'],
|
||||
},
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
forbiddenPaths: ['/tmp/forbidden-symlink'],
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
|
||||
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
|
||||
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
|
||||
@@ -412,16 +422,21 @@ describe('LinuxSandboxManager', () => {
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: ['/tmp/not-here.txt'],
|
||||
},
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
forbiddenPaths: ['/tmp/not-here.txt'],
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
|
||||
expect(bwrapArgs[idx - 2]).toBe('--symlink');
|
||||
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
|
||||
@@ -436,16 +451,21 @@ describe('LinuxSandboxManager', () => {
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: ['/tmp/dir-link'],
|
||||
},
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
forbiddenPaths: ['/tmp/dir-link'],
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
const idx = bwrapArgs.indexOf('/opt/real-dir');
|
||||
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
|
||||
});
|
||||
@@ -456,17 +476,24 @@ describe('LinuxSandboxManager', () => {
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
const bwrapArgs = await getBwrapArgs({
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: ['/tmp/conflict'],
|
||||
forbiddenPaths: ['/tmp/conflict'],
|
||||
},
|
||||
const customManager = new LinuxSandboxManager({
|
||||
workspace,
|
||||
forbiddenPaths: ['/tmp/conflict'],
|
||||
});
|
||||
|
||||
const bwrapArgs = await getBwrapArgs(
|
||||
{
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: workspace,
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: ['/tmp/conflict'],
|
||||
},
|
||||
},
|
||||
customManager,
|
||||
);
|
||||
|
||||
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
|
||||
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
|
||||
|
||||
|
||||
@@ -16,14 +16,15 @@ import {
|
||||
GOVERNANCE_FILES,
|
||||
getSecretFileFindArgs,
|
||||
sanitizePaths,
|
||||
type ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -131,20 +133,10 @@ function touch(filePath: string, isDirectory: boolean) {
|
||||
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
||||
*/
|
||||
|
||||
export interface LinuxSandboxOptions extends GlobalSandboxOptions {
|
||||
modeConfig?: {
|
||||
readonly?: boolean;
|
||||
network?: boolean;
|
||||
approvedTools?: string[];
|
||||
allowOverrides?: boolean;
|
||||
};
|
||||
policyManager?: SandboxPolicyManager;
|
||||
}
|
||||
|
||||
export class LinuxSandboxManager implements SandboxManager {
|
||||
private static maskFilePath: string | undefined;
|
||||
|
||||
constructor(private readonly options: LinuxSandboxOptions) {}
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
return isKnownSafeCommand(args);
|
||||
@@ -154,6 +146,10 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result);
|
||||
}
|
||||
|
||||
private getMaskFilePath(): string {
|
||||
if (
|
||||
LinuxSandboxManager.maskFilePath &&
|
||||
@@ -191,7 +187,7 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
: false;
|
||||
const workspaceWrite = !isReadonlyMode || isApproved;
|
||||
const networkAccess =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
||||
|
||||
const persistentPermissions = allowOverrides
|
||||
? this.options.policyManager?.getCommandPermissions(commandName)
|
||||
@@ -326,7 +322,7 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
}
|
||||
}
|
||||
|
||||
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
|
||||
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
|
||||
for (const p of forbiddenPaths) {
|
||||
let resolved: string;
|
||||
try {
|
||||
|
||||
@@ -35,7 +35,10 @@ describe('MacOsSandboxManager', () => {
|
||||
networkAccess: mockNetworkAccess,
|
||||
};
|
||||
|
||||
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: [],
|
||||
});
|
||||
|
||||
// Mock the seatbelt args builder to isolate manager tests
|
||||
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([
|
||||
@@ -68,7 +71,7 @@ describe('MacOsSandboxManager', () => {
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
forbiddenPaths: undefined,
|
||||
forbiddenPaths: [],
|
||||
workspaceWrite: true,
|
||||
additionalPermissions: {
|
||||
fileSystem: {
|
||||
@@ -177,15 +180,16 @@ describe('MacOsSandboxManager', () => {
|
||||
|
||||
describe('forbiddenPaths', () => {
|
||||
it('should parameterize forbidden paths and explicitly deny them', async () => {
|
||||
await manager.prepareCommand({
|
||||
const managerWithForbidden = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: ['/tmp/forbidden1'],
|
||||
});
|
||||
await managerWithForbidden.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
forbiddenPaths: ['/tmp/forbidden1'],
|
||||
},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
|
||||
@@ -196,15 +200,16 @@ describe('MacOsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
|
||||
await manager.prepareCommand({
|
||||
const managerWithForbidden = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: ['/tmp/does-not-exist'],
|
||||
});
|
||||
await managerWithForbidden.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
forbiddenPaths: ['/tmp/does-not-exist'],
|
||||
},
|
||||
policy: mockPolicy,
|
||||
});
|
||||
|
||||
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
|
||||
@@ -215,7 +220,11 @@ describe('MacOsSandboxManager', () => {
|
||||
});
|
||||
|
||||
it('should override allowed paths if a path is also in forbidden paths', async () => {
|
||||
await manager.prepareCommand({
|
||||
const managerWithForbidden = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
forbiddenPaths: ['/tmp/conflict'],
|
||||
});
|
||||
await managerWithForbidden.prepareCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
cwd: mockWorkspace,
|
||||
@@ -223,7 +232,6 @@ describe('MacOsSandboxManager', () => {
|
||||
policy: {
|
||||
...mockPolicy,
|
||||
allowedPaths: ['/tmp/conflict'],
|
||||
forbiddenPaths: ['/tmp/conflict'],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
type SandboxedCommand,
|
||||
type SandboxPermissions,
|
||||
type GlobalSandboxOptions,
|
||||
type ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
@@ -25,26 +27,14 @@ import {
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
} from '../utils/commandSafety.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
|
||||
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
|
||||
/** The current sandbox mode behavior from config. */
|
||||
modeConfig?: {
|
||||
readonly?: boolean;
|
||||
network?: boolean;
|
||||
approvedTools?: string[];
|
||||
allowOverrides?: boolean;
|
||||
};
|
||||
/** The policy manager for persistent approvals. */
|
||||
policyManager?: SandboxPolicyManager;
|
||||
}
|
||||
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for macOS that uses Seatbelt.
|
||||
*/
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
constructor(private readonly options: MacOsSandboxOptions) {}
|
||||
constructor(private readonly options: GlobalSandboxOptions) {}
|
||||
|
||||
isKnownSafeCommand(args: string[]): boolean {
|
||||
const toolName = args[0];
|
||||
@@ -59,6 +49,10 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parsePosixSandboxDenials(result);
|
||||
}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
await initializeShellParsers();
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
@@ -84,7 +78,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
|
||||
const workspaceWrite = !isReadonlyMode || isApproved;
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
||||
|
||||
// Fetch persistent approvals for this command
|
||||
const commandName = await getCommandName(req.command, req.args);
|
||||
@@ -114,7 +108,7 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
const sandboxArgs = buildSeatbeltArgs({
|
||||
workspace: this.options.workspace,
|
||||
allowedPaths: [...(req.policy?.allowedPaths || [])],
|
||||
forbiddenPaths: req.policy?.forbiddenPaths,
|
||||
forbiddenPaths: this.options.forbiddenPaths,
|
||||
networkAccess: mergedAdditional.network,
|
||||
workspaceWrite,
|
||||
additionalPermissions: mergedAdditional,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePosixSandboxDenials } from './sandboxDenialUtils.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
describe('parsePosixSandboxDenials', () => {
|
||||
it('should detect file system denial and extract paths', () => {
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output: 'ls: /root: Operation not permitted',
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('/root');
|
||||
});
|
||||
|
||||
it('should detect network denial', () => {
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output: 'curl: (6) Could not resolve host: google.com',
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.network).toBe(true);
|
||||
});
|
||||
|
||||
it('should use fallback heuristic for absolute paths', () => {
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output:
|
||||
'operation not permitted\nsome error happened with /some/path/to/file',
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('/some/path/to/file');
|
||||
});
|
||||
|
||||
it('should return undefined if no denial detected', () => {
|
||||
const parsed = parsePosixSandboxDenials({
|
||||
output: 'hello world',
|
||||
} as unknown as ShellExecutionResult);
|
||||
expect(parsed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
/**
|
||||
* Common POSIX-style sandbox denial detection.
|
||||
* Used by macOS and Linux sandbox managers.
|
||||
*/
|
||||
export function parsePosixSandboxDenials(
|
||||
result: ShellExecutionResult,
|
||||
): ParsedSandboxDenial | undefined {
|
||||
const output = result.output || '';
|
||||
const errorOutput = result.error?.message;
|
||||
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
|
||||
|
||||
const isFileDenial = [
|
||||
'operation not permitted',
|
||||
'vim:e303',
|
||||
'should be read/write',
|
||||
'sandbox_apply',
|
||||
'sandbox: ',
|
||||
].some((keyword) => combined.includes(keyword));
|
||||
|
||||
const isNetworkDenial = [
|
||||
'error connecting to',
|
||||
'network is unreachable',
|
||||
'could not resolve host',
|
||||
'connection refused',
|
||||
'no address associated with hostname',
|
||||
].some((keyword) => combined.includes(keyword));
|
||||
|
||||
if (!isFileDenial && !isNetworkDenial) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
// Extract denied paths (POSIX absolute paths)
|
||||
const regex =
|
||||
/(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(output)) !== null) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
if (errorOutput) {
|
||||
while ((match = regex.exec(errorOutput)) !== null) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback heuristic: look for any absolute path in the output if it was a file denial
|
||||
if (isFileDenial && filePaths.size === 0) {
|
||||
const fallbackRegex =
|
||||
/(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi;
|
||||
let m;
|
||||
while ((m = fallbackRegex.exec(output)) !== null) {
|
||||
const p = m[1];
|
||||
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
|
||||
filePaths.add(p);
|
||||
}
|
||||
}
|
||||
if (errorOutput) {
|
||||
while ((m = fallbackRegex.exec(errorOutput)) !== null) {
|
||||
const p = m[1];
|
||||
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
|
||||
filePaths.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
network: isNetworkDenial || undefined,
|
||||
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,13 @@ public class GeminiSandbox {
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct JOBOBJECT_NET_RATE_CONTROL_INFORMATION {
|
||||
public ulong MaxBandwidth;
|
||||
public uint ControlFlags;
|
||||
public byte DscpTag;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
||||
|
||||
@@ -70,6 +77,9 @@ public class GeminiSandbox {
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
|
||||
|
||||
@@ -143,6 +153,8 @@ public class GeminiSandbox {
|
||||
|
||||
private const int TokenIntegrityLevel = 25;
|
||||
private const uint SE_GROUP_INTEGRITY = 0x00000020;
|
||||
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
|
||||
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
|
||||
|
||||
static int Main(string[] args) {
|
||||
if (args.Length < 3) {
|
||||
@@ -182,14 +194,14 @@ public class GeminiSandbox {
|
||||
IntPtr lowIntegritySid = IntPtr.Zero;
|
||||
|
||||
try {
|
||||
// 1. Create Restricted Token
|
||||
if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) {
|
||||
// 1. Duplicate Primary Token
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) {
|
||||
Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Flags: 0x1 (DISABLE_MAX_PRIVILEGE)
|
||||
if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
|
||||
// Create a restricted token to strip administrative privileges
|
||||
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
|
||||
Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
|
||||
return 1;
|
||||
}
|
||||
@@ -223,6 +235,18 @@ public class GeminiSandbox {
|
||||
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
|
||||
Marshal.FreeHGlobal(lpJobLimits);
|
||||
|
||||
if (!networkAccess) {
|
||||
JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION();
|
||||
netLimits.MaxBandwidth = 1;
|
||||
netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH
|
||||
netLimits.DscpTag = 0;
|
||||
|
||||
IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits));
|
||||
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
|
||||
SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits));
|
||||
Marshal.FreeHGlobal(lpNetLimits);
|
||||
}
|
||||
|
||||
// 4. Handle Internal Commands or External Process
|
||||
if (command == "__read") {
|
||||
if (argIndex + 1 >= args.Length) {
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('WindowsSandboxManager', () => {
|
||||
manager = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { readonly: false, allowOverrides: true },
|
||||
forbiddenPaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +107,7 @@ describe('WindowsSandboxManager', () => {
|
||||
const planManager = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
modeConfig: { readonly: true, allowOverrides: false },
|
||||
forbiddenPaths: [],
|
||||
});
|
||||
const req: SandboxRequest = {
|
||||
command: 'curl',
|
||||
@@ -135,6 +137,7 @@ describe('WindowsSandboxManager', () => {
|
||||
workspace: testCwd,
|
||||
modeConfig: { allowOverrides: true, network: false },
|
||||
policyManager: mockPolicyManager,
|
||||
forbiddenPaths: [],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
@@ -155,7 +158,7 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
persistentPath,
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -224,13 +227,13 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(testCwd),
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(allowedPath),
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(allowedPath, { recursive: true, force: true });
|
||||
@@ -270,7 +273,7 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
path.resolve(extraWritePath),
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(extraWritePath, { recursive: true, force: true });
|
||||
@@ -305,7 +308,7 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(icaclsArgs).not.toContainEqual([
|
||||
uncPath,
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -340,12 +343,12 @@ describe('WindowsSandboxManager', () => {
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
longPath,
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
expect(icaclsArgs).toContainEqual([
|
||||
devicePath,
|
||||
'/setintegritylevel',
|
||||
'Low',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -362,17 +365,19 @@ describe('WindowsSandboxManager', () => {
|
||||
fs.rmSync(missingPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const managerWithForbidden = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
forbiddenPaths: [missingPath],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: [missingPath],
|
||||
},
|
||||
};
|
||||
|
||||
await manager.prepareCommand(req);
|
||||
await managerWithForbidden.prepareCommand(req);
|
||||
|
||||
// Should NOT have called icacls to deny the missing path
|
||||
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
|
||||
@@ -388,17 +393,19 @@ describe('WindowsSandboxManager', () => {
|
||||
fs.mkdirSync(forbiddenPath);
|
||||
}
|
||||
try {
|
||||
const managerWithForbidden = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
forbiddenPaths: [forbiddenPath],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: testCwd,
|
||||
env: {},
|
||||
policy: {
|
||||
forbiddenPaths: [forbiddenPath],
|
||||
},
|
||||
};
|
||||
|
||||
await manager.prepareCommand(req);
|
||||
await managerWithForbidden.prepareCommand(req);
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
|
||||
path.resolve(forbiddenPath),
|
||||
@@ -416,6 +423,11 @@ describe('WindowsSandboxManager', () => {
|
||||
fs.mkdirSync(conflictPath);
|
||||
}
|
||||
try {
|
||||
const managerWithForbidden = new WindowsSandboxManager({
|
||||
workspace: testCwd,
|
||||
forbiddenPaths: [conflictPath],
|
||||
});
|
||||
|
||||
const req: SandboxRequest = {
|
||||
command: 'test',
|
||||
args: [],
|
||||
@@ -423,11 +435,10 @@ describe('WindowsSandboxManager', () => {
|
||||
env: {},
|
||||
policy: {
|
||||
allowedPaths: [conflictPath],
|
||||
forbiddenPaths: [conflictPath],
|
||||
},
|
||||
};
|
||||
|
||||
await manager.prepareCommand(req);
|
||||
await managerWithForbidden.prepareCommand(req);
|
||||
|
||||
const spawnMock = vi.mocked(spawnAsync);
|
||||
const allowCallIndex = spawnMock.mock.calls.findIndex(
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
sanitizePaths,
|
||||
tryRealpath,
|
||||
type SandboxPermissions,
|
||||
type ParsedSandboxDenial,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
@@ -31,24 +33,12 @@ import {
|
||||
isDangerousCommand,
|
||||
isStrictlyApproved,
|
||||
} from './commandSafety.js';
|
||||
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
|
||||
import { verifySandboxOverrides } from '../utils/commandUtils.js';
|
||||
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export interface WindowsSandboxOptions extends GlobalSandboxOptions {
|
||||
/** The current sandbox mode behavior from config. */
|
||||
modeConfig?: {
|
||||
readonly?: boolean;
|
||||
network?: boolean;
|
||||
approvedTools?: string[];
|
||||
allowOverrides?: boolean;
|
||||
};
|
||||
/** The policy manager for persistent approvals. */
|
||||
policyManager?: SandboxPolicyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for Windows that uses Restricted Tokens,
|
||||
* Job Objects, and Low Integrity levels for process isolation.
|
||||
@@ -60,7 +50,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
private readonly allowedCache = new Set<string>();
|
||||
private readonly deniedCache = new Set<string>();
|
||||
|
||||
constructor(private readonly options: WindowsSandboxOptions) {
|
||||
constructor(private readonly options: GlobalSandboxOptions) {
|
||||
this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe');
|
||||
}
|
||||
|
||||
@@ -77,6 +67,10 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
return isDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
||||
return parseWindowsSandboxDenials(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a file or directory exists.
|
||||
*/
|
||||
@@ -242,6 +236,10 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
false,
|
||||
};
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
// 1. Handle filesystem permissions for Low Integrity
|
||||
// Grant "Low Mandatory Level" write access to the workspace.
|
||||
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
|
||||
@@ -257,7 +255,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
await this.grantLowIntegrityAccess(this.options.workspace);
|
||||
}
|
||||
|
||||
// Grant "Low Mandatory Level" read access to allowedPaths.
|
||||
// Grant "Low Mandatory Level" read/write access to allowedPaths.
|
||||
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
|
||||
for (const allowedPath of allowedPaths) {
|
||||
await this.grantLowIntegrityAccess(allowedPath);
|
||||
@@ -303,7 +301,7 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
// is restricted to avoid host corruption. External commands rely on
|
||||
// Low Integrity read/write restrictions, while internal commands
|
||||
// use the manifest for enforcement.
|
||||
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
|
||||
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
|
||||
for (const forbiddenPath of forbiddenPaths) {
|
||||
try {
|
||||
await this.denyLowIntegrityAccess(forbiddenPath);
|
||||
@@ -348,10 +346,6 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
|
||||
const program = this.helperPath;
|
||||
|
||||
const defaultNetwork =
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
const args = [
|
||||
networkAccess ? '1' : '0',
|
||||
req.cwd,
|
||||
@@ -401,7 +395,11 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
|
||||
await spawnAsync('icacls', [
|
||||
resolvedPath,
|
||||
'/setintegritylevel',
|
||||
'(OI)(CI)Low',
|
||||
]);
|
||||
this.allowedCache.add(resolvedPath);
|
||||
} catch (e) {
|
||||
debugLogger.log(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
describe('parseWindowsSandboxDenials', () => {
|
||||
it('should detect CMD "Access is denied" and extract paths', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output: 'Access is denied.\r\n',
|
||||
error: new Error('Command failed: dir C:\\Windows\\System32\\config'),
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('C:\\Windows\\System32\\config');
|
||||
});
|
||||
|
||||
it('should detect PowerShell "Access to the path is denied"', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output:
|
||||
"Set-Content : Access to the path 'C:\\test.txt' is denied.\r\nAt line:1 char:1\r\n",
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('C:\\test.txt');
|
||||
});
|
||||
|
||||
it('should detect Node.js EPERM on Windows', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
error: {
|
||||
message:
|
||||
"Error: EPERM: operation not permitted, open 'D:\\project\\file.ts'",
|
||||
},
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('D:\\project\\file.ts');
|
||||
});
|
||||
|
||||
it('should detect network denial (EACCES)', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output: 'Error: listen EACCES: permission denied 0.0.0.0:3000',
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.network).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect native Windows error code 0x80070005', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output: 'HRESULT: 0x80070005',
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
// No path in output, but recognized as denial
|
||||
});
|
||||
|
||||
it('should handle extended-length paths', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output: 'Access is denied to \\\\?\\C:\\Very\\Long\\Path\\file.txt',
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain(
|
||||
'\\\\?\\C:\\Very\\Long\\Path\\file.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect Windows paths with forward slashes', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output:
|
||||
"Error: EPERM: operation not permitted, open 'C:/project/file.ts'",
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed?.filePaths).toContain('C:/project/file.ts');
|
||||
});
|
||||
|
||||
it('should return undefined if no denial detected', () => {
|
||||
const parsed = parseWindowsSandboxDenials({
|
||||
output:
|
||||
'Directory of C:\\Users\r\n03/26/2026 11:40 AM <DIR> .',
|
||||
} as unknown as ShellExecutionResult);
|
||||
|
||||
expect(parsed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
|
||||
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
||||
|
||||
/**
|
||||
* Windows-specific sandbox denial detection.
|
||||
* Extracts paths from "Access is denied" and related errors.
|
||||
*/
|
||||
export function parseWindowsSandboxDenials(
|
||||
result: ShellExecutionResult,
|
||||
): ParsedSandboxDenial | undefined {
|
||||
const output = result.output || '';
|
||||
const errorOutput = result.error?.message;
|
||||
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
|
||||
|
||||
const isFileDenial = [
|
||||
'access is denied',
|
||||
'access to the path',
|
||||
'unauthorizedaccessexception',
|
||||
'0x80070005',
|
||||
'eperm: operation not permitted',
|
||||
].some((keyword) => combined.includes(keyword));
|
||||
|
||||
const isNetworkDenial = [
|
||||
'eacces: permission denied',
|
||||
'an attempt was made to access a socket in a way forbidden by its access permissions',
|
||||
// 10013 is WSAEACCES
|
||||
'10013',
|
||||
].some((keyword) => combined.includes(keyword));
|
||||
|
||||
if (!isFileDenial && !isNetworkDenial) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
// Regex for Windows absolute paths (e.g., C:\Path or \\?\C:\Path)
|
||||
// Handles drive letters and potentially quoted paths.
|
||||
// We use two passes: one for quoted paths (which can contain spaces)
|
||||
// and one for unquoted paths (which end at common separators).
|
||||
|
||||
// 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar"
|
||||
const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g;
|
||||
for (const match of output.matchAll(quotedRegex)) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
if (errorOutput) {
|
||||
for (const match of errorOutput.matchAll(quotedRegex)) {
|
||||
filePaths.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String)
|
||||
const generalRegex =
|
||||
/(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g;
|
||||
for (const match of output.matchAll(generalRegex)) {
|
||||
// Clean up trailing colon which might be part of the error message rather than the path
|
||||
let p = match[1];
|
||||
if (p.endsWith(':')) p = p.slice(0, -1);
|
||||
filePaths.add(p);
|
||||
}
|
||||
if (errorOutput) {
|
||||
for (const match of errorOutput.matchAll(generalRegex)) {
|
||||
let p = match[1];
|
||||
if (p.endsWith(':')) p = p.slice(0, -1);
|
||||
filePaths.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
network: isNetworkDenial || undefined,
|
||||
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
ROOT_SCHEDULER_ID,
|
||||
@@ -441,6 +442,44 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should sort UPDATE_TOPIC_TOOL_NAME to the front of the batch', async () => {
|
||||
const topicReq: ToolCallRequestInfo = {
|
||||
callId: 'call-topic',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: { title: 'New Chapter' },
|
||||
prompt_id: 'p1',
|
||||
isClientInitiated: false,
|
||||
};
|
||||
const otherReq: ToolCallRequestInfo = {
|
||||
callId: 'call-other',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
prompt_id: 'p1',
|
||||
isClientInitiated: false,
|
||||
};
|
||||
|
||||
// Mock tool registry to return a tool for update_topic
|
||||
vi.mocked(mockToolRegistry.getTool).mockImplementation((name) => {
|
||||
if (name === UPDATE_TOPIC_TOOL_NAME) {
|
||||
return {
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
build: vi.fn().mockReturnValue({}),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
}
|
||||
return mockTool;
|
||||
});
|
||||
|
||||
// Schedule in reverse order (other first, topic second)
|
||||
await scheduler.schedule([otherReq, topicReq], signal);
|
||||
|
||||
// Verify they were enqueued in the correct sorted order (topic first)
|
||||
const enqueueCalls = vi.mocked(mockStateManager.enqueue).mock.calls;
|
||||
const lastCall = enqueueCalls[enqueueCalls.length - 1][0];
|
||||
|
||||
expect(lastCall[0].request.callId).toBe('call-topic');
|
||||
expect(lastCall[1].request.callId).toBe('call-other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2: Queue Management', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ScheduledToolCall,
|
||||
} from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
|
||||
import {
|
||||
ToolConfirmationOutcome,
|
||||
@@ -302,9 +303,16 @@ export class Scheduler {
|
||||
this.state.clearBatch();
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
|
||||
// Sort requests to ensure Topic changes happen before actions in the same batch.
|
||||
const sortedRequests = [...requests].sort((a, b) => {
|
||||
if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1;
|
||||
if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
try {
|
||||
const toolRegistry = this.context.toolRegistry;
|
||||
const newCalls: ToolCall[] = requests.map((request) => {
|
||||
const newCalls: ToolCall[] = sortedRequests.map((request) => {
|
||||
const enrichedRequest: ToolCallRequestInfo = {
|
||||
...request,
|
||||
schedulerId: this.schedulerId,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AgentHistoryProvider > should handle summarizer failures gracefully 1`] = `
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]
|
||||
|
||||
Message 18",
|
||||
},
|
||||
{
|
||||
"text": "Message 20",
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentHistoryProvider } from './agentHistoryProvider.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
|
||||
describe('AgentHistoryProvider', () => {
|
||||
let config: Config;
|
||||
let provider: AgentHistoryProvider;
|
||||
let generateContentMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
isExperimentalAgentHistoryTruncationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
isExperimentalAgentHistorySummarizationEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValue(false),
|
||||
getBaseLlmClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
generateContentMock = vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'Mock intent summary' }] } }],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
config.getBaseLlmClient = vi.fn().mockReturnValue({
|
||||
generateContent: generateContentMock,
|
||||
} as unknown as BaseLlmClient);
|
||||
|
||||
provider = new AgentHistoryProvider(config, {
|
||||
truncationThreshold: 30,
|
||||
retainedMessages: 15,
|
||||
});
|
||||
});
|
||||
|
||||
const createMockHistory = (count: number): Content[] =>
|
||||
Array.from({ length: count }).map((_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'model',
|
||||
parts: [{ text: `Message ${i}` }],
|
||||
}));
|
||||
|
||||
it('should return history unchanged if truncation is disabled', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(false);
|
||||
|
||||
const history = createMockHistory(40);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(result).toBe(history);
|
||||
expect(result.length).toBe(40);
|
||||
});
|
||||
|
||||
it('should return history unchanged if length is under threshold', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
|
||||
const history = createMockHistory(20); // Threshold is 30
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(result).toBe(history);
|
||||
expect(result.length).toBe(20);
|
||||
});
|
||||
|
||||
it('should truncate mechanically to RETAINED_MESSAGES without summarization when sum flag is off', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(false);
|
||||
|
||||
const history = createMockHistory(35); // Above 30 threshold, should truncate to 15
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(result.length).toBe(15);
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
|
||||
// Check fallback message logic
|
||||
// Messages 20 to 34 are retained. Message 20 is 'user'.
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'System Note: Prior conversation history was truncated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call summarizer and prepend summary when summarization is enabled', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(true);
|
||||
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15); // retained messages
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].parts![0].text).toContain('<intent_summary>');
|
||||
expect(result[0].parts![0].text).toContain('Mock intent summary');
|
||||
});
|
||||
|
||||
it('should handle summarizer failures gracefully', async () => {
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistoryTruncationEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(
|
||||
config,
|
||||
'isExperimentalAgentHistorySummarizationEnabled',
|
||||
).mockReturnValue(true);
|
||||
|
||||
generateContentMock.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15);
|
||||
expect(result[0]).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { LlmRole } from '../telemetry/llmRole.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface AgentHistoryProviderConfig {
|
||||
truncationThreshold: number;
|
||||
retainedMessages: number;
|
||||
}
|
||||
|
||||
export class AgentHistoryProvider {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly providerConfig: AgentHistoryProviderConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evaluates the chat history and performs truncation and summarization if necessary.
|
||||
* Returns a new array of Content if truncation occurred, otherwise returns the original array.
|
||||
*/
|
||||
async manageHistory(
|
||||
history: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<readonly Content[]> {
|
||||
if (!this.shouldTruncate(history)) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const { messagesToKeep, messagesToTruncate } =
|
||||
this.splitHistoryForTruncation(history);
|
||||
|
||||
debugLogger.log(
|
||||
`AgentHistoryProvider: Truncating ${messagesToTruncate.length} messages, retaining ${messagesToKeep.length} messages.`,
|
||||
);
|
||||
|
||||
const summaryText = await this.getSummaryText(
|
||||
messagesToTruncate,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
return this.mergeSummaryWithHistory(summaryText, messagesToKeep);
|
||||
}
|
||||
|
||||
private shouldTruncate(history: readonly Content[]): boolean {
|
||||
if (!this.config.isExperimentalAgentHistoryTruncationEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return history.length > this.providerConfig.truncationThreshold;
|
||||
}
|
||||
|
||||
private splitHistoryForTruncation(history: readonly Content[]): {
|
||||
messagesToKeep: readonly Content[];
|
||||
messagesToTruncate: readonly Content[];
|
||||
} {
|
||||
return {
|
||||
messagesToKeep: history.slice(-this.providerConfig.retainedMessages),
|
||||
messagesToTruncate: history.slice(
|
||||
0,
|
||||
history.length - this.providerConfig.retainedMessages,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private getFallbackSummaryText(
|
||||
messagesToTruncate: readonly Content[],
|
||||
): string {
|
||||
const defaultNote =
|
||||
'System Note: Prior conversation history was truncated to maintain performance and focus. Important context should have been saved to memory.';
|
||||
|
||||
let lastUserText = '';
|
||||
for (let i = messagesToTruncate.length - 1; i >= 0; i--) {
|
||||
const msg = messagesToTruncate[i];
|
||||
if (msg.role === 'user') {
|
||||
lastUserText =
|
||||
msg.parts
|
||||
?.map((p) => p.text || '')
|
||||
.join('')
|
||||
.trim() || '';
|
||||
if (lastUserText) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserText) {
|
||||
return `[System Note: Prior conversation history was truncated. The most recent user message before truncation was:]\n\n${lastUserText}`;
|
||||
}
|
||||
|
||||
return defaultNote;
|
||||
}
|
||||
|
||||
private async getSummaryText(
|
||||
messagesToTruncate: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (!this.config.isExperimentalAgentHistorySummarizationEnabled()) {
|
||||
debugLogger.log(
|
||||
'AgentHistoryProvider: Summarization disabled, using fallback note.',
|
||||
);
|
||||
return this.getFallbackSummaryText(messagesToTruncate);
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await this.generateIntentSummary(
|
||||
messagesToTruncate,
|
||||
abortSignal,
|
||||
);
|
||||
debugLogger.log('AgentHistoryProvider: Summarization successful.');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
debugLogger.log('AgentHistoryProvider: Summarization failed.', error);
|
||||
return this.getFallbackSummaryText(messagesToTruncate);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSummaryWithHistory(
|
||||
summaryText: string,
|
||||
messagesToKeep: readonly Content[],
|
||||
): readonly Content[] {
|
||||
if (messagesToKeep.length === 0) {
|
||||
return [{ role: 'user', parts: [{ text: summaryText }] }];
|
||||
}
|
||||
|
||||
// To ensure strict user/model alternating roles required by the Gemini API,
|
||||
// we merge the summary into the first retained message if it's from the 'user'.
|
||||
const firstRetainedMessage = messagesToKeep[0];
|
||||
if (firstRetainedMessage.role === 'user') {
|
||||
const mergedParts = [
|
||||
{ text: summaryText },
|
||||
...(firstRetainedMessage.parts || []),
|
||||
];
|
||||
const mergedMessage: Content = {
|
||||
role: 'user',
|
||||
parts: mergedParts,
|
||||
};
|
||||
return [mergedMessage, ...messagesToKeep.slice(1)];
|
||||
} else {
|
||||
const summaryMessage: Content = {
|
||||
role: 'user',
|
||||
parts: [{ text: summaryText }],
|
||||
};
|
||||
return [summaryMessage, ...messagesToKeep];
|
||||
}
|
||||
}
|
||||
|
||||
private async generateIntentSummary(
|
||||
messagesToTruncate: readonly Content[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const prompt = `Create a succinct, agent-continuity focused intent summary of the truncated conversation history.
|
||||
Distill the essence of the ongoing work by capturing:
|
||||
- The Original Mandate: What the user (or calling agent) originally requested and why.
|
||||
- The Agent's Strategy: How you (the agent) are approaching the task and where the work is taking place (e.g., specific files, directories, or architectural layers).
|
||||
- Evolving Context: Any significant shifts in the user's intent or the agent's technical approach over the course of the truncated history.
|
||||
|
||||
Write this summary to orient the active agent. Do NOT predict next steps or summarize the current task state, as those are covered by the active history. Focus purely on foundational context and strategic continuity.`;
|
||||
|
||||
const summaryResponse = await this.config
|
||||
.getBaseLlmClient()
|
||||
.generateContent({
|
||||
modelConfigKey: { model: 'agent-history-provider-summarizer' },
|
||||
contents: [
|
||||
...messagesToTruncate,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
promptId: 'agent-history-provider',
|
||||
abortSignal: abortSignal ?? new AbortController().signal,
|
||||
role: LlmRole.UTILITY_COMPRESSOR,
|
||||
});
|
||||
|
||||
let summary = getResponseText(summaryResponse) ?? '';
|
||||
summary = summary.replace(/<\/?intent_summary>/g, '').trim();
|
||||
return `<intent_summary>\n${summary}\n</intent_summary>`;
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,30 @@ describe('ChatRecordingService', () => {
|
||||
expect(conversation.kind).toBe('subagent');
|
||||
});
|
||||
|
||||
it('should create a subdirectory for subagents if parentSessionId is present', () => {
|
||||
const parentSessionId = 'test-parent-uuid';
|
||||
Object.defineProperty(mockConfig, 'parentSessionId', {
|
||||
value: parentSessionId,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
chatRecordingService.initialize(undefined, 'subagent');
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
model: 'm',
|
||||
});
|
||||
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
const subagentDir = path.join(chatsDir, parentSessionId);
|
||||
expect(fs.existsSync(subagentDir)).toBe(true);
|
||||
|
||||
const files = fs.readdirSync(subagentDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toBe('test-session-id.json');
|
||||
});
|
||||
|
||||
it('should resume from an existing session if provided', () => {
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
@@ -437,7 +461,7 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
|
||||
it('should delete the session file, tool outputs, session directory, and logs if they exist', async () => {
|
||||
const sessionId = 'test-session-id';
|
||||
const shortId = '12345678';
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
@@ -464,7 +488,7 @@ describe('ChatRecordingService', () => {
|
||||
fs.mkdirSync(toolOutputDir, { recursive: true });
|
||||
|
||||
// Call with shortId
|
||||
chatRecordingService.deleteSession(shortId);
|
||||
await chatRecordingService.deleteSession(shortId);
|
||||
|
||||
expect(fs.existsSync(sessionFile)).toBe(false);
|
||||
expect(fs.existsSync(logFile)).toBe(false);
|
||||
@@ -472,7 +496,7 @@ describe('ChatRecordingService', () => {
|
||||
expect(fs.existsSync(sessionDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should delete subagent files and their logs when parent is deleted', () => {
|
||||
it('should delete subagent files and their logs when parent is deleted', async () => {
|
||||
const parentSessionId = '12345678-session-id';
|
||||
const shortId = '12345678';
|
||||
const subagentSessionId = 'subagent-session-id';
|
||||
@@ -494,11 +518,10 @@ describe('ChatRecordingService', () => {
|
||||
JSON.stringify({ sessionId: parentSessionId }),
|
||||
);
|
||||
|
||||
// Create subagent session file
|
||||
const subagentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-01-${shortId}.json`,
|
||||
);
|
||||
// Create subagent session file in subdirectory
|
||||
const subagentDir = path.join(chatsDir, parentSessionId);
|
||||
fs.mkdirSync(subagentDir, { recursive: true });
|
||||
const subagentFile = path.join(subagentDir, `${subagentSessionId}.json`);
|
||||
fs.writeFileSync(
|
||||
subagentFile,
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
|
||||
@@ -526,17 +549,55 @@ describe('ChatRecordingService', () => {
|
||||
fs.mkdirSync(subagentToolOutputDir, { recursive: true });
|
||||
|
||||
// Call with parent sessionId
|
||||
chatRecordingService.deleteSession(parentSessionId);
|
||||
await chatRecordingService.deleteSession(parentSessionId);
|
||||
|
||||
expect(fs.existsSync(parentFile)).toBe(false);
|
||||
expect(fs.existsSync(subagentFile)).toBe(false);
|
||||
expect(fs.existsSync(subagentDir)).toBe(false); // Subagent directory should be deleted
|
||||
expect(fs.existsSync(parentLog)).toBe(false);
|
||||
expect(fs.existsSync(subagentLog)).toBe(false);
|
||||
expect(fs.existsSync(parentToolOutputDir)).toBe(false);
|
||||
expect(fs.existsSync(subagentToolOutputDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should delete by basename', () => {
|
||||
it('should delete subagent files and their logs when parent is deleted (legacy flat structure)', async () => {
|
||||
const parentSessionId = '12345678-session-id';
|
||||
const shortId = '12345678';
|
||||
const subagentSessionId = 'subagent-session-id';
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
const logsDir = path.join(testTempDir, 'logs');
|
||||
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
// Create parent session file
|
||||
const parentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-00-${shortId}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
parentFile,
|
||||
JSON.stringify({ sessionId: parentSessionId }),
|
||||
);
|
||||
|
||||
// Create legacy subagent session file (flat in chatsDir)
|
||||
const subagentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-01-${shortId}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
subagentFile,
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
|
||||
);
|
||||
|
||||
// Call with parent sessionId
|
||||
await chatRecordingService.deleteSession(parentSessionId);
|
||||
|
||||
expect(fs.existsSync(parentFile)).toBe(false);
|
||||
expect(fs.existsSync(subagentFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should delete by basename', async () => {
|
||||
const sessionId = 'test-session-id';
|
||||
const shortId = '12345678';
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
@@ -553,16 +614,16 @@ describe('ChatRecordingService', () => {
|
||||
fs.writeFileSync(logFile, '{}');
|
||||
|
||||
// Call with basename
|
||||
chatRecordingService.deleteSession(basename);
|
||||
await chatRecordingService.deleteSession(basename);
|
||||
|
||||
expect(fs.existsSync(sessionFile)).toBe(false);
|
||||
expect(fs.existsSync(logFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw if session file does not exist', () => {
|
||||
expect(() =>
|
||||
it('should not throw if session file does not exist', async () => {
|
||||
await expect(
|
||||
chatRecordingService.deleteSession('non-existent'),
|
||||
).not.toThrow();
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
import { type Status } from '../scheduler/types.js';
|
||||
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
|
||||
import { getProjectHash } from '../utils/paths.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
import {
|
||||
deleteSessionArtifactsAsync,
|
||||
deleteSubagentSessionDirAndArtifactsAsync,
|
||||
} from '../utils/sessionOperations.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
Content,
|
||||
@@ -172,20 +176,46 @@ export class ChatRecordingService {
|
||||
} else {
|
||||
// Create new session
|
||||
this.sessionId = this.context.promptId;
|
||||
const chatsDir = path.join(
|
||||
let chatsDir = path.join(
|
||||
this.context.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
|
||||
// subagents are nested under the complete parent session id
|
||||
if (this.kind === 'subagent' && this.context.parentSessionId) {
|
||||
const safeParentId = sanitizeFilenamePart(
|
||||
this.context.parentSessionId,
|
||||
);
|
||||
if (!safeParentId) {
|
||||
throw new Error(
|
||||
`Invalid parentSessionId after sanitization: ${this.context.parentSessionId}`,
|
||||
);
|
||||
}
|
||||
chatsDir = path.join(chatsDir, safeParentId);
|
||||
}
|
||||
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${this.sessionId.slice(
|
||||
0,
|
||||
8,
|
||||
)}.json`;
|
||||
const safeSessionId = sanitizeFilenamePart(this.sessionId);
|
||||
if (!safeSessionId) {
|
||||
throw new Error(
|
||||
`Invalid sessionId after sanitization: ${this.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let filename: string;
|
||||
if (this.kind === 'subagent') {
|
||||
filename = `${safeSessionId}.json`;
|
||||
} else {
|
||||
filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice(
|
||||
0,
|
||||
8,
|
||||
)}.json`;
|
||||
}
|
||||
this.conversationFile = path.join(chatsDir, filename);
|
||||
|
||||
this.writeConversation({
|
||||
@@ -596,21 +626,22 @@ export class ChatRecordingService {
|
||||
*
|
||||
* @throws {Error} If shortId validation fails.
|
||||
*/
|
||||
deleteSession(sessionIdOrBasename: string): void {
|
||||
async deleteSession(sessionIdOrBasename: string): Promise<void> {
|
||||
try {
|
||||
const tempDir = this.context.config.storage.getProjectTempDir();
|
||||
const chatsDir = path.join(tempDir, 'chats');
|
||||
|
||||
const shortId = this.deriveShortId(sessionIdOrBasename);
|
||||
|
||||
if (!fs.existsSync(chatsDir)) {
|
||||
// Using stat instead of existsSync for async sanity
|
||||
if (!(await fs.promises.stat(chatsDir).catch(() => null))) {
|
||||
return; // Nothing to delete
|
||||
}
|
||||
|
||||
const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId);
|
||||
|
||||
for (const file of matchingFiles) {
|
||||
this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
|
||||
await this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error deleting session file.', error);
|
||||
@@ -654,14 +685,14 @@ export class ChatRecordingService {
|
||||
/**
|
||||
* Deletes a single session file and its associated logs, tool-outputs, and directory.
|
||||
*/
|
||||
private deleteSessionAndArtifacts(
|
||||
private async deleteSessionAndArtifacts(
|
||||
chatsDir: string,
|
||||
file: string,
|
||||
tempDir: string,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const fileContent = await fs.promises.readFile(filePath, 'utf8');
|
||||
const content = JSON.parse(fileContent) as unknown;
|
||||
|
||||
let fullSessionId: string | undefined;
|
||||
@@ -673,60 +704,22 @@ export class ChatRecordingService {
|
||||
}
|
||||
|
||||
// Delete the session file
|
||||
fs.unlinkSync(filePath);
|
||||
await fs.promises.unlink(filePath);
|
||||
|
||||
if (fullSessionId) {
|
||||
this.deleteSessionLogs(fullSessionId, tempDir);
|
||||
this.deleteSessionToolOutputs(fullSessionId, tempDir);
|
||||
this.deleteSessionDirectory(fullSessionId, tempDir);
|
||||
// Delegate to shared utility!
|
||||
await deleteSessionArtifactsAsync(fullSessionId, tempDir);
|
||||
await deleteSubagentSessionDirAndArtifactsAsync(
|
||||
fullSessionId,
|
||||
chatsDir,
|
||||
tempDir,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error deleting associated file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up activity logs for a session.
|
||||
*/
|
||||
private deleteSessionLogs(sessionId: string, tempDir: string): void {
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const logPath = path.join(logsDir, `session-${safeSessionId}.jsonl`);
|
||||
if (fs.existsSync(logPath) && logPath.startsWith(logsDir)) {
|
||||
fs.unlinkSync(logPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up tool outputs for a session.
|
||||
*/
|
||||
private deleteSessionToolOutputs(sessionId: string, tempDir: string): void {
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const toolOutputDir = path.join(
|
||||
tempDir,
|
||||
'tool-outputs',
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
const toolOutputsBase = path.join(tempDir, 'tool-outputs');
|
||||
if (
|
||||
fs.existsSync(toolOutputDir) &&
|
||||
toolOutputDir.startsWith(toolOutputsBase)
|
||||
) {
|
||||
fs.rmSync(toolOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the session-specific directory.
|
||||
*/
|
||||
private deleteSessionDirectory(sessionId: string, tempDir: string): void {
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const sessionDir = path.join(tempDir, safeSessionId);
|
||||
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewinds the conversation to the state just before the specified message ID.
|
||||
* All messages from (and including) the specified ID onwards are removed.
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('ContextManager', () => {
|
||||
getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'),
|
||||
}),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
} as unknown as Config;
|
||||
|
||||
contextManager = new ContextManager(mockConfig);
|
||||
@@ -81,12 +82,14 @@ describe('ContextManager', () => {
|
||||
await contextManager.refresh();
|
||||
|
||||
expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled();
|
||||
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith([
|
||||
'/app',
|
||||
]);
|
||||
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith(
|
||||
['/app'],
|
||||
['.git'],
|
||||
);
|
||||
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([...globalPaths, ...envPaths]),
|
||||
'tree',
|
||||
['.git'],
|
||||
);
|
||||
|
||||
expect(contextManager.getGlobalMemory()).toContain('Global Content');
|
||||
@@ -172,6 +175,7 @@ describe('ContextManager', () => {
|
||||
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
|
||||
['/home/user/.gemini/GEMINI.md', '/app/gemini.md'],
|
||||
'tree',
|
||||
['.git'],
|
||||
);
|
||||
expect(contextManager.getEnvironmentMemory()).toContain(
|
||||
'Project Content',
|
||||
@@ -197,6 +201,7 @@ describe('ContextManager', () => {
|
||||
['/app'],
|
||||
expect.any(Set),
|
||||
expect.any(Set),
|
||||
['.git'],
|
||||
);
|
||||
expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/);
|
||||
expect(result).toContain('Src Content');
|
||||
@@ -226,5 +231,25 @@ describe('ContextManager', () => {
|
||||
expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled();
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should pass custom boundary markers from config', async () => {
|
||||
const customMarkers = ['.monorepo-root', 'package.json'];
|
||||
vi.mocked(mockConfig.getMemoryBoundaryMarkers).mockReturnValue(
|
||||
customMarkers,
|
||||
);
|
||||
vi.mocked(memoryDiscovery.loadJitSubdirectoryMemory).mockResolvedValue({
|
||||
files: [],
|
||||
});
|
||||
|
||||
await contextManager.discoverContext('/app/src/file.ts', ['/app']);
|
||||
|
||||
expect(memoryDiscovery.loadJitSubdirectoryMemory).toHaveBeenCalledWith(
|
||||
'/app/src/file.ts',
|
||||
['/app'],
|
||||
expect.any(Set),
|
||||
expect.any(Set),
|
||||
customMarkers,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,10 @@ export class ContextManager {
|
||||
getExtensionMemoryPaths(this.config.getExtensionLoader()),
|
||||
),
|
||||
this.config.isTrustedFolder()
|
||||
? getEnvironmentMemoryPaths([
|
||||
...this.config.getWorkspaceContext().getDirectories(),
|
||||
])
|
||||
? getEnvironmentMemoryPaths(
|
||||
[...this.config.getWorkspaceContext().getDirectories()],
|
||||
this.config.getMemoryBoundaryMarkers(),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
@@ -76,6 +77,7 @@ export class ContextManager {
|
||||
const allContents = await readGeminiMdFiles(
|
||||
allPaths,
|
||||
this.config.getImportFormat(),
|
||||
this.config.getMemoryBoundaryMarkers(),
|
||||
);
|
||||
|
||||
const loadedFilePaths = allContents
|
||||
@@ -133,6 +135,7 @@ export class ContextManager {
|
||||
trustedRoots,
|
||||
this.loadedPaths,
|
||||
this.loadedFileIdentities,
|
||||
this.config.getMemoryBoundaryMarkers(),
|
||||
);
|
||||
|
||||
if (result.files.length === 0) {
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('FileDiscoveryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldGitIgnoreFile & shouldGeminiIgnoreFile', () => {
|
||||
describe('shouldIgnoreFile & shouldIgnoreDirectory', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(path.join(projectRoot, '.git'));
|
||||
await createTestFile('.gitignore', 'node_modules/');
|
||||
@@ -238,6 +238,13 @@ describe('FileDiscoveryService', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for git-ignored directories', () => {
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
expect(
|
||||
service.shouldIgnoreDirectory(path.join(projectRoot, 'node_modules')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-git-ignored files', () => {
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
|
||||
@@ -293,6 +300,7 @@ describe('FileDiscoveryService', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence (.geminiignore over .gitignore)', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(path.join(projectRoot, '.git'));
|
||||
@@ -495,4 +503,99 @@ describe('FileDiscoveryService', () => {
|
||||
expect(paths[0]).toBe(path.join(projectRoot, '.gitignore'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIgnoredPaths', () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(path.join(projectRoot, '.git'));
|
||||
});
|
||||
|
||||
it('should return all ignored paths that exist on disk', async () => {
|
||||
await createTestFile(
|
||||
'.gitignore',
|
||||
'ignored-dir/\nignored-file.txt\n*.log',
|
||||
);
|
||||
await createTestFile('ignored-dir/inside.txt');
|
||||
await createTestFile('ignored-file.txt');
|
||||
await createTestFile('keep.log');
|
||||
await createTestFile('src/index.ts');
|
||||
await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secrets/');
|
||||
await createTestFile('secrets/passwords.txt');
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
const ignoredPaths = await service.getIgnoredPaths();
|
||||
|
||||
const expectedPaths = [
|
||||
path.join(projectRoot, '.git'),
|
||||
path.join(projectRoot, 'ignored-dir'),
|
||||
path.join(projectRoot, 'ignored-file.txt'),
|
||||
path.join(projectRoot, 'keep.log'),
|
||||
path.join(projectRoot, 'secrets'),
|
||||
].sort();
|
||||
|
||||
expect(ignoredPaths.sort()).toEqual(expectedPaths);
|
||||
});
|
||||
|
||||
it('should optimize by not traversing into ignored directories', async () => {
|
||||
await createTestFile('.gitignore', 'ignored-dir/');
|
||||
const ignoredDir = path.join(projectRoot, 'ignored-dir');
|
||||
await fs.mkdir(ignoredDir);
|
||||
await createTestFile('ignored-dir/large-file-1.txt');
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
const ignoredPaths = await service.getIgnoredPaths();
|
||||
|
||||
expect(ignoredPaths.sort()).toEqual(
|
||||
[path.join(projectRoot, '.git'), ignoredDir].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle un-ignore patterns correctly', async () => {
|
||||
await createTestFile(
|
||||
'.gitignore',
|
||||
'ignored-dir/*\n!ignored-dir/keep.txt',
|
||||
);
|
||||
await createTestFile('ignored-dir/ignored.txt');
|
||||
await createTestFile('ignored-dir/keep.txt');
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
const ignoredPaths = await service.getIgnoredPaths();
|
||||
|
||||
expect(ignoredPaths).toContain(
|
||||
path.join(projectRoot, 'ignored-dir/ignored.txt'),
|
||||
);
|
||||
expect(ignoredPaths).not.toContain(
|
||||
path.join(projectRoot, 'ignored-dir/keep.txt'),
|
||||
);
|
||||
expect(ignoredPaths).not.toContain(path.join(projectRoot, 'ignored-dir'));
|
||||
});
|
||||
|
||||
it('should respect FilterFilesOptions when provided', async () => {
|
||||
await createTestFile('.gitignore', 'ignored-by-git.txt');
|
||||
await createTestFile(GEMINI_IGNORE_FILE_NAME, 'ignored-by-gemini.txt');
|
||||
await createTestFile('ignored-by-git.txt');
|
||||
await createTestFile('ignored-by-gemini.txt');
|
||||
|
||||
const service = new FileDiscoveryService(projectRoot);
|
||||
|
||||
const onlyGemini = await service.getIgnoredPaths({
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
});
|
||||
expect(onlyGemini).toContain(
|
||||
path.join(projectRoot, 'ignored-by-gemini.txt'),
|
||||
);
|
||||
expect(onlyGemini).not.toContain(
|
||||
path.join(projectRoot, 'ignored-by-git.txt'),
|
||||
);
|
||||
|
||||
const onlyGit = await service.getIgnoredPaths({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: false,
|
||||
});
|
||||
expect(onlyGit).toContain(path.join(projectRoot, 'ignored-by-git.txt'));
|
||||
expect(onlyGit).not.toContain(
|
||||
path.join(projectRoot, 'ignored-by-gemini.txt'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../utils/ignoreFileParser.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
@@ -83,6 +85,60 @@ export class FileDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all absolute paths (files and directories) within the project root that should be ignored.
|
||||
*/
|
||||
async getIgnoredPaths(options: FilterFilesOptions = {}): Promise<string[]> {
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
/**
|
||||
* Recursively walks the directory tree to find ignored paths.
|
||||
*/
|
||||
const walk = async (currentDir: string) => {
|
||||
let dirEntries: fs.Dirent[];
|
||||
try {
|
||||
dirEntries = await fs.promises.readdir(currentDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isNodeError(error) &&
|
||||
(error.code === 'EACCES' || error.code === 'ENOENT')
|
||||
) {
|
||||
// Stop if the directory is inaccessible or doesn't exist
|
||||
debugLogger.debug(
|
||||
`Skipping directory ${currentDir} due to ${error.code}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Traverse sibling directories concurrently to improve performance.
|
||||
await Promise.all(
|
||||
dirEntries.map(async (entry) => {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Optimization: If a directory is ignored, its contents are not traversed.
|
||||
if (this.shouldIgnoreDirectory(fullPath, options)) {
|
||||
ignoredPaths.push(fullPath);
|
||||
} else {
|
||||
await walk(fullPath);
|
||||
}
|
||||
} else {
|
||||
if (this.shouldIgnoreFile(fullPath, options)) {
|
||||
ignoredPaths.push(fullPath);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await walk(this.projectRoot);
|
||||
return ignoredPaths;
|
||||
}
|
||||
|
||||
private applyFilterFilesOptions(options?: FilterFilesOptions): void {
|
||||
if (!options) return;
|
||||
|
||||
@@ -100,34 +156,16 @@ export class FileDiscoveryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a list of file paths based on ignore rules
|
||||
* Filters a list of file paths based on ignore rules.
|
||||
*
|
||||
* NOTE: Directory paths must include a trailing slash to be correctly identified and
|
||||
* matched against directory-specific ignore patterns (e.g., 'dist/').
|
||||
*/
|
||||
filterFiles(filePaths: string[], options: FilterFilesOptions = {}): string[] {
|
||||
const {
|
||||
respectGitIgnore = this.defaultFilterFileOptions.respectGitIgnore,
|
||||
respectGeminiIgnore = this.defaultFilterFileOptions.respectGeminiIgnore,
|
||||
} = options;
|
||||
return filePaths.filter((filePath) => {
|
||||
if (
|
||||
respectGitIgnore &&
|
||||
respectGeminiIgnore &&
|
||||
this.combinedIgnoreFilter
|
||||
) {
|
||||
return !this.combinedIgnoreFilter.isIgnored(filePath);
|
||||
}
|
||||
|
||||
// Always respect custom ignore filter if provided
|
||||
if (this.customIgnoreFilter?.isIgnored(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (respectGitIgnore && this.gitIgnoreFilter?.isIgnored(filePath)) {
|
||||
return false;
|
||||
}
|
||||
if (respectGeminiIgnore && this.geminiIgnoreFilter?.isIgnored(filePath)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// Infer directory status from the string format
|
||||
const isDir = filePath.endsWith('/') || filePath.endsWith('\\');
|
||||
return !this._shouldIgnore(filePath, isDir, options);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,13 +190,61 @@ export class FileDiscoveryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified method to check if a file should be ignored based on filtering options
|
||||
* Checks if a specific file should be ignored based on project ignore rules.
|
||||
*/
|
||||
shouldIgnoreFile(
|
||||
filePath: string,
|
||||
options: FilterFilesOptions = {},
|
||||
): boolean {
|
||||
return this.filterFiles([filePath], options).length === 0;
|
||||
return this._shouldIgnore(filePath, false, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific directory should be ignored based on project ignore rules.
|
||||
*/
|
||||
shouldIgnoreDirectory(
|
||||
dirPath: string,
|
||||
options: FilterFilesOptions = {},
|
||||
): boolean {
|
||||
return this._shouldIgnore(dirPath, true, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal unified check for paths.
|
||||
*/
|
||||
private _shouldIgnore(
|
||||
filePath: string,
|
||||
isDirectory: boolean,
|
||||
options: FilterFilesOptions = {},
|
||||
): boolean {
|
||||
const {
|
||||
respectGitIgnore = this.defaultFilterFileOptions.respectGitIgnore,
|
||||
respectGeminiIgnore = this.defaultFilterFileOptions.respectGeminiIgnore,
|
||||
} = options;
|
||||
|
||||
if (respectGitIgnore && respectGeminiIgnore && this.combinedIgnoreFilter) {
|
||||
return this.combinedIgnoreFilter.isIgnored(filePath, isDirectory);
|
||||
}
|
||||
|
||||
if (this.customIgnoreFilter?.isIgnored(filePath, isDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
respectGitIgnore &&
|
||||
this.gitIgnoreFilter?.isIgnored(filePath, isDirectory)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
respectGeminiIgnore &&
|
||||
this.geminiIgnoreFilter?.isIgnored(filePath, isDirectory)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@ function ensureSandboxAvailable(): boolean {
|
||||
|
||||
describe('SandboxManager Integration', () => {
|
||||
const workspace = process.cwd();
|
||||
const manager = createSandboxManager({ enabled: true }, workspace);
|
||||
const manager = createSandboxManager({ enabled: true }, { workspace });
|
||||
|
||||
// Skip if we are on an unsupported platform or if it's a NoopSandboxManager
|
||||
const shouldSkip =
|
||||
@@ -235,7 +235,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] },
|
||||
);
|
||||
const { command, args } = Platform.touch(testFile);
|
||||
|
||||
@@ -244,7 +244,6 @@ describe('SandboxManager Integration', () => {
|
||||
args,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: { forbiddenPaths: [forbiddenDir] },
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
@@ -268,7 +267,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] },
|
||||
);
|
||||
const { command, args } = Platform.cat(nestedFile);
|
||||
|
||||
@@ -277,7 +276,6 @@ describe('SandboxManager Integration', () => {
|
||||
args,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: { forbiddenPaths: [forbiddenDir] },
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
@@ -298,7 +296,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [conflictDir] },
|
||||
);
|
||||
const { command, args } = Platform.touch(testFile);
|
||||
|
||||
@@ -309,7 +307,6 @@ describe('SandboxManager Integration', () => {
|
||||
env: process.env,
|
||||
policy: {
|
||||
allowedPaths: [conflictDir],
|
||||
forbiddenPaths: [conflictDir],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -329,7 +326,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [nonExistentPath] },
|
||||
);
|
||||
const { command, args } = Platform.echo('survived');
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
@@ -339,7 +336,6 @@ describe('SandboxManager Integration', () => {
|
||||
env: process.env,
|
||||
policy: {
|
||||
allowedPaths: [nonExistentPath],
|
||||
forbiddenPaths: [nonExistentPath],
|
||||
},
|
||||
});
|
||||
const result = await runCommand(sandboxed);
|
||||
@@ -362,7 +358,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [nonExistentFile] },
|
||||
);
|
||||
|
||||
// We use touch to attempt creation of the file
|
||||
@@ -374,7 +370,6 @@ describe('SandboxManager Integration', () => {
|
||||
args: argsTouch,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: { forbiddenPaths: [nonExistentFile] },
|
||||
});
|
||||
|
||||
// Execute the command, we expect it to fail (permission denied or read-only file system)
|
||||
@@ -402,7 +397,7 @@ describe('SandboxManager Integration', () => {
|
||||
try {
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
tempWorkspace,
|
||||
{ workspace: tempWorkspace, forbiddenPaths: [symlinkFile] },
|
||||
);
|
||||
|
||||
// Attempt to read the target file directly
|
||||
@@ -413,7 +408,6 @@ describe('SandboxManager Integration', () => {
|
||||
args: argsTarget,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
|
||||
});
|
||||
const resultTarget = await runCommand(commandTarget);
|
||||
expect(resultTarget.status).not.toBe(0);
|
||||
@@ -426,7 +420,6 @@ describe('SandboxManager Integration', () => {
|
||||
args: argsLink,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
|
||||
});
|
||||
const resultLink = await runCommand(commandLink);
|
||||
expect(resultLink.status).not.toBe(0);
|
||||
|
||||
@@ -10,7 +10,6 @@ import fsPromises from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
NoopSandboxManager,
|
||||
LocalSandboxManager,
|
||||
sanitizePaths,
|
||||
findSecretFiles,
|
||||
isSecretFile,
|
||||
@@ -364,38 +363,36 @@ describe('SandboxManager', () => {
|
||||
|
||||
describe('createSandboxManager', () => {
|
||||
it('should return NoopSandboxManager if sandboxing is disabled', () => {
|
||||
const manager = createSandboxManager({ enabled: false }, '/workspace');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: false },
|
||||
{ workspace: '/workspace' },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: 'linux', expected: LinuxSandboxManager },
|
||||
{ platform: 'darwin', expected: MacOsSandboxManager },
|
||||
{ platform: 'win32', expected: WindowsSandboxManager },
|
||||
] as const)(
|
||||
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
||||
({ platform, expected }) => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform);
|
||||
const manager = createSandboxManager({ enabled: true }, '/workspace');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: '/workspace' },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => {
|
||||
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true, command: 'windows-native' },
|
||||
'/workspace',
|
||||
{ enabled: true },
|
||||
{ workspace: '/workspace' },
|
||||
);
|
||||
expect(manager).toBeInstanceOf(WindowsSandboxManager);
|
||||
});
|
||||
|
||||
it('should return LocalSandboxManager on win32 if command is not windows-native', () => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
const manager = createSandboxManager(
|
||||
{ enabled: true, command: 'docker' as unknown as 'windows-native' },
|
||||
'/workspace',
|
||||
);
|
||||
expect(manager).toBeInstanceOf(LocalSandboxManager);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
|
||||
import type { ShellExecutionResult } from './shellExecutionService.js';
|
||||
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
export interface SandboxPermissions {
|
||||
/** Filesystem permissions. */
|
||||
fileSystem?: {
|
||||
@@ -40,8 +41,6 @@ export interface SandboxPermissions {
|
||||
export interface ExecutionPolicy {
|
||||
/** Additional absolute paths to grant full read/write access to. */
|
||||
allowedPaths?: string[];
|
||||
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
|
||||
forbiddenPaths?: string[];
|
||||
/** Whether network access is allowed. */
|
||||
networkAccess?: boolean;
|
||||
/** Rules for scrubbing sensitive environment variables. */
|
||||
@@ -50,6 +49,16 @@ export interface ExecutionPolicy {
|
||||
additionalPermissions?: SandboxPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the sandbox mode behavior.
|
||||
*/
|
||||
export interface SandboxModeConfig {
|
||||
readonly?: boolean;
|
||||
network?: boolean;
|
||||
approvedTools?: string[];
|
||||
allowOverrides?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global configuration options used to initialize a SandboxManager.
|
||||
*/
|
||||
@@ -59,6 +68,12 @@ export interface GlobalSandboxOptions {
|
||||
* This directory is granted full read and write access.
|
||||
*/
|
||||
workspace: string;
|
||||
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
|
||||
forbiddenPaths?: string[];
|
||||
/** The current sandbox mode behavior from config. */
|
||||
modeConfig?: SandboxModeConfig;
|
||||
/** The policy manager for persistent approvals. */
|
||||
policyManager?: SandboxPolicyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +106,16 @@ export interface SandboxedCommand {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A structured result from parsing sandbox denials.
|
||||
*/
|
||||
export interface ParsedSandboxDenial {
|
||||
/** If the denial is related to file system access, these are the paths that were blocked. */
|
||||
filePaths?: string[];
|
||||
/** If the denial is related to network access. */
|
||||
network?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a service that prepares commands for sandboxed execution.
|
||||
*/
|
||||
@@ -109,6 +134,11 @@ export interface SandboxManager {
|
||||
* Checks if a command with its arguments is explicitly known to be dangerous for this sandbox.
|
||||
*/
|
||||
isDangerousCommand(args: string[]): boolean;
|
||||
|
||||
/**
|
||||
* Parses the output of a command to detect sandbox denials.
|
||||
*/
|
||||
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +266,14 @@ export class NoopSandboxManager implements SandboxManager {
|
||||
? isWindowsDangerousCommand(args)
|
||||
: isMacDangerousCommand(args);
|
||||
}
|
||||
|
||||
parseDenials(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SandboxManager that implements actual sandboxing.
|
||||
* A SandboxManager implementation that just runs locally (no sandboxing yet).
|
||||
*/
|
||||
export class LocalSandboxManager implements SandboxManager {
|
||||
async prepareCommand(_req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
@@ -253,6 +287,10 @@ export class LocalSandboxManager implements SandboxManager {
|
||||
isDangerousCommand(_args: string[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
parseDenials(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,50 +9,36 @@ import {
|
||||
type SandboxManager,
|
||||
NoopSandboxManager,
|
||||
LocalSandboxManager,
|
||||
type GlobalSandboxOptions,
|
||||
} from './sandboxManager.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
|
||||
import type { SandboxConfig } from '../config/config.js';
|
||||
import { type SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
|
||||
|
||||
/**
|
||||
* Creates a sandbox manager based on the provided settings.
|
||||
*/
|
||||
export function createSandboxManager(
|
||||
sandbox: SandboxConfig | undefined,
|
||||
workspace: string,
|
||||
policyManager?: SandboxPolicyManager,
|
||||
options: GlobalSandboxOptions,
|
||||
approvalMode?: string,
|
||||
): SandboxManager {
|
||||
if (approvalMode === 'yolo') {
|
||||
return new NoopSandboxManager();
|
||||
}
|
||||
|
||||
const modeConfig =
|
||||
policyManager && approvalMode
|
||||
? policyManager.getModeConfig(approvalMode)
|
||||
: undefined;
|
||||
if (!options.modeConfig && options.policyManager && approvalMode) {
|
||||
options.modeConfig = options.policyManager.getModeConfig(approvalMode);
|
||||
}
|
||||
|
||||
if (sandbox?.enabled) {
|
||||
if (os.platform() === 'win32' && sandbox?.command === 'windows-native') {
|
||||
return new WindowsSandboxManager({
|
||||
workspace,
|
||||
modeConfig,
|
||||
policyManager,
|
||||
});
|
||||
if (os.platform() === 'win32') {
|
||||
return new WindowsSandboxManager(options);
|
||||
} else if (os.platform() === 'linux') {
|
||||
return new LinuxSandboxManager({
|
||||
workspace,
|
||||
modeConfig,
|
||||
policyManager,
|
||||
});
|
||||
return new LinuxSandboxManager(options);
|
||||
} else if (os.platform() === 'darwin') {
|
||||
return new MacOsSandboxManager({
|
||||
workspace,
|
||||
modeConfig,
|
||||
policyManager,
|
||||
});
|
||||
return new MacOsSandboxManager(options);
|
||||
}
|
||||
return new LocalSandboxManager();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user