Compare commits

...

34 Commits

Author SHA1 Message Date
Bryan Morgan 57d999f10c fix: improve fallback error handling and short-circuit on total failure
- executeFallbackForUrl() now throws on failure instead of returning
  error strings, giving callers proper error semantics to distinguish
  fetch failures from successful content.
- executeFallback() catches per-URL errors, tracks them separately from
  successful results, and short-circuits with an error result when all
  URLs fail — avoiding a wasted LLM call on empty content.
- When some URLs succeed and others fail, error notes are appended to
  the fallback prompt so the LLM is aware of partial fetch failures.
2026-03-12 16:39:28 -04:00
Aishanee Shah f12556de3c Merge branch 'main' into webfetch-stage-1 2026-03-12 15:51:16 -04:00
Aishanee Shah 6e2d6074ca refactor(core): revert unique delimiter in web_fetch fallback 2026-03-12 19:37:35 +00:00
Aishanee Shah 37880ef597 refactor(core): address PR feedback on web_fetch nits and refactoring 2026-03-12 19:37:10 +00:00
Adarsh Pandey 829c532703 fix(core/ide): add Antigravity CLI fallbacks (#22030) 2026-03-12 19:28:36 +00:00
Adam Weidman c68303c553 fix(core): add proxy routing support for remote A2A subagents (#22199) 2026-03-12 19:25:51 +00:00
Abhi 7242d71c01 fix(core): prevent duplicate tool schemas for instantiated tools (#22204) 2026-03-12 19:09:23 +00:00
Yuna Seol 391715c33c fix(core): fix startup stats to use int values for timestamps and durations (#22201)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-12 19:06:12 +00:00
Tommaso Sciortino 8a537d85e9 update vulnerable deps (#22180) 2026-03-12 17:14:43 +00:00
Abhi cd7dced951 feat(core): implement model-driven parallel tool scheduler (#21933) 2026-03-12 17:03:44 +00:00
Spencer 73c589f9e3 feat(cli): allow safe tools to execute concurrently while agent is busy (#21988) 2026-03-12 16:03:53 +00:00
nityam e700a9220b test(cli): add integration test for node deprecation warnings (#20215)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-12 15:47:21 +00:00
Adam Weidman 4b76fe0061 feat(core): add google credentials provider for remote agents (#21024) 2026-03-12 15:39:59 +00:00
Gaurav 867dc0fdda feat(telemetry): add Clearcut instrumentation for AI credits billing events (#22153) 2026-03-12 15:16:27 +00:00
Michael Ramos 7506b00488 fix(core): handle policy ALLOW for exit_plan_mode (#21802) 2026-03-12 14:43:40 +00:00
Jaisal K Jain 34709dc62d fix(cli): validate --model argument at startup (#21393)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-12 14:38:54 +00:00
Abhi 8432bcee75 fix(core): resolve MCP tool FQN validation, schema export, and wildcards in subagents (#22069) 2026-03-12 14:17:36 +00:00
Jack Wotherspoon a38aaa47fb chore: remove unnecessary log for themes (#22165) 2026-03-12 13:51:36 +00:00
Sehoon Shon 18e8dd768a feat(ui): Do not show Ultra users /upgrade hint (#22154) (#22156) 2026-03-12 13:46:58 +00:00
Jack Wotherspoon 45faf4d31b fix: register themes on extension load not start (#22148) 2026-03-12 13:38:09 +00:00
Aditya Bijalwan 333475c41f feat(browser): implement input blocker overlay during automation (#21132)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: Gaurav Ghosh <gaghosh@google.com>
2026-03-12 11:29:57 +00:00
Sri Pasumarthi 41d4f59f5e feat: Display pending and confirming tool calls (#22106)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-12 05:57:37 +00:00
Adam Weidman 10ab958378 refactor(core): extract ExecutionLifecycleService for tool backgrounding (#21717) 2026-03-12 04:03:54 +00:00
Bryan Morgan 949e85ca55 feat(core): differentiate User-Agent for a2a-server and ACP clients (#22059) 2026-03-12 02:31:59 +00:00
Spencer f090736ebc fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation (#22104)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2026-03-12 02:26:21 +00:00
Sam Roberts 35bf746e62 Add issue for automated changelogs (#21912) 2026-03-12 01:43:28 +00:00
Jenna Inouye 3da1563c30 Docs: Add documentation for model steering (experimental). (#21154) 2026-03-12 00:05:59 +00:00
N. Taylor Mullen 7380424782 fix(policy): ensure user policies are loaded when policyPaths is empty (#22090) 2026-03-11 23:58:58 +00:00
Adib234 90b53f9a82 docs(plan): clarify interactive plan editing with Ctrl+X (#22076) 2026-03-11 23:38:22 +00:00
Aishanee Shah 3fa0b81f87 Merge branch 'main' into webfetch-stage-1 2026-03-11 15:48:32 -04:00
Aishanee Shah 94bccb7ace fix(core): update web_fetch skip reasons to match E2E expectations 2026-03-11 19:39:14 +00:00
Aishanee Shah 1c881d9b6f Merge branch 'main' into webfetch-stage-1 2026-03-11 12:49:52 -04:00
Aishanee Shah ae8c75bcdb fix(core): revert retryFetchErrors default to true 2026-03-11 13:57:12 +00:00
Aishanee Shah b245b7e8b9 feat(core): implement Stage 1 improvements for webfetch tool 2026-03-11 13:46:44 +00:00
122 changed files with 5496 additions and 1490 deletions
+2
View File
@@ -95,6 +95,8 @@ jobs:
This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release.
Please review and merge.
Related to #18505
branch: 'changelog-${{ steps.release_info.outputs.VERSION }}'
base: 'main'
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
+79
View File
@@ -0,0 +1,79 @@
# Model steering (experimental)
Model steering lets you provide real-time guidance and feedback to Gemini CLI
while it is actively executing a task. This lets you correct course, add missing
context, or skip unnecessary steps without having to stop and restart the agent.
> **Note:** This is a preview feature under active development. Preview features
> may only be available in the **Preview** channel or may need to be enabled
> under `/settings`.
Model steering is particularly useful during complex [Plan Mode](./plan-mode.md)
workflows or long-running subagent executions where you want to ensure the agent
stays on the right track.
## Enabling model steering
Model steering is an experimental feature and is disabled by default. You can
enable it using the `/settings` command or by updating your `settings.json`
file.
1. Type `/settings` in the Gemini CLI.
2. Search for **Model Steering**.
3. Set the value to **true**.
Alternatively, add the following to your `settings.json`:
```json
{
"experimental": {
"modelSteering": true
}
}
```
## Using model steering
When model steering is enabled, Gemini CLI treats any text you type while the
agent is working as a steering hint.
1. Start a task (for example, "Refactor the database service").
2. While the agent is working (the spinner is visible), type your feedback in
the input box.
3. Press **Enter**.
Gemini CLI acknowledges your hint with a brief message and injects it directly
into the model's context for the very next turn. The model then re-evaluates its
current plan and adjusts its actions accordingly.
### Common use cases
You can use steering hints to guide the model in several ways:
- **Correcting a path:** "Actually, the utilities are in `src/common/utils`."
- **Skipping a step:** "Skip the unit tests for now and just focus on the
implementation."
- **Adding context:** "The `User` type is defined in `packages/core/types.ts`."
- **Redirecting the effort:** "Stop searching the codebase and start drafting
the plan now."
- **Handling ambiguity:** "Use the existing `Logger` class instead of creating a
new one."
## How it works
When you submit a steering hint, Gemini CLI performs the following actions:
1. **Immediate acknowledgment:** It uses a small, fast model to generate a
one-sentence acknowledgment so you know your hint was received.
2. **Context injection:** It prepends an internal instruction to your hint that
tells the main agent to:
- Re-evaluate the active plan.
- Classify the update (for example, as a new task or extra context).
- Apply minimal-diff changes to affected tasks.
3. **Real-time update:** The hint is delivered to the agent at the beginning of
its next turn, ensuring the most immediate course correction possible.
## Next steps
- Tackle complex tasks with [Plan Mode](./plan-mode.md).
- Build custom [Agent Skills](./skills.md).
+28 -4
View File
@@ -61,20 +61,44 @@ Gemini CLI takes action.
[`ask_user`](../tools/ask-user.md). Provide your preferences to help guide
the design.
3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a
detailed implementation plan as a Markdown file in your plans directory. You
can open and read this file to understand the proposed changes.
detailed implementation plan as a Markdown file in your plans directory.
- **View:** You can open and read this file to understand the proposed
changes.
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
external editor.
4. **Approve or iterate:** Gemini CLI will present the finalized plan for your
approval.
- **Approve:** If you're satisfied with the plan, approve it to start the
implementation immediately: **Yes, automatically accept edits** or **Yes,
manually accept edits**.
- **Iterate:** If the plan needs adjustments, provide feedback. Gemini CLI
will refine the strategy and update the plan.
- **Iterate:** If the plan needs adjustments, provide feedback in the input
box or [edit the plan file directly](#collaborative-plan-editing). Gemini
CLI will refine the strategy and update the plan.
- **Cancel:** You can cancel your plan with `Esc`.
For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#custom-planning-with-skills).
### Collaborative plan editing
You can collaborate with Gemini CLI by making direct changes or leaving comments
in the implementation plan. This is often faster and more precise than
describing complex changes in natural language.
1. **Open the plan:** Press `Ctrl+X` when Gemini CLI presents a plan for
review.
2. **Edit or comment:** The plan opens in your configured external editor (for
example, VS Code or Vim). You can:
- **Modify steps:** Directly reorder, delete, or rewrite implementation
steps.
- **Leave comments:** Add inline questions or feedback (for example, "Wait,
shouldn't we use the existing `Logger` class here?").
3. **Save and close:** Save your changes and close the editor.
4. **Review and refine:** Gemini CLI automatically detects the changes, reviews
your comments, and adjusts the implementation strategy. It then presents the
refined plan for your final approval.
## How to exit Plan Mode
You can exit Plan Mode at any time, whether you have finalized a plan or want to
+45
View File
@@ -45,6 +45,7 @@ Environment variables can override these settings.
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
**Note on boolean environment variables:** For boolean settings like `enabled`,
setting the environment variable to `true` or `1` enables the feature.
@@ -216,6 +217,50 @@ recommend using file-based output for local development.
For advanced local telemetry setups (such as Jaeger or Genkit), see the
[Local development guide](../local-development.md#viewing-traces).
## Client identification
Gemini CLI includes identifiers in its `User-Agent` header to help you
differentiate and report on API traffic from different environments (for
example, identifying calls from Gemini Code Assist versus a standard terminal).
### Automatic identification
Most integrated environments are identified automatically without additional
configuration. The identifier is included as a prefix to the `User-Agent` and as
a "surface" tag in the parenthetical metadata.
| Environment | User-Agent Prefix | Surface Tag |
| :---------------------------------- | :--------------------------- | :---------- |
| **Gemini Code Assist (Agent Mode)** | `GeminiCLI-a2a-server` | `vscode` |
| **Zed (via ACP)** | `GeminiCLI-acp-zed` | `zed` |
| **XCode (via ACP)** | `GeminiCLI-acp-xcode` | `xcode` |
| **IntelliJ IDEA (via ACP)** | `GeminiCLI-acp-intellijidea` | `jetbrains` |
| **Standard Terminal** | `GeminiCLI` | `terminal` |
**Example User-Agent:**
`GeminiCLI-a2a-server/0.34.0/gemini-pro (linux; x64; vscode)`
### Custom identification
You can provide a custom identifier for your own scripts or automation by
setting the `GEMINI_CLI_SURFACE` environment variable. This is useful for
tracking specific internal tools or distribution channels in your GCP logs.
**macOS/Linux**
```bash
export GEMINI_CLI_SURFACE="my-custom-tool"
```
**Windows (PowerShell)**
```powershell
$env:GEMINI_CLI_SURFACE="my-custom-tool"
```
When set, the value appears at the end of the `User-Agent` parenthetical:
`GeminiCLI/0.34.0/gemini-pro (linux; x64; my-custom-tool)`
## Logs, metrics, and traces
This section describes the structure of logs, metrics, and traces generated by
+89
View File
@@ -0,0 +1,89 @@
# Use Plan Mode with model steering for complex tasks
Architecting a complex solution requires precision. By combining Plan Mode's
structured environment with model steering's real-time feedback, you can guide
Gemini CLI through the research and design phases to ensure the final
implementation plan is exactly what you need.
> **Note:** This is a preview feature under active development. Preview features
> may only be available in the **Preview** channel or may need to be enabled
> under `/settings`.
## Prerequisites
- Gemini CLI installed and authenticated.
- [Plan Mode](../plan-mode.md) enabled in your settings.
- [Model steering](../model-steering.md) enabled in your settings.
## Why combine Plan Mode and model steering?
[Plan Mode](../plan-mode.md) typically follows a linear path: research, propose,
and draft. Adding model steering lets you:
1. **Direct the research:** Correct the agent if it's looking in the wrong
directory or missing a key dependency.
2. **Iterate mid-draft:** Suggest a different architectural pattern while the
agent is still writing the plan.
3. **Speed up the loop:** Avoid waiting for a full research turn to finish
before providing critical context.
## Step 1: Start a complex task
Enter Plan Mode and start a task that requires research.
**Prompt:** `/plan I want to implement a new notification service using Redis.`
Gemini CLI enters Plan Mode and starts researching your existing codebase to
identify where the new service should live.
## Step 2: Steer the research phase
As you see the agent calling tools like `list_directory` or `grep_search`, you
might realize it's missing the relevant context.
**Action:** While the spinner is active, type your hint:
`"Don't forget to check packages/common/queues for the existing Redis config."`
**Result:** Gemini CLI acknowledges your hint and immediately incorporates it
into its research. You'll see it start exploring the directory you suggested in
its very next turn.
## Step 3: Refine the design mid-turn
After research, the agent starts drafting the implementation plan. If you notice
it's proposing a design that doesn't align with your goals, steer it.
**Action:** Type:
`"Actually, let's use a Publisher/Subscriber pattern instead of a simple queue for this service."`
**Result:** The agent stops drafting the current version of the plan,
re-evaluates the design based on your feedback, and starts a new draft that uses
the Pub/Sub pattern.
## Step 4: Approve and implement
Once the agent has used your hints to craft the perfect plan, review the final
`.md` file.
**Action:** Type: `"Looks perfect. Let's start the implementation."`
Gemini CLI exits Plan Mode and transitions to the implementation phase. Because
the plan was refined in real-time with your feedback, the agent can now execute
each step with higher confidence and fewer errors.
## Tips for effective steering
- **Be specific:** Instead of "do it differently," try "use the existing
`Logger` class in `src/utils`."
- **Steer early:** Providing feedback during the research phase is more
efficient than waiting for the final plan to be drafted.
- **Use for context:** Steering is a great way to provide knowledge that might
not be obvious from reading the code (e.g., "We are planning to deprecate this
module next month").
## Next steps
- Explore [Agent Skills](../skills.md) to add specialized expertise to your
planning turns.
- See the [Model steering reference](../model-steering.md) for technical
details.
+11
View File
@@ -701,6 +701,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`agents.browser.disableUserInput`** (boolean):
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
#### `context`
- **`context.fileName`** (string | string[]):
@@ -1384,6 +1388,13 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Useful for shared compute environments or keeping CLI state isolated.
- Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows
PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`)
- **`GEMINI_CLI_SURFACE`**:
- Specifies a custom label to include in the `User-Agent` header for API
traffic reporting.
- This is useful for tracking specific internal tools or distribution
channels.
- Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell:
`$env:GEMINI_CLI_SURFACE="my-custom-tool"`)
- **`GOOGLE_API_KEY`**:
- Your Google Cloud API key.
- Required for using Vertex AI in express mode.
+3
View File
@@ -229,6 +229,9 @@ a `key` combination.
the numbered radio option and confirm when the full number is entered.
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
inline when the cursor is over the placeholder.
- `Ctrl + X` (while a plan is presented): Open the plan in an external editor to
[collaboratively edit or comment](../cli/plan-mode.md#collaborative-plan-editing)
on the implementation strategy.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
+10
View File
@@ -47,6 +47,11 @@
"label": "Plan tasks with todos",
"slug": "docs/cli/tutorials/task-planning"
},
{
"label": "Use Plan Mode with model steering",
"badge": "🔬",
"slug": "docs/cli/tutorials/plan-mode-steering"
},
{
"label": "Web search and fetch",
"slug": "docs/cli/tutorials/web-tools"
@@ -106,6 +111,11 @@
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{
"label": "Model steering",
"badge": "🔬",
"slug": "docs/cli/model-steering"
},
{
"label": "Notifications",
"badge": "🔬",
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
/**
* integration test to ensure no node.js deprecation warnings are emitted.
* must run for all supported node versions as warnings may vary by version.
*/
describe('deprecation-warnings', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it.each([
{ command: '--version', description: 'running --version' },
{ command: '--help', description: 'running with --help' },
])(
'should not emit any deprecation warnings when $description',
async ({ command, description }) => {
await rig.setup(
`should not emit any deprecation warnings when ${description}`,
);
const { stderr, exitCode } = await rig.runWithStreams([command]);
// node.js deprecation warnings: (node:12345) [DEP0040] DeprecationWarning: ...
const deprecationWarningPattern = /\[DEP\d+\].*DeprecationWarning/i;
const hasDeprecationWarning = deprecationWarningPattern.test(stderr);
if (hasDeprecationWarning) {
const deprecationMatches = stderr.match(
/\[DEP\d+\].*DeprecationWarning:.*/gi,
);
const warnings = deprecationMatches
? deprecationMatches.map((m) => m.trim()).join('\n')
: 'Unknown deprecation warning format';
throw new Error(
`Deprecation warnings detected in CLI output:\n${warnings}\n\n` +
`Full stderr:\n${stderr}\n\n` +
`This test ensures no deprecated Node.js modules are used. ` +
`Please update dependencies to use non-deprecated alternatives.`,
);
}
// only check exit code if no deprecation warnings found
if (exitCode !== 0) {
throw new Error(
`CLI exited with code ${exitCode} (expected 0). This may indicate a setup issue.\n` +
`Stderr: ${stderr}`,
);
}
},
);
});
+2
View File
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"ls -F"}}}]},"finishReason":"STOP","index":0}]},{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]}
+81
View File
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { join } from 'node:path';
import { TestRig, GEMINI_DIR } from './test-helper.js';
import fs from 'node:fs';
describe('User Policy Regression Repro', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => {
if (rig) {
await rig.cleanup();
}
});
it('should respect policies in ~/.gemini/policies/allowed-tools.toml', async () => {
rig.setup('user-policy-test', {
fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'),
});
// Create ~/.gemini/policies/allowed-tools.toml
const userPoliciesDir = join(rig.homeDir!, GEMINI_DIR, 'policies');
fs.mkdirSync(userPoliciesDir, { recursive: true });
fs.writeFileSync(
join(userPoliciesDir, 'allowed-tools.toml'),
`
[[rule]]
toolName = "run_shell_command"
commandPrefix = "ls -F"
decision = "allow"
priority = 100
`,
);
// Run gemini with a prompt that triggers ls -F
// approvalMode: 'default' in headless mode will DENY if it hits ASK_USER
const result = await rig.run({
args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'],
approvalMode: 'default',
});
expect(result).toContain('I ran ls -F');
expect(result).not.toContain('Tool execution denied by policy');
expect(result).not.toContain('Tool "run_shell_command" not found');
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find(
(l) =>
l.toolRequest.name === 'run_shell_command' &&
l.toolRequest.args.includes('ls -F'),
);
expect(lsLog).toBeDefined();
expect(lsLog?.toolRequest.success).toBe(true);
});
it('should FAIL if policy is not present (sanity check)', async () => {
rig.setup('user-policy-sanity-check', {
fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'),
});
// DO NOT create the policy file here
// Run gemini with a prompt that triggers ls -F
const result = await rig.run({
args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'],
approvalMode: 'default',
});
// In non-interactive mode, it should be denied
expect(result).toContain('Tool "run_shell_command" not found');
});
});
+225 -168
View File
@@ -1318,9 +1318,9 @@
}
},
"node_modules/@google-cloud/storage": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
"integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
"integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
@@ -1329,7 +1329,7 @@
"abort-controller": "^3.0.0",
"async-retry": "^1.3.3",
"duplexify": "^4.1.3",
"fast-xml-parser": "^4.4.1",
"fast-xml-parser": "^5.3.4",
"gaxios": "^6.0.2",
"google-auth-library": "^9.6.3",
"html-entities": "^2.5.2",
@@ -1516,9 +1516,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -2089,9 +2089,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -2195,7 +2195,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2376,7 +2375,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2426,7 +2424,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2801,7 +2798,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2835,7 +2831,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2890,7 +2885,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -3045,9 +3039,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -3058,9 +3052,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -3071,9 +3065,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -3084,9 +3078,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -3097,9 +3091,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -3110,9 +3104,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -3123,9 +3117,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -3136,9 +3130,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -3149,9 +3143,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -3162,9 +3156,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -3175,9 +3169,22 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -3188,9 +3195,22 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -3201,9 +3221,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -3214,9 +3234,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -3227,9 +3247,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -3240,9 +3260,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -3253,9 +3273,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -3265,10 +3285,23 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -3279,9 +3312,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -3292,9 +3325,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -3305,9 +3338,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -3318,9 +3351,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -3375,9 +3408,9 @@
}
},
"node_modules/@secretlint/config-loader/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4054,7 +4087,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4329,7 +4361,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5203,7 +5234,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5240,9 +5270,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5274,9 +5304,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -7735,7 +7765,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8246,7 +8275,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -8286,12 +8314,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -8433,10 +8461,10 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"node_modules/fast-xml-builder": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
"funding": [
{
"type": "github",
@@ -8445,7 +8473,24 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.2",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -9510,11 +9555,10 @@
}
},
"node_modules/hono": {
"version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9794,7 +9838,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -9963,9 +10006,9 @@
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -10749,6 +10792,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
@@ -12827,6 +12871,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -13381,7 +13440,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13392,7 +13450,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13862,9 +13919,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -13877,28 +13934,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.2",
"@rollup/rollup-android-arm64": "4.53.2",
"@rollup/rollup-darwin-arm64": "4.53.2",
"@rollup/rollup-darwin-x64": "4.53.2",
"@rollup/rollup-freebsd-arm64": "4.53.2",
"@rollup/rollup-freebsd-x64": "4.53.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
"@rollup/rollup-linux-arm64-musl": "4.53.2",
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
"@rollup/rollup-linux-x64-gnu": "4.53.2",
"@rollup/rollup-linux-x64-musl": "4.53.2",
"@rollup/rollup-openharmony-arm64": "4.53.2",
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
"@rollup/rollup-win32-x64-gnu": "4.53.2",
"@rollup/rollup-win32-x64-msvc": "4.53.2",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -14432,9 +14492,9 @@
}
},
"node_modules/simple-git": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
"integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==",
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz",
"integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==",
"license": "MIT",
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
@@ -14937,9 +14997,9 @@
}
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"funding": [
{
"type": "github",
@@ -15119,9 +15179,9 @@
}
},
"node_modules/systeminformation": {
"version": "5.30.2",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz",
"integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==",
"version": "5.31.4",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.4.tgz",
"integrity": "sha512-lZppDyQx91VdS5zJvAyGkmwe+Mq6xY978BDUG2wRkWE+jkmUF5ti8cvOovFQoN5bvSFKCXVkyKEaU5ec3SJiRg==",
"license": "MIT",
"os": [
"darwin",
@@ -15162,9 +15222,9 @@
}
},
"node_modules/table/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15437,7 +15497,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15661,8 +15720,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15670,7 +15728,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -15830,7 +15887,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15889,9 +15945,9 @@
}
},
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"dev": true,
"license": "MIT"
},
@@ -16053,7 +16109,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16167,7 +16222,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16180,7 +16234,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16822,7 +16875,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17289,9 +17341,9 @@
}
},
"packages/core/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -17339,6 +17391,12 @@
"node": ">= 4"
}
},
"packages/core/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"packages/core/node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
@@ -17359,7 +17417,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -91,6 +91,15 @@ describe('loadConfig', () => {
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
});
it('should pass clientName as a2a-server to Config', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
clientName: 'a2a-server',
}),
);
});
describe('when admin controls experiment is enabled', () => {
beforeEach(() => {
// We need to cast to any here to modify the mock implementation
+1
View File
@@ -62,6 +62,7 @@ export async function loadConfig(
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
model: PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
+59 -4
View File
@@ -1773,7 +1773,7 @@ describe('loadCliConfig model selection', () => {
});
it('always prefers model from argv', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1785,11 +1785,11 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
expect(config.getModel()).toBe('gemini-2.5-flash');
});
it('selects the model from argv if provided', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview'];
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings({
@@ -1799,7 +1799,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('gemini-2.5-flash-preview');
expect(config.getModel()).toBe('gemini-2.5-flash');
});
it('selects the default auto model if provided via auto alias', async () => {
@@ -3616,3 +3616,58 @@ describe('loadCliConfig mcpEnabled', () => {
});
});
});
describe('loadCliConfig acpMode and clientName', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should set acpMode to true and detect clientName when --acp flag is used', async () => {
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getAcpMode()).toBe(true);
expect(config.getClientName()).toBe('acp-vscode');
});
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getAcpMode()).toBe(true);
expect(config.getClientName()).toBeUndefined();
});
it('should set acpMode to false and clientName to undefined by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getAcpMode()).toBe(false);
expect(config.getClientName()).toBeUndefined();
});
});
+29 -1
View File
@@ -31,6 +31,8 @@ import {
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
isValidModelOrAlias,
getValidModelsAndAliases,
getAdminErrorMessage,
isHeadlessMode,
Config,
@@ -40,6 +42,7 @@ import {
type HookDefinition,
type HookEventName,
type OutputFormat,
detectIdeFromEnv,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -670,6 +673,18 @@ export async function loadCliConfig(
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
// Validate the model if one was explicitly specified
if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) {
if (!isValidModelOrAlias(specifiedModel)) {
const validModels = getValidModelsAndAliases();
throw new FatalConfigError(
`Invalid model: "${specifiedModel}"\n\n` +
`Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` +
`Use /model to switch models interactively.`,
);
}
}
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
? defaultModel
@@ -710,8 +725,21 @@ export async function loadCliConfig(
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
const ide = detectIdeFromEnv();
if (
ide &&
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
) {
clientName = `acp-${ide.name}`;
}
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
acpMode: isAcpMode,
clientName,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -12,12 +12,13 @@ import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings, type MergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
import { themeManager } from '../ui/themes/theme-manager.js';
import {
TrustLevel,
loadTrustedFolders,
isWorkspaceTrusted,
} from './trustedFolders.js';
import { getRealPath } from '@google/gemini-cli-core';
import { getRealPath, type CustomTheme } from '@google/gemini-cli-core';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
@@ -38,6 +39,26 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
const testTheme: CustomTheme = {
type: 'custom',
name: 'MyTheme',
background: {
primary: '#282828',
diff: { added: '#2b3312', removed: '#341212' },
},
text: {
primary: '#ebdbb2',
secondary: '#a89984',
link: '#83a598',
accent: '#d3869b',
},
status: {
success: '#b8bb26',
warning: '#fabd2f',
error: '#fb4934',
},
};
describe('ExtensionManager', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
@@ -65,6 +86,7 @@ describe('ExtensionManager', () => {
});
afterEach(() => {
themeManager.clearExtensionThemes();
try {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
} catch (_e) {
@@ -484,4 +506,45 @@ describe('ExtensionManager', () => {
).rejects.toThrow(/already installed/);
});
});
describe('early theme registration', () => {
it('should register themes with ThemeManager during loadExtensions for active extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'themed-ext',
version: '1.0.0',
themes: [testTheme],
});
await extensionManager.loadExtensions();
expect(themeManager.getCustomThemeNames()).toContain(
'MyTheme (themed-ext)',
);
});
it('should not register themes for inactive extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-ext',
version: '1.0.0',
themes: [testTheme],
});
// Disable the extension by creating an enablement override
const manager = new ExtensionManager({
enabledExtensionOverrides: ['none'],
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
});
await manager.loadExtensions();
expect(themeManager.getCustomThemeNames()).not.toContain(
'MyTheme (disabled-ext)',
);
});
});
});
+8 -1
View File
@@ -564,7 +564,7 @@ Would you like to attempt to install via "git clone" instead?`,
protected override async startExtension(extension: GeminiCLIExtension) {
await super.startExtension(extension);
if (extension.themes) {
if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) {
themeManager.registerExtensionThemes(extension.name, extension.themes);
}
}
@@ -624,6 +624,13 @@ Would you like to attempt to install via "git clone" instead?`,
this.loadedExtensions = builtExtensions;
// Register extension themes early so they're available at startup.
for (const ext of this.loadedExtensions) {
if (ext.isActive && ext.themes) {
themeManager.registerExtensionThemes(ext.name, ext.themes);
}
}
await Promise.all(
this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)),
);
+10
View File
@@ -1107,6 +1107,16 @@ const SETTINGS_SCHEMA = {
description: 'Model override for the visual agent.',
showInDialog: false,
},
disableUserInput: {
type: 'boolean',
label: 'Disable User Input',
category: 'Advanced',
requiresRestart: false,
default: true,
description:
'Disable user input on browser window during automation.',
showInDialog: false,
},
},
},
},
+15
View File
@@ -162,6 +162,7 @@ import {
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { parseSlashCommand } from '../utils/commands.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
@@ -1289,6 +1290,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems,
]);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
if (commandToExecute?.isSafeConcurrent) {
void handleSlashCommand(submittedValue);
addInput(submittedValue);
return;
}
}
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
handleHintSubmit(submittedValue);
addInput(submittedValue);
@@ -1332,6 +1345,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
addMessage,
addInput,
submitQuery,
handleSlashCommand,
slashCommands,
isMcpReady,
streamingState,
messageQueue.length,
@@ -23,6 +23,7 @@ export const aboutCommand: SlashCommand = {
description: 'Show version info',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context) => {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',
@@ -84,6 +84,7 @@ export const statsCommand: SlashCommand = {
description: 'Check session stats. Usage: /stats [session|model|tools]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
isSafeConcurrent: true,
action: async (context: CommandContext) => {
await defaultSessionView(context);
},
@@ -93,6 +94,7 @@ export const statsCommand: SlashCommand = {
description: 'Show session-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context: CommandContext) => {
await defaultSessionView(context);
},
@@ -102,6 +104,7 @@ export const statsCommand: SlashCommand = {
description: 'Show model-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
@@ -125,6 +128,7 @@ export const statsCommand: SlashCommand = {
description: 'Show tool-specific usage statistics',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: (context: CommandContext) => {
context.ui.addItem({
type: MessageType.TOOL_STATS,
+5
View File
@@ -207,6 +207,11 @@ export interface SlashCommand {
*/
autoExecute?: boolean;
/**
* Whether this command can be safely executed while the agent is busy (e.g. streaming a response).
*/
isSafeConcurrent?: boolean;
// Optional metadata for extension commands
extensionName?: string;
extensionId?: string;
@@ -37,6 +37,7 @@ describe('upgradeCommand', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
getUserTierName: vi.fn().mockReturnValue(undefined),
},
},
} as unknown as CommandContext);
@@ -115,4 +116,23 @@ describe('upgradeCommand', () => {
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
it('should return info message for ultra tiers', async () => {
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
'Advanced Ultra',
);
if (!upgradeCommand.action) {
throw new Error('The upgrade command must have an action.');
}
const result = await upgradeCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'You are already on the highest tier: Advanced Ultra.',
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,7 @@ import {
shouldLaunchBrowser,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
import { CommandKind, type SlashCommand } from './types.js';
/**
@@ -35,6 +36,15 @@ export const upgradeCommand: SlashCommand = {
};
}
const tierName = context.services.config?.getUserTierName();
if (isUltraTier(tierName)) {
return {
type: 'message',
messageType: 'info',
content: `You are already on the highest tier: ${tierName}.`,
};
}
if (!shouldLaunchBrowser()) {
return {
type: 'message',
@@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = {
description: 'Toggle vim mode on/off',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();
@@ -87,6 +87,7 @@ export const DialogManager = ({
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
/>
);
@@ -94,6 +94,12 @@ afterEach(() => {
});
const mockSlashCommands: SlashCommand[] = [
{
name: 'stats',
description: 'Check stats',
kind: CommandKind.BUILT_IN,
isSafeConcurrent: true,
},
{
name: 'clear',
kind: CommandKind.BUILT_IN,
@@ -3876,6 +3882,13 @@ describe('InputPrompt', () => {
shouldSubmit: false,
errorMessage: 'Slash commands cannot be queued',
},
{
name: 'should allow concurrent-safe slash commands',
bufferText: '/stats',
shellMode: false,
shouldSubmit: true,
errorMessage: null,
},
{
name: 'should prevent shell commands',
bufferText: 'ls',
+19 -1
View File
@@ -58,6 +58,7 @@ import {
isAutoExecutableCommand,
isSlashCommand,
} from '../utils/commandUtils.js';
import { parseSlashCommand } from '../../utils/commands.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
@@ -408,6 +409,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(isSlash || isShell) &&
streamingState === StreamingState.Responding
) {
if (isSlash) {
const { commandToExecute } = parseSlashCommand(
trimmedMessage,
slashCommands,
);
if (commandToExecute?.isSafeConcurrent) {
inputHistory.handleSubmit(trimmedMessage);
return;
}
}
setQueueErrorMessage(
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
);
@@ -415,7 +427,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
inputHistory.handleSubmit(trimmedMessage);
},
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
[
inputHistory,
shellModeActive,
streamingState,
setQueueErrorMessage,
slashCommands,
],
);
// Effect to reset completion if history navigation just occurred and set the text
@@ -202,6 +202,40 @@ describe('ProQuotaDialog', () => {
);
unmount();
});
it('should NOT render upgrade option for LOGIN_WITH_GOOGLE if tier is Ultra', () => {
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="free tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.LOGIN_WITH_GOOGLE}
tierName="Gemini Advanced Ultra"
onChoice={mockOnChoice}
/>,
);
expect(RadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
items: [
{
label: 'Switch to gemini-2.5-flash',
value: 'retry_always',
key: 'retry_always',
},
{
label: 'Stop',
value: 'retry_later',
key: 'retry_later',
},
],
}),
undefined,
);
unmount();
});
});
describe('when it is a capacity error', () => {
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { AuthType } from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
interface ProQuotaDialogProps {
failedModel: string;
@@ -17,6 +18,7 @@ interface ProQuotaDialogProps {
isTerminalQuotaError: boolean;
isModelNotFoundError?: boolean;
authType?: AuthType;
tierName?: string;
onChoice: (
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
) => void;
@@ -29,6 +31,7 @@ export function ProQuotaDialog({
isTerminalQuotaError,
isModelNotFoundError,
authType,
tierName,
onChoice,
}: ProQuotaDialogProps): React.JSX.Element {
let items;
@@ -47,6 +50,8 @@ export function ProQuotaDialog({
},
];
} else if (isModelNotFoundError || isTerminalQuotaError) {
const isUltra = isUltraTier(tierName);
// free users and out of quota users on G1 pro and Cloud Console gets an option to upgrade
items = [
{
@@ -54,7 +59,7 @@ export function ProQuotaDialog({
value: 'retry_always' as const,
key: 'retry_always',
},
...(authType === AuthType.LOGIN_WITH_GOOGLE
...(authType === AuthType.LOGIN_WITH_GOOGLE && !isUltra
? [
{
label: 'Upgrade for higher limits',
@@ -182,4 +182,23 @@ describe('<UserIdentity />', () => {
expect(output).toContain('/upgrade');
unmount();
});
it('should not render /upgrade indicator for ultra tiers', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
model: 'gemini-pro',
} as unknown as ContentGeneratorConfig);
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Advanced Ultra');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<UserIdentity config={mockConfig} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Plan: Advanced Ultra');
expect(output).not.toContain('/upgrade');
unmount();
});
});
@@ -13,6 +13,7 @@ import {
UserAccountManager,
AuthType,
} from '@google/gemini-cli-core';
import { isUltraTier } from '../../utils/tierUtils.js';
interface UserIdentityProps {
config: Config;
@@ -33,6 +34,8 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
[config, authType],
);
const isUltra = useMemo(() => isUltraTier(tierName), [tierName]);
if (!authType) {
return null;
}
@@ -60,7 +63,7 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
<Text color={theme.text.primary} wrap="truncate-end">
<Text bold>Plan:</Text> {tierName}
</Text>
<Text color={theme.text.secondary}> /upgrade</Text>
{!isUltra && <Text color={theme.text.secondary}> /upgrade</Text>}
</Box>
)}
</Box>
@@ -13,6 +13,10 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirming_tool Confirming tool description │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
Action Required (was prompted):
@@ -41,6 +45,10 @@ Tips for getting started:
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -97,6 +105,10 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -159,4 +159,22 @@ describe('ThinkingMessage', () => {
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('filters out progress dots and empty lines', async () => {
const renderResult = renderWithProviders(
<ThinkingMessage
thought={{ subject: '...', description: 'Thinking\n.\n..\n...\nDone' }}
terminalWidth={80}
isFirstThinking={true}
/>,
);
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('Thinking');
expect(output).toContain('Done');
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
@@ -23,20 +23,26 @@ function normalizeThoughtLines(thought: ThoughtSummary): string[] {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
if (!subject && !description) {
return [];
const isNoise = (text: string) => {
const trimmed = text.trim();
return !trimmed || /^\.+$/.test(trimmed);
};
const lines: string[] = [];
if (subject && !isNoise(subject)) {
lines.push(subject);
}
if (!subject) {
return description.split('\n');
if (description) {
const descriptionLines = description
.split('\n')
.map((line) => line.trim())
.filter((line) => !isNoise(line));
lines.push(...descriptionLines);
}
if (!description) {
return [subject];
}
const bodyLines = description.split('\n');
return [subject, ...bodyLines];
return lines;
}
/**
@@ -118,9 +118,10 @@ describe('<ToolGroupMessage />', () => {
{ config: baseMockConfig, settings: fullVerbositySettings },
);
// Should render nothing because all tools in the group are confirming
// Should now render confirming tools
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
const output = lastFrame();
expect(output).toContain('test-tool');
unmount();
});
@@ -162,11 +163,11 @@ describe('<ToolGroupMessage />', () => {
},
},
);
// pending-tool should be hidden
// pending-tool should now be visible
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('successful-tool');
expect(output).not.toContain('pending-tool');
expect(output).toContain('pending-tool');
expect(output).toContain('error-tool');
expect(output).toMatchSnapshot();
unmount();
@@ -280,12 +281,12 @@ describe('<ToolGroupMessage />', () => {
},
},
);
// write_file (Pending) should be hidden
// write_file (Pending) should now be visible
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('read_file');
expect(output).toContain('run_shell_command');
expect(output).not.toContain('write_file');
expect(output).toContain('write_file');
expect(output).toMatchSnapshot();
unmount();
});
@@ -841,7 +842,7 @@ describe('<ToolGroupMessage />', () => {
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(lastFrame({ allowEmpty: true })).not.toBe('');
unmount();
});
@@ -110,10 +110,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
return (
displayStatus !== ToolCallStatus.Pending &&
displayStatus !== ToolCallStatus.Confirming
);
// We used to filter out Pending and Confirming statuses here to avoid
// duplication with the Global Queue, but this causes tools to appear to
// "vanish" from the context after approval.
// We now allow them to be visible here as well.
return displayStatus !== ToolCallStatus.Canceled;
}),
[toolCalls],
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="19" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold" font-style="italic">Thinking</text>
<text x="9" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs" font-style="italic">Done</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

@@ -1,5 +1,20 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ThinkingMessage > filters out progress dots and empty lines 1`] = `
" Thinking...
│ Thinking
│ Done
"
`;
exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = `
" Thinking...
│ Thinking
│ Done"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
" Thinking...
@@ -74,6 +74,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
│ ⊶ run_shell_command Run command │
│ │
│ Test result │
│ │
│ o write_file Write to file │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -84,6 +88,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls w
│ │
│ Test result │
│ │
│ o pending-tool This tool is pending │
│ │
│ Test result │
│ │
│ x error-tool This tool failed │
│ │
│ Test result │
@@ -80,7 +80,7 @@ export const useShellCommandProcessor = (
setShellInputFocused: (value: boolean) => void,
terminalWidth?: number,
terminalHeight?: number,
activeToolPtyId?: number,
activeBackgroundExecutionId?: number,
isWaitingForConfirmation?: boolean,
) => {
const [state, dispatch] = useReducer(shellReducer, initialState);
@@ -103,7 +103,8 @@ export const useShellCommandProcessor = (
}
const m = manager.current;
const activePtyId = state.activeShellPtyId || activeToolPtyId;
const activePtyId =
state.activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;
useEffect(() => {
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
@@ -191,7 +192,8 @@ export const useShellCommandProcessor = (
]);
const backgroundCurrentShell = useCallback(() => {
const pidToBackground = state.activeShellPtyId || activeToolPtyId;
const pidToBackground =
state.activeShellPtyId ?? activeBackgroundExecutionId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
@@ -202,7 +204,7 @@ export const useShellCommandProcessor = (
m.restoreTimeout = null;
}
}
}, [state.activeShellPtyId, activeToolPtyId, m]);
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
const dismissBackgroundShell = useCallback(
async (pid: number) => {
@@ -103,6 +103,25 @@ const MockedUserPromptEvent = vi.hoisted(() =>
vi.fn().mockImplementation(() => {}),
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
const mockIsBackgroundExecutionData = vi.hoisted(
() =>
(data: unknown): data is { pid?: number } => {
if (typeof data !== 'object' || data === null) {
return false;
}
const value = data as {
pid?: unknown;
command?: unknown;
initialOutput?: unknown;
};
return (
(value.pid === undefined || typeof value.pid === 'number') &&
(value.command === undefined || typeof value.command === 'string') &&
(value.initialOutput === undefined ||
typeof value.initialOutput === 'string')
);
},
);
const MockValidationRequiredError = vi.hoisted(
() =>
@@ -128,6 +147,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
...actualCoreModule,
isBackgroundExecutionData: mockIsBackgroundExecutionData,
GitService: vi.fn(),
GeminiClient: MockedGeminiClientClass,
UserPromptEvent: MockedUserPromptEvent,
@@ -606,6 +626,35 @@ describe('useGeminiStream', () => {
expect(mockSendMessageStream).not.toHaveBeenCalled(); // submitQuery uses this
});
it('should expose activePtyId for non-shell executing tools that report an execution ID', () => {
const remoteExecutingTool: TrackedExecutingToolCall = {
request: {
callId: 'remote-call-1',
name: 'remote_agent_call',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-remote',
},
status: CoreToolCallStatus.Executing,
responseSubmittedToGemini: false,
tool: {
name: 'remote_agent_call',
displayName: 'Remote Agent',
description: 'Remote agent execution',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Calling remote agent',
} as unknown as AnyToolInvocation,
startTime: Date.now(),
liveOutput: 'working...',
pid: 4242,
};
const { result } = renderTestHook([remoteExecutingTool]);
expect(result.current.activePtyId).toBe(4242);
});
it('should submit tool responses when all tool calls are completed and ready', async () => {
const toolCall1ResponseParts: Part[] = [{ text: 'tool 1 final response' }];
const toolCall2ResponseParts: Part[] = [{ text: 'tool 2 final response' }];
+48 -39
View File
@@ -37,6 +37,7 @@ import {
buildUserSteeringHintPrompt,
GeminiCliOperation,
getPlanModeExitMessage,
isBackgroundExecutionData,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -94,10 +95,10 @@ type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
};
interface ShellToolData {
pid?: number;
command?: string;
initialOutput?: string;
interface BackgroundedToolInfo {
pid: number;
command: string;
initialOutput: string;
}
enum StreamProcessingStatus {
@@ -111,15 +112,32 @@ const SUPPRESSED_TOOL_ERRORS_NOTE =
const LOW_VERBOSITY_FAILURE_NOTE =
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
function getBackgroundedToolInfo(
toolCall: TrackedCompletedToolCall | TrackedCancelledToolCall,
): BackgroundedToolInfo | undefined {
const response = toolCall.response as ToolResponseWithParts;
const rawData: unknown = response?.data;
if (!isBackgroundExecutionData(rawData)) {
return undefined;
}
const d = data as Partial<ShellToolData>;
if (rawData.pid === undefined) {
return undefined;
}
return {
pid: rawData.pid,
command: rawData.command ?? toolCall.request.name,
initialOutput: rawData.initialOutput ?? '',
};
}
function isBackgroundableExecutingToolCall(
toolCall: TrackedToolCall,
): toolCall is TrackedExecutingToolCall {
return (
(d.pid === undefined || typeof d.pid === 'number') &&
(d.command === undefined || typeof d.command === 'string') &&
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
toolCall.status === CoreToolCallStatus.Executing &&
typeof toolCall.pid === 'number'
);
}
@@ -319,13 +337,11 @@ export const useGeminiStream = (
getPreferredEditor,
);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
const activeBackgroundExecutionId = useMemo(() => {
const executingBackgroundableTool = toolCalls.find(
isBackgroundableExecutingToolCall,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
return executingBackgroundableTool?.pid;
}, [toolCalls]);
const onExec = useCallback(
@@ -358,7 +374,7 @@ export const useGeminiStream = (
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
activeBackgroundExecutionId,
);
const streamingState = useMemo(
@@ -536,7 +552,8 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const activePtyId = activeShellPtyId || activeToolPtyId;
const activePtyId =
activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;
const prevActiveShellPtyIdRef = useRef<number | null>(null);
useEffect(() => {
@@ -1039,7 +1056,9 @@ export const useGeminiStream = (
return;
}
const finishReasonMessages: Record<FinishReason, string | undefined> = {
const finishReasonMessages: Partial<
Record<FinishReason, string | undefined>
> = {
[FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
[FinishReason.STOP]: undefined,
[FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
@@ -1676,26 +1695,16 @@ export const useGeminiStream = (
!processedMemoryToolsRef.current.has(t.request.callId),
);
// Handle backgrounded shell tools
completedAndReadyToSubmitTools.forEach((t) => {
const isShell = t.request.name === 'run_shell_command';
// Access result from the tracked tool call response
const response = t.response as ToolResponseWithParts;
const rawData = response?.data;
const data = isShellToolData(rawData) ? rawData : undefined;
// Use data.pid for shell commands moved to the background.
const pid = data?.pid;
if (isShell && pid) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const command = (data?.['command'] as string) ?? 'shell';
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const initialOutput = (data?.['initialOutput'] as string) ?? '';
registerBackgroundShell(pid, command, initialOutput);
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
registerBackgroundShell(
backgroundedTool.pid,
backgroundedTool.command,
backgroundedTool.initialOutput,
);
}
});
}
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
+11 -5
View File
@@ -174,11 +174,6 @@ class ThemeManager {
return;
}
debugLogger.log(
`Registering extension themes for "${extensionName}":`,
customThemes,
);
for (const customThemeConfig of customThemes) {
const namespacedName = `${customThemeConfig.name} (${extensionName})`;
@@ -240,6 +235,17 @@ class ThemeManager {
}
}
/**
* Checks if themes for a given extension are already registered.
* @param extensionName The name of the extension.
* @returns True if any themes from the extension are registered.
*/
hasExtensionThemes(extensionName: string): boolean {
return Array.from(this.extensionThemes.keys()).some((name) =>
name.endsWith(`(${extensionName})`),
);
}
/**
* Clears all registered extension themes.
* This is primarily for testing purposes to reset state between tests.
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { isUltraTier } from './tierUtils.js';
describe('tierUtils', () => {
describe('isUltraTier', () => {
it('should return true if tier name contains "ultra" (case-insensitive)', () => {
expect(isUltraTier('Advanced Ultra')).toBe(true);
expect(isUltraTier('gemini ultra')).toBe(true);
expect(isUltraTier('ULTRA')).toBe(true);
});
it('should return false if tier name does not contain "ultra"', () => {
expect(isUltraTier('Free')).toBe(false);
expect(isUltraTier('Pro')).toBe(false);
expect(isUltraTier('Standard')).toBe(false);
});
it('should return false if tier name is undefined', () => {
expect(isUltraTier(undefined)).toBe(false);
});
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Checks if the given tier name corresponds to an "Ultra" tier.
*
* @param tierName The name of the user's tier.
* @returns True if the tier is an "Ultra" tier, false otherwise.
*/
export function isUltraTier(tierName?: string): boolean {
return !!tierName?.toLowerCase().includes('ultra');
}
@@ -18,6 +18,8 @@ import {
type AuthenticationHandler,
type Client,
} from '@a2a-js/sdk/client';
import type { Config } from '../config/config.js';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { debugLogger } from '../utils/debugLogger.js';
vi.mock('../utils/debugLogger.js', () => ({
@@ -117,6 +119,51 @@ describe('A2AClientManager', () => {
expect(instance1).toBe(instance2);
});
describe('getInstance / dispatcher initialization', () => {
it('should use UndiciAgent when no proxy is configured', async () => {
await manager.loadAgent('TestAgent', 'http://test.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
await cardFetch('http://test.agent/card');
const fetchCall = vi
.mocked(fetch)
.mock.calls.find((call) => call[0] === 'http://test.agent/card');
expect(fetchCall).toBeDefined();
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).toBeInstanceOf(UndiciAgent);
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).not.toBeInstanceOf(ProxyAgent);
});
it('should use ProxyAgent when a proxy is configured via Config', async () => {
A2AClientManager.resetInstanceForTesting();
const mockConfig = {
getProxy: () => 'http://my-proxy:8080',
} as Config;
manager = A2AClientManager.getInstance(mockConfig);
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
await cardFetch('http://test.proxy.agent/card');
const fetchCall = vi
.mocked(fetch)
.mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card');
expect(fetchCall).toBeDefined();
expect(
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
).toBeInstanceOf(ProxyAgent);
});
});
describe('loadAgent', () => {
it('should create and cache an A2AClient', async () => {
const agentCard = await manager.loadAgent(
+36 -17
View File
@@ -23,7 +23,8 @@ import {
createAuthenticatingFetchWithRetry,
} from '@a2a-js/sdk/client';
import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import { safeLookup } from '../utils/fetch.js';
import { classifyAgentError } from './a2a-errors.js';
@@ -31,16 +32,6 @@ import { classifyAgentError } from './a2a-errors.js';
// Remote agents can take 10+ minutes (e.g. Deep Research).
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
const A2A_TIMEOUT = 1800000; // 30 minutes
const a2aDispatcher = new UndiciAgent({
headersTimeout: A2A_TIMEOUT,
bodyTimeout: A2A_TIMEOUT,
connect: {
lookup: safeLookup, // SSRF protection at connection level
},
});
const a2aFetch: typeof fetch = (input, init) =>
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
fetch(input, { ...init, dispatcher: a2aDispatcher } as RequestInit);
export type SendMessageResult =
| Message
@@ -59,14 +50,39 @@ export class A2AClientManager {
private clients = new Map<string, Client>();
private agentCards = new Map<string, AgentCard>();
private constructor() {}
private a2aDispatcher: UndiciAgent | ProxyAgent;
private a2aFetch: typeof fetch;
private constructor(config?: Config) {
const proxyUrl = config?.getProxy();
const agentOptions = {
headersTimeout: A2A_TIMEOUT,
bodyTimeout: A2A_TIMEOUT,
connect: {
lookup: safeLookup, // SSRF protection at connection level
},
};
if (proxyUrl) {
this.a2aDispatcher = new ProxyAgent({
uri: proxyUrl,
...agentOptions,
});
} else {
this.a2aDispatcher = new UndiciAgent(agentOptions);
}
this.a2aFetch = (input, init) =>
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
fetch(input, { ...init, dispatcher: this.a2aDispatcher } as RequestInit);
}
/**
* Gets the singleton instance of the A2AClientManager.
*/
static getInstance(): A2AClientManager {
static getInstance(config?: Config): A2AClientManager {
if (!A2AClientManager.instance) {
A2AClientManager.instance = new A2AClientManager();
A2AClientManager.instance = new A2AClientManager(config);
}
return A2AClientManager.instance;
}
@@ -97,9 +113,12 @@ export class A2AClientManager {
}
// Authenticated fetch for API calls (transports).
let authFetch: typeof fetch = a2aFetch;
let authFetch: typeof fetch = this.a2aFetch;
if (authHandler) {
authFetch = createAuthenticatingFetchWithRetry(a2aFetch, authHandler);
authFetch = createAuthenticatingFetchWithRetry(
this.a2aFetch,
authHandler,
);
}
// Use unauthenticated fetch for the agent card unless explicitly required.
@@ -109,7 +128,7 @@ export class A2AClientManager {
init?: RequestInit,
): Promise<Response> => {
// Try without auth first
const response = await a2aFetch(input, init);
const response = await this.a2aFetch(input, init);
// Retry with auth if we hit a 401/403
if ((response.status === 401 || response.status === 403) && authFetch) {
+25 -5
View File
@@ -44,7 +44,7 @@ interface FrontmatterLocalAgentDefinition
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http' | 'oauth2';
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth2';
// API Key
key?: string;
name?: string;
@@ -54,10 +54,11 @@ interface FrontmatterAuthConfig {
username?: string;
password?: string;
value?: string;
// Google Credentials
scopes?: string[];
// OAuth2
client_id?: string;
client_secret?: string;
scopes?: string[];
authorization_url?: string;
token_url?: string;
}
@@ -107,9 +108,11 @@ const localAgentSchema = z
display_name: z.string().optional(),
tools: z
.array(
z.string().refine((val) => isValidToolName(val), {
message: 'Invalid tool name',
}),
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
)
.optional(),
model: z.string().optional(),
@@ -150,6 +153,15 @@ const httpAuthSchema = z.object({
value: z.string().min(1).optional(),
});
/**
* Google Credentials auth schema.
*/
const googleCredentialsAuthSchema = z.object({
...baseAuthFields,
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
});
/**
* OAuth2 auth schema.
* authorization_url and token_url can be discovered from the agent card if omitted.
@@ -168,6 +180,7 @@ const authConfigSchema = z
.discriminatedUnion('type', [
apiKeyAuthSchema,
httpAuthSchema,
googleCredentialsAuthSchema,
oauth2AuthSchema,
])
.superRefine((data, ctx) => {
@@ -367,6 +380,13 @@ function convertFrontmatterAuthToConfig(
name: frontmatter.name,
};
case 'google-credentials':
return {
...base,
type: 'google-credentials',
scopes: frontmatter.scopes,
};
case 'http': {
if (!frontmatter.scheme) {
throw new Error(
@@ -12,12 +12,15 @@ import type {
} from './types.js';
import { ApiKeyAuthProvider } from './api-key-provider.js';
import { HttpAuthProvider } from './http-provider.js';
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
export interface CreateAuthProviderOptions {
/** Required for OAuth/OIDC token storage. */
agentName?: string;
authConfig?: A2AAuthConfig;
agentCard?: AgentCard;
/** Required by some providers (like google-credentials) to determine token audience. */
targetUrl?: string;
/** URL to fetch the agent card from, used for OAuth2 URL discovery. */
agentCardUrl?: string;
}
@@ -43,9 +46,14 @@ export class A2AAuthProviderFactory {
}
switch (authConfig.type) {
case 'google-credentials':
// TODO: Implement
throw new Error('google-credentials auth provider not yet implemented');
case 'google-credentials': {
const provider = new GoogleCredentialsAuthProvider(
authConfig,
options.targetUrl,
);
await provider.initialize();
return provider;
}
case 'apiKey': {
const provider = new ApiKeyAuthProvider(authConfig);
@@ -0,0 +1,205 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
import type { GoogleCredentialsAuthConfig } from './types.js';
import { GoogleAuth } from 'google-auth-library';
import { OAuthUtils } from '../../mcp/oauth-utils.js';
// Mock the external dependencies
vi.mock('google-auth-library', () => ({
GoogleAuth: vi.fn(),
}));
describe('GoogleCredentialsAuthProvider', () => {
const mockConfig: GoogleCredentialsAuthConfig = {
type: 'google-credentials',
};
let mockGetClient: Mock;
let mockGetAccessToken: Mock;
let mockGetIdTokenClient: Mock;
let mockFetchIdToken: Mock;
beforeEach(() => {
vi.clearAllMocks();
mockGetAccessToken = vi
.fn()
.mockResolvedValue({ token: 'mock-access-token' });
mockGetClient = vi.fn().mockResolvedValue({
getAccessToken: mockGetAccessToken,
credentials: { expiry_date: Date.now() + 3600 * 1000 },
});
mockFetchIdToken = vi.fn().mockResolvedValue('mock-id-token');
mockGetIdTokenClient = vi.fn().mockResolvedValue({
idTokenProvider: {
fetchIdToken: mockFetchIdToken,
},
});
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
getClient: mockGetClient,
getIdTokenClient: mockGetIdTokenClient,
}));
});
describe('Initialization', () => {
it('throws if no targetUrl is provided', () => {
expect(() => new GoogleCredentialsAuthProvider(mockConfig)).toThrow(
/targetUrl must be provided/,
);
});
it('throws if targetHost is not allowed', () => {
expect(
() =>
new GoogleCredentialsAuthProvider(mockConfig, 'https://example.com'),
).toThrow(/is not an allowed host/);
});
it('initializes seamlessly with .googleapis.com', () => {
expect(
() =>
new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com/v1/models',
),
).not.toThrow();
});
it('initializes seamlessly with .run.app', () => {
expect(
() =>
new GoogleCredentialsAuthProvider(
mockConfig,
'https://my-cloud-run-service.run.app',
),
).not.toThrow();
});
});
describe('Token Fetching', () => {
it('fetches an access token for googleapis.com endpoint', async () => {
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com',
);
const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer mock-access-token' });
expect(mockGetClient).toHaveBeenCalled();
expect(mockGetAccessToken).toHaveBeenCalled();
expect(mockGetIdTokenClient).not.toHaveBeenCalled();
});
it('fetches an identity token for run.app endpoint', async () => {
// Mock OAuthUtils.parseTokenExpiry to avoid Base64 decoding issues in tests
vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue(
Date.now() + 1000000,
);
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://my-service.run.app/some-path',
);
const headers = await provider.headers();
expect(headers).toEqual({ Authorization: 'Bearer mock-id-token' });
expect(mockGetIdTokenClient).toHaveBeenCalledWith('my-service.run.app');
expect(mockFetchIdToken).toHaveBeenCalledWith('my-service.run.app');
expect(mockGetClient).not.toHaveBeenCalled();
});
it('returns cached access token on subsequent calls', async () => {
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com',
);
await provider.headers();
await provider.headers();
// Should only call getClient/getAccessToken once due to caching
expect(mockGetClient).toHaveBeenCalledTimes(1);
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
});
it('returns cached id token on subsequent calls', async () => {
vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue(
Date.now() + 1000000,
);
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://my-service.run.app',
);
await provider.headers();
await provider.headers();
expect(mockGetIdTokenClient).toHaveBeenCalledTimes(1);
expect(mockFetchIdToken).toHaveBeenCalledTimes(1);
});
it('re-fetches access token on 401 (shouldRetryWithHeaders)', async () => {
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com',
);
// Prime the cache
await provider.headers();
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
const req = {} as RequestInit;
const res = { status: 401 } as Response;
const retryHeaders = await provider.shouldRetryWithHeaders(req, res);
expect(retryHeaders).toEqual({
Authorization: 'Bearer mock-access-token',
});
// Cache was cleared, so getAccessToken was called again
expect(mockGetAccessToken).toHaveBeenCalledTimes(2);
});
it('re-fetches token on 403', async () => {
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com',
);
const req = {} as RequestInit;
const res = { status: 403 } as Response;
const retryHeaders = await provider.shouldRetryWithHeaders(req, res);
expect(retryHeaders).toEqual({
Authorization: 'Bearer mock-access-token',
});
});
it('stops retrying after MAX_AUTH_RETRIES', async () => {
const provider = new GoogleCredentialsAuthProvider(
mockConfig,
'https://language.googleapis.com',
);
const req = {} as RequestInit;
const res = { status: 401 } as Response;
// First two retries should succeed (MAX_AUTH_RETRIES = 2)
expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined();
expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined();
// Third should return undefined (exhausted)
expect(await provider.shouldRetryWithHeaders(req, res)).toBeUndefined();
});
});
});
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { HttpHeaders } from '@a2a-js/sdk/client';
import { BaseA2AAuthProvider } from './base-provider.js';
import type { GoogleCredentialsAuthConfig } from './types.js';
import { GoogleAuth } from 'google-auth-library';
import { debugLogger } from '../../utils/debugLogger.js';
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from '../../mcp/oauth-utils.js';
const CLOUD_RUN_HOST_REGEX = /^(.*\.)?run\.app$/;
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, CLOUD_RUN_HOST_REGEX];
/**
* Authentication provider for Google ADC (Application Default Credentials).
* Automatically decides whether to use identity tokens or access tokens
* based on the target endpoint URL.
*/
export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
readonly type = 'google-credentials' as const;
private readonly auth: GoogleAuth;
private readonly useIdToken: boolean = false;
private readonly audience?: string;
private cachedToken?: string;
private tokenExpiryTime?: number;
constructor(
private readonly config: GoogleCredentialsAuthConfig,
targetUrl?: string,
) {
super();
if (!targetUrl) {
throw new Error(
'targetUrl must be provided to GoogleCredentialsAuthProvider to determine token audience.',
);
}
const hostname = new URL(targetUrl).hostname;
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
if (isRunAppHost) {
this.useIdToken = true;
}
this.audience = hostname;
if (
!this.useIdToken &&
!ALLOWED_HOSTS.some((pattern) => pattern.test(hostname))
) {
throw new Error(
`Host "${hostname}" is not an allowed host for Google Credential provider.`,
);
}
// A2A spec requires scopes if configured, otherwise use default cloud-platform
const scopes =
this.config.scopes && this.config.scopes.length > 0
? this.config.scopes
: ['https://www.googleapis.com/auth/cloud-platform'];
this.auth = new GoogleAuth({
scopes,
});
}
override async initialize(): Promise<void> {
// We can pre-fetch or validate if necessary here,
// but deferred fetching is usually better for auth tokens.
}
async headers(): Promise<HttpHeaders> {
// Check cache
if (
this.cachedToken &&
this.tokenExpiryTime &&
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
) {
return { Authorization: `Bearer ${this.cachedToken}` };
}
// Clear expired cache
this.cachedToken = undefined;
this.tokenExpiryTime = undefined;
if (this.useIdToken) {
try {
const idClient = await this.auth.getIdTokenClient(this.audience!);
const idToken = await idClient.idTokenProvider.fetchIdToken(
this.audience!,
);
const expiryTime = OAuthUtils.parseTokenExpiry(idToken);
if (expiryTime) {
this.tokenExpiryTime = expiryTime;
this.cachedToken = idToken;
}
return { Authorization: `Bearer ${idToken}` };
} catch (e) {
const errorMessage = `Failed to get ADC ID token: ${
e instanceof Error ? e.message : String(e)
}`;
debugLogger.error(errorMessage, e);
throw new Error(errorMessage);
}
}
// Otherwise, access token
try {
const client = await this.auth.getClient();
const token = await client.getAccessToken();
if (token.token) {
this.cachedToken = token.token;
// Use expiry_date from the underlying credentials if available.
const creds = client.credentials;
if (creds.expiry_date) {
this.tokenExpiryTime = creds.expiry_date;
}
return { Authorization: `Bearer ${token.token}` };
}
throw new Error('Failed to retrieve ADC access token.');
} catch (e) {
const errorMessage = `Failed to get ADC access token: ${
e instanceof Error ? e.message : String(e)
}`;
debugLogger.error(errorMessage, e);
throw new Error(errorMessage);
}
}
override async shouldRetryWithHeaders(
_req: RequestInit,
res: Response,
): Promise<HttpHeaders | undefined> {
if (res.status !== 401 && res.status !== 403) {
this.authRetryCount = 0;
return undefined;
}
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
return undefined;
}
this.authRetryCount++;
debugLogger.debug(
'[GoogleCredentialsAuthProvider] Re-fetching token after auth failure',
);
// Clear cache to force a re-fetch
this.cachedToken = undefined;
this.tokenExpiryTime = undefined;
return this.headers();
}
}
@@ -28,6 +28,7 @@ import {
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { injectInputBlocker } from './inputBlocker.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
@@ -62,18 +63,30 @@ export async function createBrowserAgentDefinition(
printOutput('Browser connected with isolated MCP client.');
}
// Inject automation overlay if not in headless mode
// Determine if input blocker should be active (non-headless + enabled)
const shouldDisableInput = config.shouldDisableBrowserUserInput();
// Inject automation overlay and input blocker if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
if (printOutput) {
printOutput('Injecting automation overlay...');
}
await injectAutomationOverlay(browserManager);
if (shouldDisableInput) {
if (printOutput) {
printOutput('Injecting input blocker...');
}
await injectInputBlocker(browserManager);
}
}
// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
const mcpTools = await createMcpDeclarativeTools(
browserManager,
messageBus,
shouldDisableInput,
);
const availableToolNames = mcpTools.map((t) => t.name);
// Validate required semantic tools are available
@@ -19,6 +19,7 @@ import {
vi.mock('../../utils/debugLogger.js', () => ({
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
@@ -36,6 +36,7 @@ import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { removeInputBlocker } from './inputBlocker.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
@@ -490,6 +491,7 @@ ${displayResult}
} finally {
// Always cleanup browser resources
if (browserManager) {
await removeInputBlocker(browserManager);
await cleanupBrowserAgent(browserManager);
}
}
@@ -23,6 +23,7 @@ import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
import { Storage } from '../../config/storage.js';
import { injectInputBlocker } from './inputBlocker.js';
import * as path from 'node:path';
import { injectAutomationOverlay } from './automationOverlay.js';
@@ -97,10 +98,12 @@ export class BrowserManager {
* Always false in headless mode (no visible window to decorate).
*/
private readonly shouldInjectOverlay: boolean;
private readonly shouldDisableInput: boolean;
constructor(private config: Config) {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
this.shouldDisableInput = config.shouldDisableBrowserUserInput();
}
/**
@@ -176,20 +179,32 @@ export class BrowserManager {
}
}
// Re-inject the automation overlay after any tool that can cause a
// full-page navigation (including implicit navigations from clicking links).
// chrome-devtools-mcp emits no MCP notifications, so callTool() is the
// only interception point we have — equivalent to a page-load listener.
// Re-inject the automation overlay and input blocker after tools that
// can cause a full-page navigation. chrome-devtools-mcp emits no MCP
// notifications, so callTool() is the only interception point.
if (
this.shouldInjectOverlay &&
!result.isError &&
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
!signal?.aborted
) {
try {
await injectAutomationOverlay(this, signal);
if (this.shouldInjectOverlay) {
await injectAutomationOverlay(this, signal);
}
// Only re-inject the input blocker for tools that *reliably*
// replace the page DOM (navigate_page, new_page, select_page).
// click/click_at are handled by pointer-events suspend/resume
// in mcpToolWrapper — no full re-inject roundtrip needed.
// press_key/handle_dialog only sometimes navigate.
const reliableNavigation =
toolName === 'navigate_page' ||
toolName === 'new_page' ||
toolName === 'select_page';
if (this.shouldDisableInput && reliableNavigation) {
await injectInputBlocker(this);
}
} catch {
// Never let overlay failures interrupt the tool result
// Never let overlay/blocker failures interrupt the tool result
}
}
@@ -375,6 +390,7 @@ export class BrowserManager {
await this.rawMcpClient!.connect(this.mcpTransport!);
debugLogger.log('MCP client connected to chrome-devtools-mcp');
await this.discoverTools();
this.registerInputBlockerHandler();
})(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
@@ -485,4 +501,45 @@ export class BrowserManager {
this.discoveredTools.map((t) => t.name).join(', '),
);
}
/**
* Registers a fallback notification handler on the MCP client to
* automatically re-inject the input blocker after any server-side
* notification (e.g. page navigation, resource updates).
*
* This covers ALL navigation types (link clicks, form submissions,
* history navigation) not just explicit navigate_page tool calls.
*/
private registerInputBlockerHandler(): void {
if (!this.rawMcpClient) {
return;
}
if (!this.config.shouldDisableBrowserUserInput()) {
return;
}
const existingHandler = this.rawMcpClient.fallbackNotificationHandler;
this.rawMcpClient.fallbackNotificationHandler = async (notification: {
method: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any;
}) => {
// Chain with any existing handler first.
if (existingHandler) {
await existingHandler(notification);
}
// Only re-inject on resource update notifications which indicate
// page content has changed (navigation, new page, etc.)
if (notification.method === 'notifications/resources/updated') {
debugLogger.log('Page content changed, re-injecting input blocker...');
void injectInputBlocker(this);
}
};
debugLogger.log(
'Registered global notification handler for input blocker re-injection',
);
}
}
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { injectInputBlocker, removeInputBlocker } from './inputBlocker.js';
import type { BrowserManager } from './browserManager.js';
describe('inputBlocker', () => {
let mockBrowserManager: BrowserManager;
beforeEach(() => {
mockBrowserManager = {
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Script ran on page and returned:' }],
}),
} as unknown as BrowserManager;
});
describe('injectInputBlocker', () => {
it('should call evaluate_script with correct function parameter', async () => {
await injectInputBlocker(mockBrowserManager);
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
'evaluate_script',
{
function: expect.stringContaining('__gemini_input_blocker'),
},
);
});
it('should pass a function declaration, not an IIFE', async () => {
await injectInputBlocker(mockBrowserManager);
const call = vi.mocked(mockBrowserManager.callTool).mock.calls[0];
const args = call[1] as { function: string };
// Must start with "() =>" — chrome-devtools-mcp requires a function declaration
expect(args.function.trimStart()).toMatch(/^\(\)\s*=>/);
// Must NOT contain an IIFE invocation at the end
expect(args.function.trimEnd()).not.toMatch(/\}\)\(\)\s*;?\s*$/);
});
it('should use "function" parameter name, not "code"', async () => {
await injectInputBlocker(mockBrowserManager);
const call = vi.mocked(mockBrowserManager.callTool).mock.calls[0];
const args = call[1];
expect(args).toHaveProperty('function');
expect(args).not.toHaveProperty('code');
expect(args).not.toHaveProperty('expression');
});
it('should include the informational banner text', async () => {
await injectInputBlocker(mockBrowserManager);
const call = vi.mocked(mockBrowserManager.callTool).mock.calls[0];
const args = call[1] as { function: string };
expect(args.function).toContain('Gemini CLI is controlling this browser');
});
it('should set aria-hidden to prevent accessibility tree pollution', async () => {
await injectInputBlocker(mockBrowserManager);
const call = vi.mocked(mockBrowserManager.callTool).mock.calls[0];
const args = call[1] as { function: string };
expect(args.function).toContain('aria-hidden');
});
it('should not throw if script execution fails', async () => {
mockBrowserManager.callTool = vi
.fn()
.mockRejectedValue(new Error('Script failed'));
await expect(
injectInputBlocker(mockBrowserManager),
).resolves.toBeUndefined();
});
});
describe('removeInputBlocker', () => {
it('should call evaluate_script with function to remove blocker', async () => {
await removeInputBlocker(mockBrowserManager);
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
'evaluate_script',
{
function: expect.stringContaining('__gemini_input_blocker'),
},
);
});
it('should use "function" parameter name for removal too', async () => {
await removeInputBlocker(mockBrowserManager);
const call = vi.mocked(mockBrowserManager.callTool).mock.calls[0];
const args = call[1];
expect(args).toHaveProperty('function');
expect(args).not.toHaveProperty('code');
});
it('should not throw if removal fails', async () => {
mockBrowserManager.callTool = vi
.fn()
.mockRejectedValue(new Error('Removal failed'));
await expect(
removeInputBlocker(mockBrowserManager),
).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,271 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Input blocker utility for browser agent.
*
* Injects a transparent overlay that captures all user input events
* and displays an informational banner during automation.
*
* The overlay is PERSISTENT it stays in the DOM for the entire
* browser agent session. To allow CDP tool calls to interact with
* page elements, we temporarily set `pointer-events: none` on the
* overlay (via {@link suspendInputBlocker}) which makes it invisible
* to hit-testing / interactability checks without any DOM mutation
* or visual change. After the tool call, {@link resumeInputBlocker}
* restores `pointer-events: auto`.
*
* IMPORTANT: chrome-devtools-mcp's evaluate_script tool expects:
* { function: "() => { ... }" }
* It takes a function declaration string, NOT raw code.
* The parameter name is "function", not "code" or "expression".
*/
import type { BrowserManager } from './browserManager.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
* JavaScript function to inject the input blocker overlay.
* This blocks all user input events while allowing CDP commands to work normally.
*
* Must be a function declaration (NOT an IIFE) because evaluate_script
* evaluates it via Puppeteer's page.evaluate().
*/
const INPUT_BLOCKER_FUNCTION = `() => {
// If the blocker already exists, just ensure it's active and return.
// This makes re-injection after potentially-navigating tools near-free
// when the page didn't actually navigate (most clicks don't navigate).
var existing = document.getElementById('__gemini_input_blocker');
if (existing) {
existing.style.pointerEvents = 'auto';
return;
}
const blocker = document.createElement('div');
blocker.id = '__gemini_input_blocker';
blocker.setAttribute('aria-hidden', 'true');
blocker.setAttribute('role', 'presentation');
blocker.style.cssText = [
'position: fixed',
'inset: 0',
'z-index: 2147483646',
'cursor: not-allowed',
'background: transparent',
].join('; ');
// Block all input events on the overlay itself
var blockEvent = function(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
var events = [
'click', 'mousedown', 'mouseup', 'keydown', 'keyup',
'keypress', 'touchstart', 'touchend', 'touchmove', 'wheel',
'contextmenu', 'dblclick', 'pointerdown', 'pointerup', 'pointermove',
];
for (var i = 0; i < events.length; i++) {
blocker.addEventListener(events[i], blockEvent, { capture: true });
}
// Capsule-shaped floating pill at bottom center
var pill = document.createElement('div');
pill.style.cssText = [
'position: fixed',
'bottom: 20px',
'left: 50%',
'transform: translateX(-50%) translateY(20px)',
'display: flex',
'align-items: center',
'gap: 10px',
'padding: 10px 20px',
'background: rgba(24, 24, 27, 0.88)',
'color: #fff',
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
'font-size: 13px',
'line-height: 1',
'border-radius: 999px',
'z-index: 2147483647',
'backdrop-filter: blur(16px)',
'-webkit-backdrop-filter: blur(16px)',
'border: 1px solid rgba(255, 255, 255, 0.08)',
'box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05)',
'opacity: 0',
'transition: opacity 0.4s ease, transform 0.4s ease',
'white-space: nowrap',
'user-select: none',
'pointer-events: none',
].join('; ');
// Pulsing red dot
var dot = document.createElement('span');
dot.style.cssText = [
'width: 10px',
'height: 10px',
'border-radius: 50%',
'background: #ef4444',
'display: inline-block',
'flex-shrink: 0',
'box-shadow: 0 0 6px rgba(239, 68, 68, 0.6)',
'animation: __gemini_pulse 2s ease-in-out infinite',
].join('; ');
// Labels
var label = document.createElement('span');
label.style.cssText = 'font-weight: 600; letter-spacing: 0.01em;';
label.textContent = 'Gemini CLI is controlling this browser';
var sep = document.createElement('span');
sep.style.cssText = 'width: 1px; height: 14px; background: rgba(255,255,255,0.2); flex-shrink: 0;';
var sub = document.createElement('span');
sub.style.cssText = 'color: rgba(255,255,255,0.55); font-size: 12px;';
sub.textContent = 'Input disabled during automation';
pill.appendChild(dot);
pill.appendChild(label);
pill.appendChild(sep);
pill.appendChild(sub);
// Inject @keyframes for the pulse animation
var styleEl = document.createElement('style');
styleEl.id = '__gemini_input_blocker_style';
styleEl.textContent = '@keyframes __gemini_pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.85); } }';
document.head.appendChild(styleEl);
blocker.appendChild(pill);
var target = document.body || document.documentElement;
if (target) {
target.appendChild(blocker);
// Trigger entrance animation
requestAnimationFrame(function() {
pill.style.opacity = '1';
pill.style.transform = 'translateX(-50%) translateY(0)';
});
}
}`;
/**
* JavaScript function to remove the input blocker overlay entirely.
* Used only during final cleanup.
*/
const REMOVE_BLOCKER_FUNCTION = `() => {
var blocker = document.getElementById('__gemini_input_blocker');
if (blocker) {
blocker.remove();
}
var style = document.getElementById('__gemini_input_blocker_style');
if (style) {
style.remove();
}
}`;
/**
* JavaScript to temporarily suspend the input blocker by setting
* pointer-events to 'none'. This makes the overlay invisible to
* hit-testing so chrome-devtools-mcp's interactability checks pass
* and CDP clicks fall through to page elements.
*
* The overlay DOM element stays in place no visual change, no flickering.
*/
const SUSPEND_BLOCKER_FUNCTION = `() => {
var blocker = document.getElementById('__gemini_input_blocker');
if (blocker) {
blocker.style.pointerEvents = 'none';
}
}`;
/**
* JavaScript to resume the input blocker by restoring pointer-events
* to 'auto'. User clicks are blocked again.
*/
const RESUME_BLOCKER_FUNCTION = `() => {
var blocker = document.getElementById('__gemini_input_blocker');
if (blocker) {
blocker.style.pointerEvents = 'auto';
}
}`;
/**
* Injects the input blocker overlay into the current page.
*
* @param browserManager The browser manager to use for script execution
* @returns Promise that resolves when the blocker is injected
*/
export async function injectInputBlocker(
browserManager: BrowserManager,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: INPUT_BLOCKER_FUNCTION,
});
debugLogger.log('Input blocker injected successfully');
} catch (error) {
// Log but don't throw - input blocker is a UX enhancement, not critical functionality
debugLogger.warn(
'Failed to inject input blocker: ' +
(error instanceof Error ? error.message : String(error)),
);
}
}
/**
* Removes the input blocker overlay from the current page entirely.
* Used only during final cleanup.
*
* @param browserManager The browser manager to use for script execution
* @returns Promise that resolves when the blocker is removed
*/
export async function removeInputBlocker(
browserManager: BrowserManager,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: REMOVE_BLOCKER_FUNCTION,
});
debugLogger.log('Input blocker removed successfully');
} catch (error) {
// Log but don't throw - removal failure is not critical
debugLogger.warn(
'Failed to remove input blocker: ' +
(error instanceof Error ? error.message : String(error)),
);
}
}
/**
* Temporarily suspends the input blocker so CDP tool calls can
* interact with page elements. The overlay stays in the DOM
* (no visual change) only pointer-events is toggled.
*/
export async function suspendInputBlocker(
browserManager: BrowserManager,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: SUSPEND_BLOCKER_FUNCTION,
});
} catch {
// Non-critical — tool call will still attempt to proceed
}
}
/**
* Resumes the input blocker after a tool call completes.
* Restores pointer-events so user clicks are blocked again.
*/
export async function resumeInputBlocker(
browserManager: BrowserManager,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: RESUME_BLOCKER_FUNCTION,
});
} catch {
// Non-critical
}
}
@@ -193,4 +193,104 @@ describe('mcpToolWrapper', () => {
expect(result.error?.message).toBe('Connection lost');
});
});
describe('Input blocker suspend/resume', () => {
it('should suspend and resume input blocker around click (interactive tool)', async () => {
const tools = await createMcpDeclarativeTools(
mockBrowserManager,
mockMessageBus,
true, // shouldDisableInput
);
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'elem-42' });
await invocation.execute(new AbortController().signal);
// callTool: suspend blocker + click + resume blocker
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(3);
// First call: suspend blocker (pointer-events: none)
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
1,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
);
// Second call: click
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
2,
'click',
{ uid: 'elem-42' },
expect.any(AbortSignal),
);
// Third call: resume blocker (pointer-events: auto)
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
3,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
);
});
it('should NOT suspend/resume for take_snapshot (read-only tool)', async () => {
const tools = await createMcpDeclarativeTools(
mockBrowserManager,
mockMessageBus,
true, // shouldDisableInput
);
const snapshotTool = tools.find((t) => t.name === 'take_snapshot')!;
const invocation = snapshotTool.build({});
await invocation.execute(new AbortController().signal);
// callTool should only be called once for take_snapshot — no suspend/resume
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
'take_snapshot',
{},
expect.any(AbortSignal),
);
});
it('should NOT suspend/resume when shouldDisableInput is false', async () => {
const tools = await createMcpDeclarativeTools(
mockBrowserManager,
mockMessageBus,
false, // shouldDisableInput disabled
);
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'elem-42' });
await invocation.execute(new AbortController().signal);
// callTool should only be called once for click — no suspend/resume
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
});
it('should resume blocker even when interactive tool fails', async () => {
vi.mocked(mockBrowserManager.callTool)
.mockResolvedValueOnce({ content: [] }) // suspend blocker succeeds
.mockRejectedValueOnce(new Error('Click failed')) // tool fails
.mockResolvedValueOnce({ content: [] }); // resume succeeds
const tools = await createMcpDeclarativeTools(
mockBrowserManager,
mockMessageBus,
true, // shouldDisableInput
);
const clickTool = tools.find((t) => t.name === 'click')!;
const invocation = clickTool.build({ uid: 'bad-elem' });
const result = await invocation.execute(new AbortController().signal);
// Should return error, not throw
expect(result.error).toBeDefined();
// Should still try to resume
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(3);
});
});
});
@@ -30,6 +30,23 @@ import {
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import type { BrowserManager, McpToolCallResult } from './browserManager.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { suspendInputBlocker, resumeInputBlocker } from './inputBlocker.js';
/**
* Tools that interact with page elements and require the input blocker
* overlay to be temporarily SUSPENDED (pointer-events: none) so
* chrome-devtools-mcp's interactability checks pass. The overlay
* stays in the DOM only the CSS property toggles, zero flickering.
*/
const INTERACTIVE_TOOLS = new Set([
'click',
'click_at',
'fill',
'fill_form',
'hover',
'drag',
'upload_file',
]);
/**
* Tool invocation that dispatches to BrowserManager's isolated MCP client.
@@ -43,6 +60,7 @@ class McpToolInvocation extends BaseToolInvocation<
protected readonly toolName: string,
params: Record<string, unknown>,
messageBus: MessageBus,
private readonly shouldDisableInput: boolean,
) {
super(params, messageBus, toolName, toolName);
}
@@ -78,16 +96,29 @@ class McpToolInvocation extends BaseToolInvocation<
};
}
/**
* Whether this specific tool needs the input blocker suspended
* (pointer-events toggled to 'none') before execution.
*/
private get needsBlockerSuspend(): boolean {
return this.shouldDisableInput && INTERACTIVE_TOOLS.has(this.toolName);
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
const callToolPromise = this.browserManager.callTool(
// Suspend the input blocker for interactive tools so
// chrome-devtools-mcp's interactability checks pass.
// Only toggles pointer-events CSS — no DOM change, no flicker.
if (this.needsBlockerSuspend) {
await suspendInputBlocker(this.browserManager);
}
const result: McpToolCallResult = await this.browserManager.callTool(
this.toolName,
this.params,
signal,
);
const result: McpToolCallResult = await callToolPromise;
// Extract text content from MCP response
let textContent = '';
if (result.content && Array.isArray(result.content)) {
@@ -103,6 +134,11 @@ class McpToolInvocation extends BaseToolInvocation<
textContent,
);
// Resume input blocker after interactive tool completes.
if (this.needsBlockerSuspend) {
await resumeInputBlocker(this.browserManager);
}
if (result.isError) {
return {
llmContent: `Error: ${processedContent}`,
@@ -124,6 +160,11 @@ class McpToolInvocation extends BaseToolInvocation<
throw error;
}
// Resume on error path too so the blocker is always restored
if (this.needsBlockerSuspend) {
await resumeInputBlocker(this.browserManager).catch(() => {});
}
debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`);
return {
llmContent: `Error: ${errorMsg}`,
@@ -285,6 +326,7 @@ class McpDeclarativeTool extends DeclarativeTool<
description: string,
parameterSchema: unknown,
messageBus: MessageBus,
private readonly shouldDisableInput: boolean,
) {
super(
name,
@@ -306,6 +348,7 @@ class McpDeclarativeTool extends DeclarativeTool<
this.name,
params,
this.messageBus,
this.shouldDisableInput,
);
}
}
@@ -385,12 +428,14 @@ class TypeTextDeclarativeTool extends DeclarativeTool<
export async function createMcpDeclarativeTools(
browserManager: BrowserManager,
messageBus: MessageBus,
shouldDisableInput: boolean = false,
): Promise<Array<McpDeclarativeTool | TypeTextDeclarativeTool>> {
// Get dynamically discovered tools from the MCP server
const mcpTools = await browserManager.getDiscoveredTools();
debugLogger.log(
`Creating ${mcpTools.length} declarative tools for browser agent`,
`Creating ${mcpTools.length} declarative tools for browser agent` +
(shouldDisableInput ? ' (input blocker enabled)' : ''),
);
const tools: Array<McpDeclarativeTool | TypeTextDeclarativeTool> =
@@ -407,6 +452,7 @@ export async function createMcpDeclarativeTools(
augmentedDescription,
schema.parametersJsonSchema,
messageBus,
shouldDisableInput,
);
});
@@ -33,6 +33,7 @@ import {
type PartListUnion,
type Tool,
type CallableTool,
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import { MockTool } from '../test-utils/mock-tool.js';
@@ -560,6 +561,34 @@ describe('LocalAgentExecutor', () => {
getToolSpy.mockRestore();
});
it('should not duplicate schemas when instantiated tools are provided in toolConfig', async () => {
// Create an instantiated mock tool
const instantiatedTool = new MockTool({ name: 'instantiated_tool' });
// Create an agent definition containing the instantiated tool
const definition = createTestDefinition([instantiatedTool]);
// Create the executor
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Extract the prepared tools list using the private method
const toolsList = (
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
).prepareToolsList();
// Filter for the specific tool schema
const foundSchemas = (
toolsList as unknown as FunctionDeclaration[]
).filter((t: FunctionDeclaration) => t.name === 'instantiated_tool');
// Assert that there is exactly ONE schema for this tool
expect(foundSchemas).toHaveLength(1);
});
});
describe('run (Execution Loop and Logic)', () => {
+48 -23
View File
@@ -17,7 +17,13 @@ import {
type Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { type AnyDeclarativeTool } from '../tools/tools.js';
import {
DiscoveredMCPTool,
isMcpToolName,
parseMcpToolName,
MCP_TOOL_PREFIX,
} from '../tools/mcp-tool.js';
import { CompressionStatus } from '../core/turn.js';
import { type ToolCallRequestInfo } from '../scheduler/types.js';
import { type Message } from '../confirmation-bus/types.js';
@@ -146,28 +152,55 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
context.config.getAgentRegistry().getAllAgentNames(),
);
const registerToolByName = (toolName: string) => {
const registerToolInstance = (tool: AnyDeclarativeTool) => {
// Check if the tool is a subagent to prevent recursion.
// We do not allow agents to call other agents.
if (allAgentNames.has(toolName)) {
if (allAgentNames.has(tool.name)) {
debugLogger.warn(
`[LocalAgentExecutor] Skipping subagent tool '${toolName}' for agent '${definition.name}' to prevent recursion.`,
`[LocalAgentExecutor] Skipping subagent tool '${tool.name}' for agent '${definition.name}' to prevent recursion.`,
);
return;
}
agentToolRegistry.registerTool(tool);
};
const registerToolByName = (toolName: string) => {
// Handle global wildcard
if (toolName === '*') {
for (const tool of parentToolRegistry.getAllTools()) {
registerToolInstance(tool);
}
return;
}
// Handle MCP wildcards
if (isMcpToolName(toolName)) {
if (toolName === `${MCP_TOOL_PREFIX}*`) {
for (const tool of parentToolRegistry.getAllTools()) {
if (tool instanceof DiscoveredMCPTool) {
registerToolInstance(tool);
}
}
return;
}
const parsed = parseMcpToolName(toolName);
if (parsed.serverName && parsed.toolName === '*') {
for (const tool of parentToolRegistry.getToolsByServer(
parsed.serverName,
)) {
registerToolInstance(tool);
}
return;
}
}
// If the tool is referenced by name, retrieve it from the parent
// registry and register it with the agent's isolated registry.
const tool = parentToolRegistry.getTool(toolName);
if (tool) {
if (tool instanceof DiscoveredMCPTool) {
// Subagents MUST use fully qualified names for MCP tools to ensure
// unambiguous tool calls and to comply with policy requirements.
// We automatically "upgrade" any MCP tool to its qualified version.
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
} else {
agentToolRegistry.registerTool(tool);
}
registerToolInstance(tool);
}
};
@@ -1175,22 +1208,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const { toolConfig, outputConfig } = this.definition;
if (toolConfig) {
const toolNamesToLoad: string[] = [];
for (const toolRef of toolConfig.tools) {
if (typeof toolRef === 'string') {
toolNamesToLoad.push(toolRef);
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
// Tool instance with an explicit schema property.
toolsList.push(toolRef.schema);
} else {
if (typeof toolRef === 'object' && !('schema' in toolRef)) {
// Raw `FunctionDeclaration` object.
toolsList.push(toolRef);
}
}
// Add schemas from tools that were registered by name.
toolsList.push(
...this.toolRegistry.getFunctionDeclarationsFiltered(toolNamesToLoad),
);
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
}
// Always inject complete_task.
@@ -593,6 +593,7 @@ describe('AgentRegistry', () => {
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
authConfig: mockAuth,
agentName: 'RemoteAgentWithAuth',
targetUrl: 'https://example.com/card',
agentCardUrl: 'https://example.com/card',
});
expect(loadAgentSpy).toHaveBeenCalledWith(
+3 -2
View File
@@ -69,7 +69,7 @@ export class AgentRegistry {
* Clears the current registry and re-scans for agents.
*/
async reload(): Promise<void> {
A2AClientManager.getInstance().clearCache();
A2AClientManager.getInstance(this.config).clearCache();
await this.config.reloadAgents();
this.agents.clear();
this.allDefinitions.clear();
@@ -414,12 +414,13 @@ export class AgentRegistry {
// Load the remote A2A agent card and register.
try {
const clientManager = A2AClientManager.getInstance();
const clientManager = A2AClientManager.getInstance(this.config);
let authHandler: AuthenticationHandler | undefined;
if (definition.auth) {
const provider = await A2AAuthProviderFactory.create({
authConfig: definition.auth,
agentName: definition.name,
targetUrl: definition.agentCardUrl,
agentCardUrl: remoteDef.agentCardUrl,
});
if (!provider) {
@@ -195,6 +195,7 @@ describe('RemoteAgentInvocation', () => {
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
authConfig: mockAuth,
agentName: 'test-agent',
targetUrl: 'http://test-agent/card',
agentCardUrl: 'http://test-agent/card',
});
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
+1 -34
View File
@@ -22,7 +22,6 @@ import {
type SendMessageResult,
} from './a2a-client-manager.js';
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import { GoogleAuth } from 'google-auth-library';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
@@ -30,39 +29,6 @@ import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
/**
* Authentication handler implementation using Google Application Default Credentials (ADC).
*/
export class ADCHandler implements AuthenticationHandler {
private auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
async headers(): Promise<Record<string, string>> {
try {
const client = await this.auth.getClient();
const token = await client.getAccessToken();
if (token.token) {
return { Authorization: `Bearer ${token.token}` };
}
throw new Error('Failed to retrieve ADC access token.');
} catch (e) {
const errorMessage = `Failed to get ADC token: ${
e instanceof Error ? e.message : String(e)
}`;
debugLogger.log('ERROR', errorMessage);
throw new Error(errorMessage);
}
}
async shouldRetryWithHeaders(
_response: unknown,
): Promise<Record<string, string> | undefined> {
// For ADC, we usually just re-fetch the token if needed.
return this.headers();
}
}
/**
* A tool invocation that proxies to a remote A2A agent.
*
@@ -121,6 +87,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth,
agentName: this.definition.name,
targetUrl: this.definition.agentCardUrl,
agentCardUrl: this.definition.agentCardUrl,
});
if (!provider) {
@@ -103,9 +103,19 @@ describe('SubagentToolWrapper', () => {
expect(schema.name).toBe(mockDefinition.name);
expect(schema.description).toBe(mockDefinition.description);
expect(schema.parametersJsonSchema).toEqual(
mockDefinition.inputConfig.inputSchema,
);
expect(schema.parametersJsonSchema).toEqual({
...(mockDefinition.inputConfig.inputSchema as Record<string, unknown>),
properties: {
...((
mockDefinition.inputConfig.inputSchema as Record<string, unknown>
)['properties'] as Record<string, unknown>),
wait_for_previous: {
type: 'boolean',
description:
'Set to true to wait for all previously requested tools in this turn to complete before starting. Set to false (or omit) to run in parallel. Use true when this tool depends on the output of previous tools.',
},
},
});
});
});
+22
View File
@@ -316,6 +316,8 @@ export interface BrowserAgentCustomConfig {
profilePath?: string;
/** Model override for the visual agent. */
visualModel?: string;
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
disableUserInput?: boolean;
}
/**
@@ -502,6 +504,7 @@ export interface PolicyUpdateConfirmationRequest {
export interface ConfigParameters {
sessionId: string;
clientName?: string;
clientVersion?: string;
embeddingModel?: string;
sandbox?: SandboxConfig;
@@ -646,6 +649,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
private skillManager!: SkillManager;
private _sessionId: string;
private readonly clientName: string | undefined;
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
@@ -843,6 +847,7 @@ export class Config implements McpContext, AgentLoopContext {
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
this.clientName = params.clientName;
this.clientVersion = params.clientVersion ?? 'unknown';
this.approvedPlanPath = undefined;
this.embeddingModel =
@@ -1408,6 +1413,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.promptId;
}
getClientName(): string | undefined {
return this.clientName;
}
setSessionId(sessionId: string): void {
this._sessionId = sessionId;
}
@@ -2881,10 +2890,23 @@ export class Config implements McpContext, AgentLoopContext {
headless: customConfig.headless ?? false,
profilePath: customConfig.profilePath,
visualModel: customConfig.visualModel,
disableUserInput: customConfig.disableUserInput,
},
};
}
/**
* Determines if user input should be disabled during browser automation.
* Based on the `disableUserInput` setting and `headless` mode.
*/
shouldDisableBrowserUserInput(): boolean {
const browserConfig = this.getBrowserAgentConfig();
return (
browserConfig.customConfig?.disableUserInput !== false &&
!browserConfig.customConfig?.headless
);
}
async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.messageBus);
+64
View File
@@ -22,6 +22,7 @@ import {
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
@@ -30,6 +31,10 @@ import {
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
isValidModelOrAlias,
getValidModelsAndAliases,
VALID_GEMINI_MODELS,
VALID_ALIASES,
} from './models.js';
describe('isPreviewModel', () => {
@@ -389,3 +394,62 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('isValidModelOrAlias', () => {
it('should return true for valid model names', () => {
expect(isValidModelOrAlias(DEFAULT_GEMINI_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_3_1_MODEL)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe(
true,
);
});
it('should return true for valid aliases', () => {
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_AUTO)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_PRO)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_FLASH)).toBe(true);
expect(isValidModelOrAlias(GEMINI_MODEL_ALIAS_FLASH_LITE)).toBe(true);
expect(isValidModelOrAlias(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
expect(isValidModelOrAlias(DEFAULT_GEMINI_MODEL_AUTO)).toBe(true);
});
it('should return true for custom (non-gemini) models', () => {
expect(isValidModelOrAlias('gpt-4')).toBe(true);
expect(isValidModelOrAlias('claude-3')).toBe(true);
expect(isValidModelOrAlias('my-custom-model')).toBe(true);
});
it('should return false for invalid gemini model names', () => {
expect(isValidModelOrAlias('gemini-4-pro')).toBe(false);
expect(isValidModelOrAlias('gemini-99-flash')).toBe(false);
expect(isValidModelOrAlias('gemini-invalid')).toBe(false);
});
});
describe('getValidModelsAndAliases', () => {
it('should return a sorted array', () => {
const result = getValidModelsAndAliases();
const sorted = [...result].sort();
expect(result).toEqual(sorted);
});
it('should include all valid models and aliases', () => {
const result = getValidModelsAndAliases();
for (const model of VALID_GEMINI_MODELS) {
expect(result).toContain(model);
}
for (const alias of VALID_ALIASES) {
expect(result).toContain(alias);
}
});
it('should not contain duplicates', () => {
const result = getValidModelsAndAliases();
const unique = [...new Set(result)];
expect(result).toEqual(unique);
});
});
+43
View File
@@ -32,6 +32,15 @@ export const GEMINI_MODEL_ALIAS_PRO = 'pro';
export const GEMINI_MODEL_ALIAS_FLASH = 'flash';
export const GEMINI_MODEL_ALIAS_FLASH_LITE = 'flash-lite';
export const VALID_ALIASES = new Set([
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_PRO,
GEMINI_MODEL_ALIAS_FLASH,
GEMINI_MODEL_ALIAS_FLASH_LITE,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO,
]);
export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
// Cap the thinking at 8192 to prevent run-away thinking loops.
@@ -283,3 +292,37 @@ export function isActiveModel(
);
}
}
/**
* Checks if the model name is valid (either a valid model or a valid alias).
*
* @param model The model name to check.
* @returns True if the model is valid.
*/
export function isValidModelOrAlias(model: string): boolean {
// Check if it's a valid alias
if (VALID_ALIASES.has(model)) {
return true;
}
// Check if it's a valid model name
if (VALID_GEMINI_MODELS.has(model)) {
return true;
}
// Allow custom models (non-gemini models)
if (!model.startsWith('gemini-')) {
return true;
}
return false;
}
/**
* Gets a list of all valid model names and aliases for error messages.
*
* @returns Array of valid model names and aliases.
*/
export function getValidModelsAndAliases(): string[] {
return [...new Set([...VALID_ALIASES, ...VALID_GEMINI_MODELS])].sort();
}
@@ -158,7 +158,8 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -334,7 +335,8 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -617,7 +619,8 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -770,7 +773,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -909,7 +913,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
@@ -1031,7 +1036,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
@@ -1670,7 +1676,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -1823,7 +1830,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -1980,7 +1988,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2137,7 +2146,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2290,7 +2300,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2435,7 +2446,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2587,7 +2599,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2740,7 +2753,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -2904,7 +2918,8 @@ You are operating with a persistent file-based task tracking system located at \
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -3298,7 +3313,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -3451,7 +3467,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -3716,7 +3733,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -3869,7 +3887,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
@@ -33,6 +33,7 @@ const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
describe('createContentGenerator', () => {
@@ -53,6 +54,7 @@ describe('createContentGenerator', () => {
const fakeResponsesFile = 'fake/responses.yaml';
const mockConfigWithFake = {
fakeResponses: fakeResponsesFile,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const generator = await createContentGenerator(
{
@@ -74,6 +76,7 @@ describe('createContentGenerator', () => {
const mockConfigWithRecordResponses = {
fakeResponses: fakeResponsesFile,
recordResponses: recordResponsesFile,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const generator = await createContentGenerator(
{
@@ -123,6 +126,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
// Set a fixed version for testing
@@ -144,7 +148,9 @@ describe('createContentGenerator', () => {
vertexai: undefined,
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringContaining('GeminiCLI/1.2.3/gemini-pro'),
'User-Agent': expect.stringMatching(
/GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; .*\)/,
),
}),
}),
});
@@ -153,6 +159,40 @@ describe('createContentGenerator', () => {
);
});
it('should include clientName prefix in User-Agent when specified', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => true,
getClientName: vi.fn().mockReturnValue('a2a-server'),
} as unknown as Config;
// Set a fixed version for testing
vi.stubEnv('CLI_VERSION', '1.2.3');
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,
undefined,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(
/GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/,
),
}),
}),
}),
);
});
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => {
const mockGenerator = {} as unknown as ContentGenerator;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
@@ -189,6 +229,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -235,6 +276,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -268,6 +310,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -309,6 +352,7 @@ describe('createContentGenerator', () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
@@ -340,6 +384,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -373,6 +418,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -410,6 +456,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -448,6 +495,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -478,6 +526,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -505,10 +554,13 @@ describe('createContentGenerator', () => {
});
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 = {
@@ -538,6 +590,7 @@ describe('createContentGenerator', () => {
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');
@@ -558,6 +611,7 @@ describe('createContentGenerator', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
@@ -594,6 +648,7 @@ describe('createContentGeneratorConfig', () => {
setModel: vi.fn(),
flashFallbackHandler: vi.fn(),
getProxy: vi.fn(),
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
beforeEach(() => {
+7 -1
View File
@@ -22,6 +22,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js';
import { InstallationManager } from '../utils/installationManager.js';
import { FakeContentGenerator } from './fakeContentGenerator.js';
import { parseCustomHeaders } from '../utils/customHeaderUtils.js';
import { determineSurface } from '../utils/surface.js';
import { RecordingContentGenerator } from './recordingContentGenerator.js';
import { getVersion, resolveModel } from '../../index.js';
import type { LlmRole } from '../telemetry/llmRole.js';
@@ -173,7 +174,12 @@ export async function createContentGenerator(
);
const customHeadersEnv =
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`;
const clientName = gcConfig.getClientName();
const userAgentPrefix = clientName
? `GeminiCLI-${clientName}`
: 'GeminiCLI';
const surface = determineSurface();
const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
const apiKeyAuthMechanism =
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';
@@ -11,6 +11,7 @@ import {
BaseToolInvocation,
type ToolResult,
type AnyDeclarativeTool,
type ToolLiveOutput,
} from '../tools/tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { HookSystem } from '../hooks/hookSystem.js';
@@ -37,6 +38,30 @@ class MockInvocation extends BaseToolInvocation<{ key?: string }, ToolResult> {
}
}
class MockBackgroundableInvocation extends BaseToolInvocation<
{ key?: string },
ToolResult
> {
constructor(params: { key?: string }, messageBus: MessageBus) {
super(params, messageBus);
}
getDescription() {
return 'mock-pid';
}
async execute(
_signal: AbortSignal,
_updateOutput?: (output: ToolLiveOutput) => void,
_shellExecutionConfig?: unknown,
setExecutionIdCallback?: (executionId: number) => void,
) {
setExecutionIdCallback?.(4242);
return {
llmContent: 'pid',
returnDisplay: 'pid',
};
}
}
describe('executeToolWithHooks', () => {
let messageBus: MessageBus;
let mockTool: AnyDeclarativeTool;
@@ -258,4 +283,26 @@ describe('executeToolWithHooks', () => {
expect(invocation.params.key).toBe('original');
expect(mockTool.build).not.toHaveBeenCalled();
});
it('should pass execution ID callback through for non-shell invocations', async () => {
const invocation = new MockBackgroundableInvocation({}, messageBus);
const abortSignal = new AbortController().signal;
const setExecutionIdCallback = vi.fn();
vi.mocked(mockHookSystem.fireBeforeToolEvent).mockResolvedValue(undefined);
vi.mocked(mockHookSystem.fireAfterToolEvent).mockResolvedValue(undefined);
await executeToolWithHooks(
invocation,
'test_tool',
abortSignal,
mockTool,
undefined,
undefined,
setExecutionIdCallback,
mockConfig,
);
expect(setExecutionIdCallback).toHaveBeenCalledWith(4242);
});
});
+12 -21
View File
@@ -15,7 +15,6 @@ import type {
import { ToolErrorType } from '../tools/tool-error.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { ShellExecutionConfig } from '../index.js';
import { ShellToolInvocation } from '../tools/shell.js';
import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js';
/**
@@ -26,7 +25,7 @@ import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js';
* @returns MCP context if this is an MCP tool, undefined otherwise
*/
function extractMcpContext(
invocation: ShellToolInvocation | AnyToolInvocation,
invocation: AnyToolInvocation,
config: Config,
): McpToolContext | undefined {
if (!(invocation instanceof DiscoveredMCPToolInvocation)) {
@@ -63,18 +62,18 @@ function extractMcpContext(
* @param signal Abort signal for cancellation
* @param liveOutputCallback Optional callback for live output updates
* @param shellExecutionConfig Optional shell execution config
* @param setPidCallback Optional callback to set the PID for shell invocations
* @param setExecutionIdCallback Optional callback to set an execution ID for backgroundable invocations
* @param config Config to look up MCP server details for hook context
* @returns The tool result
*/
export async function executeToolWithHooks(
invocation: ShellToolInvocation | AnyToolInvocation,
invocation: AnyToolInvocation,
toolName: string,
signal: AbortSignal,
tool: AnyDeclarativeTool,
liveOutputCallback?: (outputChunk: ToolLiveOutput) => void,
shellExecutionConfig?: ShellExecutionConfig,
setPidCallback?: (pid: number) => void,
setExecutionIdCallback?: (executionId: number) => void,
config?: Config,
originalRequestName?: string,
): Promise<ToolResult> {
@@ -154,22 +153,14 @@ export async function executeToolWithHooks(
}
}
// Execute the actual tool
let toolResult: ToolResult;
if (setPidCallback && invocation instanceof ShellToolInvocation) {
toolResult = await invocation.execute(
signal,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
);
} else {
toolResult = await invocation.execute(
signal,
liveOutputCallback,
shellExecutionConfig,
);
}
// Execute the actual tool. Tools that support backgrounding can optionally
// surface an execution ID via the callback.
const toolResult: ToolResult = await invocation.execute(
signal,
liveOutputCallback,
shellExecutionConfig,
setExecutionIdCallback,
);
// Append notification if parameters were modified
if (inputWasModified) {
+15
View File
@@ -140,6 +140,21 @@ describe('detectIde', () => {
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
});
it('should detect Zed via ZED_SESSION_ID', () => {
vi.stubEnv('ZED_SESSION_ID', 'test-session-id');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.zed);
});
it('should detect Zed via TERM_PROGRAM', () => {
vi.stubEnv('TERM_PROGRAM', 'Zed');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.zed);
});
it('should detect XCode via XCODE_VERSION_ACTUAL', () => {
vi.stubEnv('XCODE_VERSION_ACTUAL', '1500');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.xcode);
});
it('should detect JetBrains IDE via TERMINAL_EMULATOR', () => {
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
+12 -1
View File
@@ -27,6 +27,8 @@ export const IDE_DEFINITIONS = {
rustrover: { name: 'rustrover', displayName: 'RustRover' },
datagrip: { name: 'datagrip', displayName: 'DataGrip' },
phpstorm: { name: 'phpstorm', displayName: 'PhpStorm' },
zed: { name: 'zed', displayName: 'Zed' },
xcode: { name: 'xcode', displayName: 'XCode' },
} as const;
export interface IdeInfo {
@@ -75,6 +77,12 @@ export function detectIdeFromEnv(): IdeInfo {
if (process.env['TERM_PROGRAM'] === 'sublime') {
return IDE_DEFINITIONS.sublimetext;
}
if (process.env['ZED_SESSION_ID'] || process.env['TERM_PROGRAM'] === 'Zed') {
return IDE_DEFINITIONS.zed;
}
if (process.env['XCODE_VERSION_ACTUAL']) {
return IDE_DEFINITIONS.xcode;
}
if (isJetBrains()) {
return IDE_DEFINITIONS.jetbrains;
}
@@ -147,10 +155,13 @@ export function detectIde(
};
}
// Only VS Code, Sublime Text and JetBrains integrations are currently supported.
// Only VS Code, Sublime Text, JetBrains, Zed, and XCode integrations are currently supported.
if (
process.env['TERM_PROGRAM'] !== 'vscode' &&
process.env['TERM_PROGRAM'] !== 'sublime' &&
process.env['TERM_PROGRAM'] !== 'Zed' &&
!process.env['ZED_SESSION_ID'] &&
!process.env['XCODE_VERSION_ACTUAL'] &&
!isJetBrains()
) {
return undefined;
+97 -6
View File
@@ -281,15 +281,105 @@ describe('AntigravityInstaller', () => {
);
});
it('returns a failure message if the alias is not set', async () => {
it('ignores an unsafe alias and falls back to safe commands', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy;malicious_command');
const { installer } = setup();
vi.mocked(child_process.execSync).mockImplementationOnce(() => 'agy');
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenCalledTimes(1);
expect(child_process.execSync).toHaveBeenCalledWith('command -v agy', {
stdio: 'ignore',
});
expect(child_process.spawnSync).toHaveBeenCalledWith(
'agy',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: false },
);
});
it('falls back to antigravity when agy is unavailable on linux', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
const { installer } = setup();
vi.mocked(child_process.execSync)
.mockImplementationOnce(() => {
throw new Error('Command not found');
})
.mockImplementationOnce(() => 'antigravity');
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenNthCalledWith(
1,
'command -v agy',
{
stdio: 'ignore',
},
);
expect(child_process.execSync).toHaveBeenNthCalledWith(
2,
'command -v antigravity',
{ stdio: 'ignore' },
);
expect(child_process.spawnSync).toHaveBeenCalledWith(
'antigravity',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: false },
);
});
it('falls back to antigravity.cmd when agy.cmd is unavailable on windows', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy.cmd');
const { installer } = setup({
platform: 'win32',
});
vi.mocked(child_process.execSync)
.mockImplementationOnce(() => {
throw new Error('Command not found');
})
.mockImplementationOnce(
() => 'C:\\Program Files\\Antigravity\\bin\\antigravity.cmd',
);
const result = await installer.install();
expect(result.success).toBe(true);
expect(child_process.execSync).toHaveBeenNthCalledWith(
1,
'where.exe agy.cmd',
);
expect(child_process.execSync).toHaveBeenNthCalledWith(
2,
'where.exe antigravity.cmd',
);
expect(child_process.spawnSync).toHaveBeenCalledWith(
'C:\\Program Files\\Antigravity\\bin\\antigravity.cmd',
[
'--install-extension',
'google.gemini-cli-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe', shell: true },
);
});
it('falls back to default commands if the alias is not set', async () => {
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
const { installer } = setup({});
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain(
'ANTIGRAVITY_CLI_ALIAS environment variable not set',
);
expect(result.success).toBe(true);
});
it('returns a failure message if the command is not found', async () => {
@@ -302,6 +392,7 @@ describe('AntigravityInstaller', () => {
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain('not-a-command not found');
expect(result.message).toContain('Antigravity CLI not found');
expect(result.message).toContain('agy, antigravity');
});
});
+25 -8
View File
@@ -252,19 +252,36 @@ class AntigravityInstaller implements IdeInstaller {
) {}
async install(): Promise<InstallResult> {
const command = process.env['ANTIGRAVITY_CLI_ALIAS'];
if (!command) {
return {
success: false,
message: 'ANTIGRAVITY_CLI_ALIAS environment variable not set.',
};
const envCommand = process.env['ANTIGRAVITY_CLI_ALIAS'];
const safeCommandPattern = /^[a-zA-Z0-9.\-_/\\]+$/;
const sanitizedEnvCommand =
envCommand && safeCommandPattern.test(envCommand)
? envCommand
: undefined;
const fallbackCommands =
this.platform === 'win32'
? ['agy.cmd', 'antigravity.cmd']
: ['agy', 'antigravity'];
const commands = [
...(sanitizedEnvCommand ? [sanitizedEnvCommand] : []),
...fallbackCommands,
].filter(
(command, index, allCommands) => allCommands.indexOf(command) === index,
);
let commandPath: string | null = null;
for (const command of commands) {
commandPath = await findCommand(command, this.platform);
if (commandPath) {
break;
}
}
const commandPath = await findCommand(command, this.platform);
if (!commandPath) {
const supportedCommands = fallbackCommands.join(', ');
return {
success: false,
message: `${command} not found. Please ensure it is in your system's PATH.`,
message: `Antigravity CLI not found. Please ensure one of these commands is in your system's PATH: ${supportedCommands}.`,
};
}
+50
View File
@@ -19,6 +19,7 @@ import { isDirectorySecure } from '../utils/security.js';
import {
createPolicyEngineConfig,
clearEmittedPolicyWarnings,
getPolicyDirectories,
} from './config.js';
import { Storage } from '../config/storage.js';
import * as tomlLoader from './toml-loader.js';
@@ -746,3 +747,52 @@ modes = ["plan"]
feedbackSpy.mockRestore();
});
});
describe('getPolicyDirectories', () => {
const USER_POLICIES_DIR = '/mock/user/policies';
const SYSTEM_POLICIES_DIR = '/mock/system/policies';
beforeEach(() => {
vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(USER_POLICIES_DIR);
vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
SYSTEM_POLICIES_DIR,
);
});
it('should include default user policies directory when policyPaths is undefined', () => {
const dirs = getPolicyDirectories();
expect(dirs).toContain(USER_POLICIES_DIR);
});
it('should include default user policies directory when policyPaths is an empty array', () => {
// This is the specific case that regressed
const dirs = getPolicyDirectories(undefined, []);
expect(dirs).toContain(USER_POLICIES_DIR);
});
it('should replace default user policies directory when policyPaths has entries', () => {
const customPath = '/custom/policies';
const dirs = getPolicyDirectories(undefined, [customPath]);
expect(dirs).toContain(customPath);
expect(dirs).not.toContain(USER_POLICIES_DIR);
});
it('should include all tiers in correct order', () => {
const defaultDir = '/default/policies';
const workspaceDir = '/workspace/policies';
const adminPath = '/admin/extra/policies';
const userPath = '/user/custom/policies';
const dirs = getPolicyDirectories(defaultDir, [userPath], workspaceDir, [
adminPath,
]);
// Order should be Admin -> User -> Workspace -> Default
// getPolicyDirectories returns them in that order (which is then reversed by the loader)
expect(dirs[0]).toBe(SYSTEM_POLICIES_DIR);
expect(dirs[1]).toBe(adminPath);
expect(dirs[2]).toBe(userPath);
expect(dirs[3]).toBe(workspaceDir);
expect(dirs[4]).toBe(defaultDir);
});
});
+3 -1
View File
@@ -124,7 +124,9 @@ export function getPolicyDirectories(
...(adminPolicyPaths ?? []),
// User tier (second highest priority)
...(policyPaths ?? [Storage.getUserPoliciesDir()]),
...(policyPaths && policyPaths.length > 0
? policyPaths
: [Storage.getUserPoliciesDir()]),
// Workspace Tier (third highest)
workspacePoliciesDir,
+1 -1
View File
@@ -98,7 +98,7 @@ toolName = ["write_file", "replace"]
decision = "allow"
priority = 70
modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
# Explicitly Deny other write operations in Plan mode with a clear message.
[[rule]]
+22 -5
View File
@@ -57,7 +57,11 @@
* // Returns: '{"safe":"data"}'
*/
export function stableStringify(obj: unknown): string {
const stringify = (currentObj: unknown, ancestors: Set<unknown>): string => {
const stringify = (
currentObj: unknown,
ancestors: Set<unknown>,
isTopLevel = false,
): string => {
// Handle primitives and null
if (currentObj === undefined) {
return 'null'; // undefined in arrays becomes null in JSON
@@ -89,7 +93,10 @@ export function stableStringify(obj: unknown): string {
if (jsonValue === null) {
return 'null';
}
return stringify(jsonValue, ancestors);
// The result of toJSON is effectively a new object graph, but it
// takes the place of the current node, so we preserve the top-level
// status of the current node.
return stringify(jsonValue, ancestors, isTopLevel);
} catch {
// If toJSON throws, treat as a regular object
}
@@ -101,7 +108,7 @@ export function stableStringify(obj: unknown): string {
if (item === undefined || typeof item === 'function') {
return 'null';
}
return stringify(item, ancestors);
return stringify(item, ancestors, false);
});
return '[' + items.join(',') + ']';
}
@@ -115,7 +122,17 @@ export function stableStringify(obj: unknown): string {
const value = (currentObj as Record<string, unknown>)[key];
// Skip undefined and function values in objects (per JSON spec)
if (value !== undefined && typeof value !== 'function') {
pairs.push(JSON.stringify(key) + ':' + stringify(value, ancestors));
let pairStr =
JSON.stringify(key) + ':' + stringify(value, ancestors, false);
if (isTopLevel) {
// We use a null byte (\0) to denote structural boundaries.
// This is safe because any literal \0 in the user's data will
// be escaped by JSON.stringify into "\u0000" before reaching here.
pairStr = '\0' + pairStr + '\0';
}
pairs.push(pairStr);
}
}
@@ -125,5 +142,5 @@ export function stableStringify(obj: unknown): string {
}
};
return stringify(obj, new Set());
return stringify(obj, new Set(), true);
}
+32 -8
View File
@@ -89,6 +89,25 @@ export function buildArgsPatterns(
return [argsPattern];
}
/**
* Builds a regex pattern to match a specific parameter and value in tool arguments.
* This is used to narrow tool approvals to specific parameters.
*
* @param paramName The name of the parameter.
* @param value The value to match.
* @returns A regex string that matches "<paramName>":<value> in a JSON string.
*/
export function buildParamArgsPattern(
paramName: string,
value: unknown,
): string {
const encodedValue = JSON.stringify(value);
// We wrap the JSON string in escapeRegex and prepend/append \\0 to explicitly
// match top-level JSON properties generated by stableStringify, preventing
// argument injection bypass attacks.
return `\\\\0${escapeRegex(`"${paramName}":${encodedValue}`)}\\\\0`;
}
/**
* Builds a regex pattern to match a specific file path in tool arguments.
* This is used to narrow tool approvals for edit tools to specific files.
@@ -97,11 +116,18 @@ export function buildArgsPatterns(
* @returns A regex string that matches "file_path":"<path>" in a JSON string.
*/
export function buildFilePathArgsPattern(filePath: string): string {
const encodedPath = JSON.stringify(filePath);
// We must wrap the JSON string in escapeRegex to ensure regex control characters
// (like '.' in file extensions) are treated as literals, preventing overly broad
// matches (e.g. 'foo.ts' matching 'fooXts').
return escapeRegex(`"file_path":${encodedPath}`);
return buildParamArgsPattern('file_path', filePath);
}
/**
* Builds a regex pattern to match a specific directory path in tool arguments.
* This is used to narrow tool approvals for list_directory tool.
*
* @param dirPath The path to the directory.
* @returns A regex string that matches "dir_path":"<path>" in a JSON string.
*/
export function buildDirPathArgsPattern(dirPath: string): string {
return buildParamArgsPattern('dir_path', dirPath);
}
/**
@@ -112,7 +138,5 @@ export function buildFilePathArgsPattern(filePath: string): string {
* @returns A regex string that matches "pattern":"<pattern>" in a JSON string.
*/
export function buildPatternArgsPattern(pattern: string): string {
const encodedPattern = JSON.stringify(pattern);
// We use escapeRegex to ensure regex control characters are treated as literals.
return escapeRegex(`"pattern":${encodedPattern}`);
return buildParamArgsPattern('pattern', pattern);
}
+2 -1
View File
@@ -355,7 +355,8 @@ export function renderOperationalGuidelines(
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
- **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
options.interactive,
options.interactiveShellEnabled,
+2 -1
View File
@@ -660,7 +660,8 @@ describe('policy.ts', () => {
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'write_file',
argsPattern: escapeRegex('"file_path":"src/foo.ts"'),
argsPattern:
'\\\\0' + escapeRegex('"file_path":"src/foo.ts"') + '\\\\0',
}),
);
});
@@ -134,7 +134,7 @@ describe('Scheduler (Orchestrator)', () => {
const req2: ToolCallRequestInfo = {
callId: 'call-2',
name: 'test-tool',
args: { foo: 'baz' },
args: { foo: 'baz', wait_for_previous: true },
isClientInitiated: false,
prompt_id: 'prompt-1',
schedulerId: ROOT_SCHEDULER_ID,
+12 -6
View File
@@ -29,7 +29,6 @@ import { PolicyDecision, type ApprovalMode } from '../policy/types.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
Kind,
} from '../tools/tools.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
@@ -434,10 +433,10 @@ export class Scheduler {
}
// If the first tool is parallelizable, batch all contiguous parallelizable tools.
if (this._isParallelizable(next.tool)) {
if (this._isParallelizable(next.request)) {
while (this.state.queueLength > 0) {
const peeked = this.state.peekQueue();
if (peeked && this._isParallelizable(peeked.tool)) {
if (peeked && this._isParallelizable(peeked.request)) {
this.state.dequeue();
} else {
break;
@@ -522,9 +521,16 @@ export class Scheduler {
return false;
}
private _isParallelizable(tool?: AnyDeclarativeTool): boolean {
if (!tool) return false;
return tool.isReadOnly || tool.kind === Kind.Agent;
private _isParallelizable(request: ToolCallRequestInfo): boolean {
if (request.args) {
const wait = request.args['wait_for_previous'];
if (typeof wait === 'boolean') {
return !wait;
}
}
// Default to parallel if the flag is omitted.
return true;
}
private async _processValidatingCall(
@@ -119,7 +119,7 @@ describe('Scheduler Parallel Execution', () => {
const req3: ToolCallRequestInfo = {
callId: 'call-3',
name: 'write-tool',
args: { path: 'c.txt', content: 'hi' },
args: { path: 'c.txt', content: 'hi', wait_for_previous: true },
isClientInitiated: false,
prompt_id: 'p1',
schedulerId: ROOT_SCHEDULER_ID,
@@ -505,4 +505,50 @@ describe('Scheduler Parallel Execution', () => {
const start1 = executionLog.indexOf('start-call-1');
expect(start1).toBeGreaterThan(end3);
});
it('should execute non-read-only tools in parallel if wait_for_previous is false', async () => {
const executionLog: string[] = [];
mockExecutor.execute.mockImplementation(async ({ call }) => {
const id = call.request.callId;
executionLog.push(`start-${id}`);
await new Promise<void>((resolve) => setTimeout(resolve, 10));
executionLog.push(`end-${id}`);
return {
status: 'success',
response: { callId: id, responseParts: [] },
} as unknown as SuccessfulToolCall;
});
const w1 = { ...req3, callId: 'w1', args: { wait_for_previous: false } };
const w2 = { ...req3, callId: 'w2', args: { wait_for_previous: false } };
await scheduler.schedule([w1, w2], signal);
expect(executionLog.slice(0, 2)).toContain('start-w1');
expect(executionLog.slice(0, 2)).toContain('start-w2');
});
it('should execute read-only tools sequentially if wait_for_previous is true', async () => {
const executionLog: string[] = [];
mockExecutor.execute.mockImplementation(async ({ call }) => {
const id = call.request.callId;
executionLog.push(`start-${id}`);
await new Promise<void>((resolve) => setTimeout(resolve, 10));
executionLog.push(`end-${id}`);
return {
status: 'success',
response: { callId: id, responseParts: [] },
} as unknown as SuccessfulToolCall;
});
const r1 = { ...req1, callId: 'r1', args: { wait_for_previous: false } };
const r2 = { ...req1, callId: 'r2', args: { wait_for_previous: true } };
await scheduler.schedule([r1, r2], signal);
expect(executionLog[0]).toBe('start-r1');
expect(executionLog[1]).toBe('end-r1');
expect(executionLog[2]).toBe('start-r2');
expect(executionLog[3]).toBe('end-r2');
});
});
@@ -550,7 +550,7 @@ describe('ToolExecutor', () => {
expect(result.status).toBe(CoreToolCallStatus.Success);
});
it('should report PID updates for shell tools', async () => {
it('should report execution ID updates for backgroundable tools', async () => {
// 1. Setup ShellToolInvocation
const messageBus = createMockMessageBus();
const shellInvocation = new ShellToolInvocation(
@@ -561,7 +561,7 @@ describe('ToolExecutor', () => {
// We need a dummy tool that matches the invocation just for structure
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
// 2. Mock executeToolWithHooks to trigger the PID callback
// 2. Mock executeToolWithHooks to trigger the execution ID callback
const testPid = 12345;
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async (
@@ -571,13 +571,13 @@ describe('ToolExecutor', () => {
_tool,
_liveCb,
_shellCfg,
setPidCallback,
setExecutionIdCallback,
_config,
_originalRequestName,
) => {
// Simulate the shell tool reporting a PID
if (setPidCallback) {
setPidCallback(testPid);
// Simulate the tool reporting an execution ID
if (setExecutionIdCallback) {
setExecutionIdCallback(testPid);
}
return { llmContent: 'done', returnDisplay: 'done' };
},
@@ -606,7 +606,7 @@ describe('ToolExecutor', () => {
onUpdateToolCall,
});
// 4. Verify PID was reported
// 4. Verify execution ID was reported
expect(onUpdateToolCall).toHaveBeenCalledWith(
expect.objectContaining({
status: CoreToolCallStatus.Executing,
@@ -615,6 +615,59 @@ describe('ToolExecutor', () => {
);
});
it('should report execution ID updates for non-shell backgroundable tools', async () => {
const mockTool = new MockTool({
name: 'remote_agent_call',
description: 'Remote agent call',
});
const invocation = mockTool.build({});
const testExecutionId = 67890;
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async (
_inv,
_name,
_sig,
_tool,
_liveCb,
_shellCfg,
setExecutionIdCallback,
) => {
setExecutionIdCallback?.(testExecutionId);
return { llmContent: 'done', returnDisplay: 'done' };
},
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-remote-pid',
name: 'remote_agent_call',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-remote-pid',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const onUpdateToolCall = vi.fn();
await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall,
});
expect(onUpdateToolCall).toHaveBeenCalledWith(
expect.objectContaining({
status: CoreToolCallStatus.Executing,
pid: testExecutionId,
}),
);
});
it('should return cancelled result with partial output when signal is aborted', async () => {
const mockTool = new MockTool({
name: 'slowTool',
+22 -37
View File
@@ -18,7 +18,6 @@ import {
} from '../index.js';
import { isAbortError } from '../utils/errors.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { ShellToolInvocation } from '../tools/shell.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
import {
@@ -95,43 +94,29 @@ export class ToolExecutor {
let completedToolCall: CompletedToolCall;
try {
let promise: Promise<ToolResult>;
if (invocation instanceof ShellToolInvocation) {
const setPidCallback = (pid: number) => {
const executingCall: ExecutingToolCall = {
...call,
status: CoreToolCallStatus.Executing,
tool,
invocation,
pid,
startTime: 'startTime' in call ? call.startTime : undefined,
};
onUpdateToolCall(executingCall);
const setExecutionIdCallback = (executionId: number) => {
const executingCall: ExecutingToolCall = {
...call,
status: CoreToolCallStatus.Executing,
tool,
invocation,
pid: executionId,
startTime: 'startTime' in call ? call.startTime : undefined,
};
promise = executeToolWithHooks(
invocation,
toolName,
signal,
tool,
liveOutputCallback,
shellExecutionConfig,
setPidCallback,
this.config,
request.originalRequestName,
);
} else {
promise = executeToolWithHooks(
invocation,
toolName,
signal,
tool,
liveOutputCallback,
shellExecutionConfig,
undefined,
this.config,
request.originalRequestName,
);
}
onUpdateToolCall(executingCall);
};
const promise = executeToolWithHooks(
invocation,
toolName,
signal,
tool,
liveOutputCallback,
shellExecutionConfig,
setExecutionIdCallback,
this.config,
request.originalRequestName,
);
const toolResult: ToolResult = await promise;
@@ -0,0 +1,298 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ExecutionLifecycleService,
type ExecutionHandle,
type ExecutionResult,
} from './executionLifecycleService.js';
function createResult(
overrides: Partial<ExecutionResult> = {},
): ExecutionResult {
return {
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 123,
executionMethod: 'child_process',
...overrides,
};
}
describe('ExecutionLifecycleService', () => {
beforeEach(() => {
ExecutionLifecycleService.resetForTest();
});
it('completes managed executions in the foreground and notifies exit subscribers', async () => {
const handle = ExecutionLifecycleService.createExecution();
if (handle.pid === undefined) {
throw new Error('Expected execution ID.');
}
const onExit = vi.fn();
const unsubscribe = ExecutionLifecycleService.onExit(handle.pid, onExit);
ExecutionLifecycleService.appendOutput(handle.pid, 'Hello');
ExecutionLifecycleService.appendOutput(handle.pid, ' World');
ExecutionLifecycleService.completeExecution(handle.pid, {
exitCode: 0,
});
const result = await handle.result;
expect(result.output).toBe('Hello World');
expect(result.executionMethod).toBe('none');
expect(result.backgrounded).toBeUndefined();
await vi.waitFor(() => {
expect(onExit).toHaveBeenCalledWith(0, undefined);
});
unsubscribe();
});
it('supports explicit execution methods for managed executions', async () => {
const handle = ExecutionLifecycleService.createExecution(
'',
undefined,
'remote_agent',
);
if (handle.pid === undefined) {
throw new Error('Expected execution ID.');
}
ExecutionLifecycleService.completeExecution(handle.pid, {
exitCode: 0,
});
const result = await handle.result;
expect(result.executionMethod).toBe('remote_agent');
});
it('supports backgrounding managed executions and continues streaming updates', async () => {
const handle = ExecutionLifecycleService.createExecution();
if (handle.pid === undefined) {
throw new Error('Expected execution ID.');
}
const chunks: string[] = [];
const onExit = vi.fn();
const unsubscribeStream = ExecutionLifecycleService.subscribe(
handle.pid,
(event) => {
if (event.type === 'data' && typeof event.chunk === 'string') {
chunks.push(event.chunk);
}
},
);
const unsubscribeExit = ExecutionLifecycleService.onExit(
handle.pid,
onExit,
);
ExecutionLifecycleService.appendOutput(handle.pid, 'Chunk 1');
ExecutionLifecycleService.background(handle.pid);
const backgroundResult = await handle.result;
expect(backgroundResult.backgrounded).toBe(true);
expect(backgroundResult.output).toBe('Chunk 1');
ExecutionLifecycleService.appendOutput(handle.pid, '\nChunk 2');
ExecutionLifecycleService.completeExecution(handle.pid, {
exitCode: 0,
});
await vi.waitFor(() => {
expect(chunks.join('')).toContain('Chunk 2');
expect(onExit).toHaveBeenCalledWith(0, undefined);
});
unsubscribeStream();
unsubscribeExit();
});
it('kills managed executions and resolves with aborted result', async () => {
const onKill = vi.fn();
const handle = ExecutionLifecycleService.createExecution('', onKill);
if (handle.pid === undefined) {
throw new Error('Expected execution ID.');
}
ExecutionLifecycleService.appendOutput(handle.pid, 'work');
ExecutionLifecycleService.kill(handle.pid);
const result = await handle.result;
expect(onKill).toHaveBeenCalledTimes(1);
expect(result.aborted).toBe(true);
expect(result.exitCode).toBe(130);
expect(result.error?.message).toContain('Operation cancelled by user');
});
it('does not probe OS process state for completed non-process execution IDs', async () => {
const handle = ExecutionLifecycleService.createExecution();
if (handle.pid === undefined) {
throw new Error('Expected execution ID.');
}
ExecutionLifecycleService.completeExecution(handle.pid, { exitCode: 0 });
await handle.result;
const processKillSpy = vi.spyOn(process, 'kill');
expect(ExecutionLifecycleService.isActive(handle.pid)).toBe(false);
expect(processKillSpy).not.toHaveBeenCalled();
processKillSpy.mockRestore();
});
it('manages external executions through registration hooks', async () => {
const writeInput = vi.fn();
const isActive = vi.fn().mockReturnValue(true);
const exitListener = vi.fn();
const chunks: string[] = [];
let output = 'seed';
const handle: ExecutionHandle = ExecutionLifecycleService.attachExecution(
4321,
{
executionMethod: 'child_process',
getBackgroundOutput: () => output,
getSubscriptionSnapshot: () => output,
writeInput,
isActive,
},
);
const unsubscribe = ExecutionLifecycleService.subscribe(4321, (event) => {
if (event.type === 'data' && typeof event.chunk === 'string') {
chunks.push(event.chunk);
}
});
ExecutionLifecycleService.onExit(4321, exitListener);
ExecutionLifecycleService.writeInput(4321, 'stdin');
expect(writeInput).toHaveBeenCalledWith('stdin');
expect(ExecutionLifecycleService.isActive(4321)).toBe(true);
const firstChunk = { type: 'data', chunk: ' +delta' } as const;
ExecutionLifecycleService.emitEvent(4321, firstChunk);
output += firstChunk.chunk;
ExecutionLifecycleService.background(4321);
const backgroundResult = await handle.result;
expect(backgroundResult.backgrounded).toBe(true);
expect(backgroundResult.output).toBe('seed +delta');
expect(backgroundResult.executionMethod).toBe('child_process');
ExecutionLifecycleService.completeWithResult(
4321,
createResult({
pid: 4321,
output: 'seed +delta done',
rawOutput: Buffer.from('seed +delta done'),
executionMethod: 'child_process',
}),
);
await vi.waitFor(() => {
expect(exitListener).toHaveBeenCalledWith(0, undefined);
});
const lateExit = vi.fn();
ExecutionLifecycleService.onExit(4321, lateExit);
expect(lateExit).toHaveBeenCalledWith(0, undefined);
unsubscribe();
});
it('supports late subscription catch-up after backgrounding an external execution', async () => {
let output = 'seed';
const onExit = vi.fn();
const handle = ExecutionLifecycleService.attachExecution(4322, {
executionMethod: 'child_process',
getBackgroundOutput: () => output,
getSubscriptionSnapshot: () => output,
});
ExecutionLifecycleService.onExit(4322, onExit);
ExecutionLifecycleService.background(4322);
const backgroundResult = await handle.result;
expect(backgroundResult.backgrounded).toBe(true);
expect(backgroundResult.output).toBe('seed');
output += ' +late';
ExecutionLifecycleService.emitEvent(4322, {
type: 'data',
chunk: ' +late',
});
const chunks: string[] = [];
const unsubscribe = ExecutionLifecycleService.subscribe(4322, (event) => {
if (event.type === 'data' && typeof event.chunk === 'string') {
chunks.push(event.chunk);
}
});
expect(chunks[0]).toBe('seed +late');
output += ' +live';
ExecutionLifecycleService.emitEvent(4322, {
type: 'data',
chunk: ' +live',
});
expect(chunks[chunks.length - 1]).toBe(' +live');
ExecutionLifecycleService.completeWithResult(
4322,
createResult({
pid: 4322,
output,
rawOutput: Buffer.from(output),
executionMethod: 'child_process',
}),
);
await vi.waitFor(() => {
expect(onExit).toHaveBeenCalledWith(0, undefined);
});
unsubscribe();
});
it('kills external executions and settles pending promises', async () => {
const terminate = vi.fn();
const onExit = vi.fn();
const handle = ExecutionLifecycleService.attachExecution(4323, {
executionMethod: 'child_process',
initialOutput: 'running',
kill: terminate,
});
ExecutionLifecycleService.onExit(4323, onExit);
ExecutionLifecycleService.kill(4323);
const result = await handle.result;
expect(terminate).toHaveBeenCalledTimes(1);
expect(result.aborted).toBe(true);
expect(result.exitCode).toBe(130);
expect(result.output).toBe('running');
expect(result.error?.message).toContain('Operation cancelled by user');
expect(onExit).toHaveBeenCalledWith(130, undefined);
});
it('rejects duplicate execution registration for active execution IDs', () => {
ExecutionLifecycleService.attachExecution(4324, {
executionMethod: 'child_process',
});
expect(() => {
ExecutionLifecycleService.attachExecution(4324, {
executionMethod: 'child_process',
});
}).toThrow('Execution 4324 is already attached.');
});
});
@@ -0,0 +1,454 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '../utils/terminalSerializer.js';
export type ExecutionMethod =
| 'lydell-node-pty'
| 'node-pty'
| 'child_process'
| 'remote_agent'
| 'none';
export interface ExecutionResult {
rawOutput: Buffer;
output: string;
exitCode: number | null;
signal: number | null;
error: Error | null;
aborted: boolean;
pid: number | undefined;
executionMethod: ExecutionMethod;
backgrounded?: boolean;
}
export interface ExecutionHandle {
pid: number | undefined;
result: Promise<ExecutionResult>;
}
export type ExecutionOutputEvent =
| {
type: 'data';
chunk: string | AnsiOutput;
}
| {
type: 'binary_detected';
}
| {
type: 'binary_progress';
bytesReceived: number;
}
| {
type: 'exit';
exitCode: number | null;
signal: number | null;
};
export interface ExecutionCompletionOptions {
exitCode?: number | null;
signal?: number | null;
error?: Error | null;
aborted?: boolean;
}
export interface ExternalExecutionRegistration {
executionMethod: ExecutionMethod;
initialOutput?: string;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
writeInput?: (input: string) => void;
kill?: () => void;
isActive?: () => boolean;
}
interface ManagedExecutionBase {
executionMethod: ExecutionMethod;
output: string;
getBackgroundOutput?: () => string;
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
}
interface VirtualExecutionState extends ManagedExecutionBase {
kind: 'virtual';
onKill?: () => void;
}
interface ExternalExecutionState extends ManagedExecutionBase {
kind: 'external';
writeInput?: (input: string) => void;
kill?: () => void;
isActive?: () => boolean;
}
type ManagedExecutionState = VirtualExecutionState | ExternalExecutionState;
const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000;
/**
* Central owner for execution backgrounding lifecycle across shell and tools.
*/
export class ExecutionLifecycleService {
private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000;
private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
private static activeExecutions = new Map<number, ManagedExecutionState>();
private static activeResolvers = new Map<
number,
(result: ExecutionResult) => void
>();
private static activeListeners = new Map<
number,
Set<(event: ExecutionOutputEvent) => void>
>();
private static exitedExecutionInfo = new Map<
number,
{ exitCode: number; signal?: number }
>();
private static storeExitInfo(
executionId: number,
exitCode: number,
signal?: number,
): void {
this.exitedExecutionInfo.set(executionId, {
exitCode,
signal,
});
setTimeout(() => {
this.exitedExecutionInfo.delete(executionId);
}, this.EXIT_INFO_TTL_MS).unref();
}
private static allocateExecutionId(): number {
let executionId = ++this.nextExecutionId;
while (this.activeExecutions.has(executionId)) {
executionId = ++this.nextExecutionId;
}
return executionId;
}
private static createPendingResult(
executionId: number,
): Promise<ExecutionResult> {
return new Promise<ExecutionResult>((resolve) => {
this.activeResolvers.set(executionId, resolve);
});
}
private static createAbortedResult(
executionId: number,
execution: ManagedExecutionState,
): ExecutionResult {
const output = execution.getBackgroundOutput?.() ?? execution.output;
return {
rawOutput: Buffer.from(output, 'utf8'),
output,
exitCode: 130,
signal: null,
error: new Error('Operation cancelled by user.'),
aborted: true,
pid: executionId,
executionMethod: execution.executionMethod,
};
}
/**
* Resets lifecycle state for isolated unit tests.
*/
static resetForTest(): void {
this.activeExecutions.clear();
this.activeResolvers.clear();
this.activeListeners.clear();
this.exitedExecutionInfo.clear();
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
}
static attachExecution(
executionId: number,
registration: ExternalExecutionRegistration,
): ExecutionHandle {
if (
this.activeExecutions.has(executionId) ||
this.activeResolvers.has(executionId)
) {
throw new Error(`Execution ${executionId} is already attached.`);
}
this.exitedExecutionInfo.delete(executionId);
this.activeExecutions.set(executionId, {
executionMethod: registration.executionMethod,
output: registration.initialOutput ?? '',
kind: 'external',
getBackgroundOutput: registration.getBackgroundOutput,
getSubscriptionSnapshot: registration.getSubscriptionSnapshot,
writeInput: registration.writeInput,
kill: registration.kill,
isActive: registration.isActive,
});
return {
pid: executionId,
result: this.createPendingResult(executionId),
};
}
static createExecution(
initialOutput = '',
onKill?: () => void,
executionMethod: ExecutionMethod = 'none',
): ExecutionHandle {
const executionId = this.allocateExecutionId();
this.activeExecutions.set(executionId, {
executionMethod,
output: initialOutput,
kind: 'virtual',
onKill,
getBackgroundOutput: () => {
const state = this.activeExecutions.get(executionId);
return state?.output ?? initialOutput;
},
getSubscriptionSnapshot: () => {
const state = this.activeExecutions.get(executionId);
return state?.output ?? initialOutput;
},
});
return {
pid: executionId,
result: this.createPendingResult(executionId),
};
}
static appendOutput(executionId: number, chunk: string): void {
const execution = this.activeExecutions.get(executionId);
if (!execution || chunk.length === 0) {
return;
}
execution.output += chunk;
this.emitEvent(executionId, { type: 'data', chunk });
}
static emitEvent(executionId: number, event: ExecutionOutputEvent): void {
const listeners = this.activeListeners.get(executionId);
if (listeners) {
listeners.forEach((listener) => listener(event));
}
}
private static resolvePending(
executionId: number,
result: ExecutionResult,
): void {
const resolve = this.activeResolvers.get(executionId);
if (!resolve) {
return;
}
resolve(result);
this.activeResolvers.delete(executionId);
}
private static settleExecution(
executionId: number,
result: ExecutionResult,
): void {
if (!this.activeExecutions.has(executionId)) {
return;
}
this.resolvePending(executionId, result);
this.emitEvent(executionId, {
type: 'exit',
exitCode: result.exitCode,
signal: result.signal,
});
this.activeListeners.delete(executionId);
this.activeExecutions.delete(executionId);
this.storeExitInfo(
executionId,
result.exitCode ?? 0,
result.signal ?? undefined,
);
}
static completeExecution(
executionId: number,
options?: ExecutionCompletionOptions,
): void {
const execution = this.activeExecutions.get(executionId);
if (!execution) {
return;
}
const {
error = null,
aborted = false,
exitCode = error ? 1 : 0,
signal = null,
} = options ?? {};
const output = execution.getBackgroundOutput?.() ?? execution.output;
this.settleExecution(executionId, {
rawOutput: Buffer.from(output, 'utf8'),
output,
exitCode,
signal,
error,
aborted,
pid: executionId,
executionMethod: execution.executionMethod,
});
}
static completeWithResult(
executionId: number,
result: ExecutionResult,
): void {
this.settleExecution(executionId, result);
}
static background(executionId: number): void {
const resolve = this.activeResolvers.get(executionId);
if (!resolve) {
return;
}
const execution = this.activeExecutions.get(executionId);
if (!execution) {
return;
}
const output = execution.getBackgroundOutput?.() ?? execution.output;
resolve({
rawOutput: Buffer.from(''),
output,
exitCode: null,
signal: null,
error: null,
aborted: false,
pid: executionId,
executionMethod: execution.executionMethod,
backgrounded: true,
});
this.activeResolvers.delete(executionId);
}
static subscribe(
executionId: number,
listener: (event: ExecutionOutputEvent) => void,
): () => void {
if (!this.activeListeners.has(executionId)) {
this.activeListeners.set(executionId, new Set());
}
this.activeListeners.get(executionId)?.add(listener);
const execution = this.activeExecutions.get(executionId);
if (execution) {
const snapshot =
execution.getSubscriptionSnapshot?.() ??
(execution.output.length > 0 ? execution.output : undefined);
if (snapshot && (typeof snapshot !== 'string' || snapshot.length > 0)) {
listener({ type: 'data', chunk: snapshot });
}
}
return () => {
this.activeListeners.get(executionId)?.delete(listener);
if (this.activeListeners.get(executionId)?.size === 0) {
this.activeListeners.delete(executionId);
}
};
}
static onExit(
executionId: number,
callback: (exitCode: number, signal?: number) => void,
): () => void {
if (this.activeExecutions.has(executionId)) {
const listener = (event: ExecutionOutputEvent) => {
if (event.type === 'exit') {
callback(event.exitCode ?? 0, event.signal ?? undefined);
unsubscribe();
}
};
const unsubscribe = this.subscribe(executionId, listener);
return unsubscribe;
}
const exitedInfo = this.exitedExecutionInfo.get(executionId);
if (exitedInfo) {
callback(exitedInfo.exitCode, exitedInfo.signal);
}
return () => {};
}
static kill(executionId: number): void {
const execution = this.activeExecutions.get(executionId);
if (!execution) {
return;
}
if (execution.kind === 'virtual') {
execution.onKill?.();
}
if (execution.kind === 'external') {
execution.kill?.();
}
this.completeWithResult(
executionId,
this.createAbortedResult(executionId, execution),
);
}
static isActive(executionId: number): boolean {
const execution = this.activeExecutions.get(executionId);
if (!execution) {
if (executionId >= NON_PROCESS_EXECUTION_ID_START) {
return false;
}
try {
return process.kill(executionId, 0);
} catch {
return false;
}
}
if (execution.kind === 'virtual') {
return true;
}
if (execution.kind === 'external' && execution.isActive) {
try {
return execution.isActive();
} catch {
return false;
}
}
try {
return process.kill(executionId, 0);
} catch {
return false;
}
}
static writeInput(executionId: number, input: string): void {
const execution = this.activeExecutions.get(executionId);
if (execution?.kind === 'external') {
execution.writeInput?.(input);
}
}
}
@@ -22,6 +22,7 @@ import {
type ShellOutputEvent,
type ShellExecutionConfig,
} from './shellExecutionService.js';
import { ExecutionLifecycleService } from './executionLifecycleService.js';
import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js';
// Hoisted Mocks
@@ -201,6 +202,7 @@ describe('ShellExecutionService', () => {
beforeEach(() => {
vi.clearAllMocks();
ExecutionLifecycleService.resetForTest();
mockSerializeTerminalToObject.mockReturnValue([]);
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
@@ -469,9 +471,10 @@ describe('ShellExecutionService', () => {
});
describe('pty interaction', () => {
let ptySpy: { mockRestore(): void };
let activePtysGetSpy: { mockRestore: () => void };
beforeEach(() => {
ptySpy = vi
activePtysGetSpy = vi
.spyOn(ShellExecutionService['activePtys'], 'get')
.mockReturnValue({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -482,7 +485,7 @@ describe('ShellExecutionService', () => {
});
afterEach(() => {
ptySpy.mockRestore();
activePtysGetSpy.mockRestore();
});
it('should write to the pty and trigger a render', async () => {
@@ -1102,11 +1105,10 @@ describe('ShellExecutionService', () => {
});
it('should destroy the PTY when an exception occurs after spawn in executeWithPty', async () => {
// Simulate: spawn succeeds, Promise executor runs fine (pid accesses 1-2),
// but the return statement `{ pid: ptyProcess.pid }` (access 3) throws.
// The catch block should call spawnedPty.destroy() to release the fd.
// Simulate: spawn succeeds, but accessing ptyProcess.pid throws.
// spawnedPty is set before the pid access, so the catch block should
// call spawnedPty.destroy() to release the fd.
const destroySpy = vi.fn();
let pidAccessCount = 0;
const faultyPty = {
onData: vi.fn(),
onExit: vi.fn(),
@@ -1114,15 +1116,8 @@ describe('ShellExecutionService', () => {
kill: vi.fn(),
resize: vi.fn(),
destroy: destroySpy,
get pid() {
pidAccessCount++;
// Accesses 1-2 are inside the Promise executor (setup).
// Access 3 is at `return { pid: ptyProcess.pid, result }`,
// outside the Promise — caught by the outer try/catch.
if (pidAccessCount > 2) {
throw new Error('Simulated post-spawn failure on pid access');
}
return 77777;
get pid(): number {
throw new Error('Simulated post-spawn failure on pid access');
},
};
mockPtySpawn.mockReturnValueOnce(faultyPty);
File diff suppressed because it is too large Load Diff
@@ -51,6 +51,12 @@ import { InstallationManager } from '../../utils/installationManager.js';
import si, { type Systeminformation } from 'systeminformation';
import * as os from 'node:os';
import {
CreditsUsedEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
} from '../billingEvents.js';
interface CustomMatchers<R = unknown> {
toHaveMetadataValue: ([key, value]: [EventMetadataKey, string]) => R;
@@ -1551,4 +1557,99 @@ describe('ClearcutLogger', () => {
]);
});
});
describe('logCreditsUsedEvent', () => {
it('logs an event with model, consumed, and remaining credits', () => {
const { logger } = setup();
const event = new CreditsUsedEvent('gemini-3-pro-preview', 10, 490);
logger?.logCreditsUsedEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.CREDITS_USED);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_CONSUMED,
'10',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_REMAINING,
'490',
]);
});
});
describe('logOverageOptionSelectedEvent', () => {
it('logs an event with model, selected option, and credit balance', () => {
const { logger } = setup();
const event = new OverageOptionSelectedEvent(
'gemini-3-pro-preview',
'use_credits',
350,
);
logger?.logOverageOptionSelectedEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.OVERAGE_OPTION_SELECTED);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_SELECTED_OPTION,
'"use_credits"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_CREDIT_BALANCE,
'350',
]);
});
});
describe('logEmptyWalletMenuShownEvent', () => {
it('logs an event with the model', () => {
const { logger } = setup();
const event = new EmptyWalletMenuShownEvent('gemini-3-pro-preview');
logger?.logEmptyWalletMenuShownEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.EMPTY_WALLET_MENU_SHOWN);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
});
});
describe('logCreditPurchaseClickEvent', () => {
it('logs an event with model and source', () => {
const { logger } = setup();
const event = new CreditPurchaseClickEvent(
'empty_wallet_menu',
'gemini-3-pro-preview',
);
logger?.logCreditPurchaseClickEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.CREDIT_PURCHASE_CLICK);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
'"gemini-3-pro-preview"',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_BILLING_PURCHASE_SOURCE,
'"empty_wallet_menu"',
]);
});
});
});
@@ -52,6 +52,12 @@ import type {
TokenStorageInitializationEvent,
StartupStatsEvent,
} from '../types.js';
import type {
CreditsUsedEvent,
OverageOptionSelectedEvent,
EmptyWalletMenuShownEvent,
CreditPurchaseClickEvent,
} from '../billingEvents.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
import { InstallationManager } from '../../utils/installationManager.js';
@@ -121,6 +127,10 @@ export enum EventNames {
CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
CONSECA_VERDICT = 'conseca_verdict',
STARTUP_STATS = 'startup_stats',
CREDITS_USED = 'credits_used',
OVERAGE_OPTION_SELECTED = 'overage_option_selected',
EMPTY_WALLET_MENU_SHOWN = 'empty_wallet_menu_shown',
CREDIT_PURCHASE_CLICK = 'credit_purchase_click',
}
export interface LogResponse {
@@ -1806,6 +1816,84 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
// ==========================================================================
// Billing / AI Credits Events
// ==========================================================================
logCreditsUsedEvent(event: CreditsUsedEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_CONSUMED,
value: JSON.stringify(event.credits_consumed),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDITS_REMAINING,
value: JSON.stringify(event.credits_remaining),
},
];
this.enqueueLogEvent(this.createLogEvent(EventNames.CREDITS_USED, data));
this.flushIfNeeded();
}
logOverageOptionSelectedEvent(event: OverageOptionSelectedEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_SELECTED_OPTION,
value: JSON.stringify(event.selected_option),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_CREDIT_BALANCE,
value: JSON.stringify(event.credit_balance),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.OVERAGE_OPTION_SELECTED, data),
);
this.flushIfNeeded();
}
logEmptyWalletMenuShownEvent(event: EmptyWalletMenuShownEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.EMPTY_WALLET_MENU_SHOWN, data),
);
this.flushIfNeeded();
}
logCreditPurchaseClickEvent(event: CreditPurchaseClickEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_MODEL,
value: JSON.stringify(event.model),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_BILLING_PURCHASE_SOURCE,
value: JSON.stringify(event.source),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.CREDIT_PURCHASE_CLICK, data),
);
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 180
// Next ID: 191
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -687,4 +687,26 @@ export enum EventMetadataKey {
// Logs the error type for a network retry.
GEMINI_CLI_NETWORK_RETRY_ERROR_TYPE = 182,
// ==========================================================================
// Billing / AI Credits Event Keys
// ==========================================================================
// Logs the model associated with a billing event.
GEMINI_CLI_BILLING_MODEL = 185,
// Logs the number of AI credits consumed in a request.
GEMINI_CLI_BILLING_CREDITS_CONSUMED = 186,
// Logs the remaining AI credits after a request.
GEMINI_CLI_BILLING_CREDITS_REMAINING = 187,
// Logs the overage option selected by the user (e.g. use_credits, use_fallback, manage, stop).
GEMINI_CLI_BILLING_SELECTED_OPTION = 188,
// Logs the user's credit balance when the overage menu was shown.
GEMINI_CLI_BILLING_CREDIT_BALANCE = 189,
// Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage).
GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190,
}

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