Compare commits

...

16 Commits

Author SHA1 Message Date
Christian Gunderman a94cf529d6 Merge remote-tracking branch 'origin/main' into gundermanc/promote2 2026-03-13 14:21:32 -07:00
christine betts 24adacdbc2 Move keychain fallback to keychain service (#22332) 2026-03-13 20:57:08 +00:00
gemini-cli-robot aa23da67af chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 (#22251) 2026-03-13 20:33:16 +00:00
cynthialong0-0 bfbd3c40a7 feat(agent): add allowed domain restrictions for browser agent (#21775) 2026-03-13 19:41:40 +00:00
anj-s dd8d4c98b3 feat(tracker): return TodoList display for tracker tools (#22060) 2026-03-13 18:18:33 +00:00
Gaurav d368997ca3 test: add Object.create context regression test and tool confirmation integration test (#22356) 2026-03-13 17:49:33 +00:00
Abhi bbd80c9393 docs: overhaul subagents documentation and add /agents command (#22345) 2026-03-13 17:26:13 +00:00
Abhi 3b601b3d90 refactor(ui): extract SessionBrowser static ui components (#22348) 2026-03-13 17:25:13 +00:00
Adam Weidman b4bcd1a015 docs(core): add authentication guide for remote subagents (#22178) 2026-03-13 16:48:21 +00:00
Alexander Farber aa000d7d30 fix(core): show descriptive error messages when saving settings fails (#18095)
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
2026-03-13 16:19:56 +00:00
Christian Gunderman 6c397faee1 chore: fix merge conflict in evals/ask_user.eval.ts 2026-03-13 09:08:26 -07:00
Tommaso Sciortino 2a7e602356 refactor(cli): consolidate getErrorMessage utility to core (#22190) 2026-03-13 15:40:29 +00:00
matt korwel 8d0b2d7f1b feat(skills): improve async-pr-review workflow and logging (#21790) 2026-03-13 15:18:07 +00:00
Ankit c156bac5f7 fix(settings): prevent j/k navigation keys from intercepting edit buffer input (#21865) 2026-03-13 14:55:36 +00:00
Adib234 263b8cd3b3 fix(plan): Fix AskUser evals (#22074) 2026-03-13 13:30:19 +00:00
Christian Gunderman 41678570dc Promote stable tests. 2026-03-12 17:43:41 -07:00
86 changed files with 2417 additions and 931 deletions
+45
View File
@@ -0,0 +1,45 @@
---
name: async-pr-review
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
---
# Async PR Review
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
## Workflow
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
### Start Review
If the user wants to start a new async PR review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
```bash
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
```
3. Inform the user that the tasks have started successfully and they can check the status later.
### Check Status
If the user wants to check the status or view the final assessment of a previously started async review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
```bash
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
```
3. **Evaluate Output**: Read the output from the script.
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
+148
View File
@@ -0,0 +1,148 @@
# --- CORE TOOLS ---
[[rule]]
toolName = "read_file"
decision = "allow"
priority = 100
[[rule]]
toolName = "write_file"
decision = "allow"
priority = 100
[[rule]]
toolName = "grep_search"
decision = "allow"
priority = 100
[[rule]]
toolName = "glob"
decision = "allow"
priority = 100
[[rule]]
toolName = "list_directory"
decision = "allow"
priority = 100
[[rule]]
toolName = "codebase_investigator"
decision = "allow"
priority = 100
# --- SHELL COMMANDS ---
# Git (Safe/Read-only)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"git blame",
"git show",
"git grep",
"git show-ref",
"git ls-tree",
"git ls-remote",
"git reflog",
"git remote -v",
"git diff",
"git rev-list",
"git rev-parse",
"git merge-base",
"git cherry",
"git fetch",
"git status",
"git st",
"git branch",
"git br",
"git log",
"git --version"
]
decision = "allow"
priority = 100
# GitHub CLI (Read-only)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"gh workflow list",
"gh auth status",
"gh checkout view",
"gh run view",
"gh run job view",
"gh run list",
"gh run --help",
"gh issue view",
"gh issue list",
"gh label list",
"gh pr diff",
"gh pr check",
"gh pr checks",
"gh pr view",
"gh pr list",
"gh pr status",
"gh repo view",
"gh job view",
"gh api",
"gh log"
]
decision = "allow"
priority = 100
# Node.js/NPM (Generic Tests, Checks, and Build)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"npm run start",
"npm install",
"npm run",
"npm test",
"npm ci",
"npm list",
"npm --version"
]
decision = "allow"
priority = 100
# Core Utilities (Safe)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"sleep",
"env",
"break",
"xargs",
"base64",
"uniq",
"sort",
"echo",
"which",
"ls",
"find",
"tail",
"head",
"cat",
"cd",
"grep",
"ps",
"pwd",
"wc",
"file",
"stat",
"diff",
"lsof",
"date",
"whoami",
"uname",
"du",
"cut",
"true",
"false",
"readlink",
"awk",
"jq",
"rg",
"less",
"more",
"tree"
]
decision = "allow"
priority = 100
+241
View File
@@ -0,0 +1,241 @@
#!/bin/bash
notify() {
local title="$1"
local message="$2"
local pr="$3"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
# Native macOS notification
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
fi
}
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
cd "$base_dir" || exit 1
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
exit 1
fi
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
fi
echo 0 > "$log_dir/setup.exit"
cd "$target_dir" || exit 1
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "$log_dir/npm-test.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
ci_status=$?
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
failed_files=""
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
fi
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
exit_code=0
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
else
ws_dir=$(echo "$file" | cut -d'/' -f1)
fi
rel_file=${file#$ws_dir/}
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo $exit_code > "$log_dir/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "$log_dir/test-execution.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[$t]=0; done
all_done=0
while [[ $all_done -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
fi
task_done[$t]=1
else
echo "$t: RUNNING"
all_done=0
fi
done
echo ""
echo "=================================================="
echo "📝 Live Logs (Last 5 lines of running tasks)"
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
log_file="${log_files[$i]}"
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
echo ""
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
fi
fi
done
if [[ $all_done -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
exit 1
fi
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
@@ -0,0 +1,65 @@
#!/bin/bash
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
if [[ ! -d "$log_dir" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #$pr_number in $log_dir"
exit 0
fi
tasks=(
"setup|setup.log"
"pr-diff|pr-diff.diff"
"build-and-lint|build-and-lint.log"
"review|review.md"
"npm-test|npm-test.log"
"test-execution|test-execution.log"
"final-assessment|final-assessment.md"
)
all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "$task_info"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
else
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
fi
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
all_done=false
else
echo " $task_name: NOT STARTED"
all_done=false
fi
done
if $all_done; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: $log_dir"
else
echo "STATUS: IN_PROGRESS"
fi
+282
View File
@@ -25,6 +25,20 @@ To use remote subagents, you must explicitly enable them in your
}
```
## Proxy support
Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is
configured. It uses the `general.proxy` setting in your `settings.json` file or
standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`).
```json
{
"general": {
"proxy": "http://my-proxy:8080"
}
}
```
## Defining remote subagents
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
@@ -40,6 +54,7 @@ You can place them in:
| `kind` | string | Yes | Must be `remote`. |
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
### Single-subagent example
@@ -70,6 +85,273 @@ Markdown file.
> **Note:** Mixed local and remote agents, or multiple local agents, are not
> supported in a single file; the list format is currently remote-only.
## Authentication
Many remote agents require authentication. Gemini CLI supports several
authentication methods aligned with the
[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme).
Add an `auth` block to your agent's frontmatter to configure credentials.
### Supported auth types
Gemini CLI supports the following authentication types:
| Type | Description |
| :------------------- | :--------------------------------------------------------------------------------------------- |
| `apiKey` | Send a static API key as an HTTP header. |
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
### Dynamic values
For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`,
`password`, `value`) support dynamic resolution:
| Format | Description | Example |
| :---------- | :-------------------------------------------------- | :------------------------- |
| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` |
| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` |
| literal | Use the string as-is. | `sk-abc123` |
| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` |
> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets
> directly in agent files, especially for project-level agents checked into
> version control.
### API key (`apiKey`)
Sends an API key as an HTTP header on every request.
| Field | Type | Required | Description |
| :----- | :----- | :------- | :---------------------------------------------------- |
| `type` | string | Yes | Must be `apiKey`. |
| `key` | string | Yes | The API key value. Supports dynamic values. |
| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. |
```yaml
---
kind: remote
name: my-agent
agent_card_url: https://example.com/agent-card
auth:
type: apiKey
key: $MY_API_KEY
---
```
### HTTP authentication (`http`)
Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP
authentication schemes.
#### Bearer token
Use the following fields to configure a Bearer token:
| Field | Type | Required | Description |
| :------- | :----- | :------- | :----------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | Must be `Bearer`. |
| `token` | string | Yes | The bearer token. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Bearer
token: $MY_BEARER_TOKEN
```
#### Basic authentication
Use the following fields to configure Basic authentication:
| Field | Type | Required | Description |
| :--------- | :----- | :------- | :------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | Must be `Basic`. |
| `username` | string | Yes | The username. Supports dynamic values. |
| `password` | string | Yes | The password. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Basic
username: $MY_USERNAME
password: $MY_PASSWORD
```
#### Raw scheme
For any other IANA-registered scheme (for example, Digest, HOBA), provide the
raw authorization value.
| Field | Type | Required | Description |
| :------- | :----- | :------- | :---------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | The scheme name (for example, `Digest`). |
| `value` | string | Yes | Raw value sent as `Authorization: <scheme> <value>`. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Digest
value: $MY_DIGEST_VALUE
```
### Google Application Default Credentials (`google-credentials`)
Uses
[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials)
to authenticate with Google Cloud services and Cloud Run endpoints. This is the
recommended auth method for agents hosted on Google Cloud infrastructure.
| Field | Type | Required | Description |
| :------- | :------- | :------- | :-------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `google-credentials`. |
| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. |
```yaml
---
kind: remote
name: my-gcp-agent
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
auth:
type: google-credentials
---
```
#### How token selection works
The provider automatically selects the correct token type based on the agent's
host:
| Host pattern | Token type | Use case |
| :----------------- | :----------------- | :------------------------------------------ |
| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) |
| `*.run.app` | **Identity token** | Cloud Run services |
- **Access tokens** authorize API calls to Google services. They are scoped
(default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`.
- **Identity tokens** prove the caller's identity to a service that validates
the token's audience. The audience is set to the target host. These are
fetched via `GoogleAuth.getIdTokenClient()`.
Both token types are cached and automatically refreshed before expiry.
#### Setup
`google-credentials` relies on ADC, which means your environment must have
credentials configured. Common setups:
- **Local development:** Run `gcloud auth application-default login` to
authenticate with your Google account.
- **CI / Cloud environments:** Use a service account. Set the
`GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your
service account key file, or use workload identity on GKE / Cloud Run.
#### Allowed hosts
For security, `google-credentials` only sends tokens to known Google-owned
hosts:
- `*.googleapis.com`
- `*.run.app`
Requests to any other host will be rejected with an error. If your agent is
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
or `oauth2`).
#### Examples
The following examples demonstrate how to configure Google Application Default
Credentials.
**Cloud Run agent:**
```yaml
---
kind: remote
name: cloud-run-agent
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
auth:
type: google-credentials
---
```
**Google API with custom scopes:**
```yaml
---
kind: remote
name: vertex-agent
agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json
auth:
type: google-credentials
scopes:
- https://www.googleapis.com/auth/cloud-platform
- https://www.googleapis.com/auth/compute
---
```
### OAuth 2.0 (`oauth2`)
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
for subsequent requests.
| Field | Type | Required | Description |
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `oauth2`. |
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. |
| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. |
```yaml
---
kind: remote
name: oauth-agent
agent_card_url: https://example.com/.well-known/agent.json
auth:
type: oauth2
client_id: my-client-id.apps.example.com
---
```
If the agent card advertises an `oauth2` security scheme with
`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are
automatically discovered. You only need to provide `client_id` (and
`client_secret` if required).
Tokens are persisted to disk and refreshed automatically when they expire.
### Auth validation
When Gemini CLI loads a remote agent, it validates your auth configuration
against the agent card's declared `securitySchemes`. If the agent requires
authentication that you haven't configured, you'll see an error describing
what's needed.
`google-credentials` is treated as compatible with `http` Bearer security
schemes, since it produces Bearer tokens.
### Auth retry behavior
All auth providers automatically retry on `401` and `403` responses by
re-fetching credentials (up to 2 retries). This handles cases like expired
tokens or rotated credentials. For `apiKey` with `!command` values, the command
is re-executed on retry to fetch a fresh key.
### Agent card fetching and auth
When connecting to a remote agent, Gemini CLI first fetches the agent card
**without** authentication. If the card endpoint returns a `401` or `403`, it
retries the fetch **with** the configured auth headers. This lets agents have
publicly accessible cards while protecting their task endpoints, or to protect
both behind auth.
## Managing Subagents
Users can manage subagents using the following commands within the Gemini CLI:
+135 -19
View File
@@ -38,6 +38,34 @@ main agent calls the tool, it delegates the task to the subagent. Once the
subagent completes its task, it reports back to the main agent with its
findings.
## How to use subagents
You can use subagents through automatic delegation or by explicitly forcing them
in your prompt.
### Automatic delegation
Gemini CLI's main agent is instructed to use specialized subagents when a task
matches their expertise. For example, if you ask "How does the auth system
work?", the main agent may decide to call the `codebase_investigator` subagent
to perform the research.
### Forcing a subagent (@ syntax)
You can explicitly direct a task to a specific subagent by using the `@` symbol
followed by the subagent's name at the beginning of your prompt. This is useful
when you want to bypass the main agent's decision-making and go straight to a
specialist.
**Example:**
```bash
@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
```
When you use the `@` syntax, the CLI injects a system note that nudges the
primary model to use that specific subagent tool immediately.
## Built-in subagents
Gemini CLI comes with the following built-in subagents:
@@ -49,15 +77,17 @@ Gemini CLI comes with the following built-in subagents:
dependencies.
- **When to use:** "How does the authentication system work?", "Map out the
dependencies of the `AgentRegistry` class."
- **Configuration:** Enabled by default. You can configure it in
`settings.json`. Example (forcing a specific model):
- **Configuration:** Enabled by default. You can override its settings in
`settings.json` under `agents.overrides`. Example (forcing a specific model
and increasing turns):
```json
{
"experimental": {
"codebaseInvestigatorSettings": {
"enabled": true,
"maxNumTurns": 20,
"model": "gemini-2.5-pro"
"agents": {
"overrides": {
"codebase_investigator": {
"modelConfig": { "model": "gemini-3-flash-preview" },
"runConfig": { "maxTurns": 50 }
}
}
}
}
@@ -233,7 +263,7 @@ kind: local
tools:
- read_file
- grep_search
model: gemini-2.5-pro
model: gemini-3-flash-preview
temperature: 0.2
max_turns: 10
---
@@ -254,16 +284,102 @@ it yourself; just report it.
### Configuration schema
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
### Tool wildcards
When defining `tools` for a subagent, you can use wildcards to quickly grant
access to groups of tools:
- `*`: Grant access to all available built-in and discovered tools.
- `mcp_*`: Grant access to all tools from all connected MCP servers.
- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
`my-server`.
### Isolation and recursion protection
Each subagent runs in its own isolated context loop. This means:
- **Independent history:** The subagent's conversation history does not bloat
the main agent's context.
- **Isolated tools:** The subagent only has access to the tools you explicitly
grant it.
- **Recursion protection:** To prevent infinite loops and excessive token usage,
subagents **cannot** call other subagents. If a subagent is granted the `*`
tool wildcard, it will still be unable to see or invoke other agents.
## Managing subagents
You can manage subagents interactively using the `/agents` command or
persistently via `settings.json`.
### Interactive management (/agents)
If you are in an interactive CLI session, you can use the `/agents` command to
manage subagents without editing configuration files manually. This is the
recommended way to quickly enable, disable, or re-configure agents on the fly.
For a full list of sub-commands and usage, see the
[`/agents` command reference](../reference/commands.md#agents).
### Persistent configuration (settings.json)
While the `/agents` command and agent definition files provide a starting point,
you can use `settings.json` for global, persistent overrides. This is useful for
enforcing specific models or execution limits across all sessions.
#### `agents.overrides`
Use this to enable or disable specific agents or override their run
configurations.
```json
{
"agents": {
"overrides": {
"security-auditor": {
"enabled": false,
"runConfig": {
"maxTurns": 20,
"maxTimeMinutes": 10
}
}
}
}
}
```
#### `modelConfigs.overrides`
You can target specific subagents with custom model settings (like system
instruction prefixes or specific safety settings) using the `overrideScope`
field.
```json
{
"modelConfigs": {
"overrides": [
{
"match": { "overrideScope": "security-auditor" },
"modelConfig": {
"generateContentConfig": {
"temperature": 0.1
}
}
}
]
}
}
```
### Optimizing your subagent
@@ -298,7 +414,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
> **Note: Remote subagents are currently an experimental feature.**
See the [Remote Subagents documentation](remote-agents) for detailed
configuration and usage instructions.
configuration, authentication, and usage instructions.
## Extension subagents
+25
View File
@@ -14,6 +14,31 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Show version info. Share this information when filing issues.
### `/agents`
- **Description:** Manage local and remote subagents.
- **Note:** This command is experimental and requires
`experimental.enableAgents: true` in your `settings.json`.
- **Sub-commands:**
- **`list`**:
- **Description:** Lists all discovered agents, including built-in, local,
and remote agents.
- **Usage:** `/agents list`
- **`reload`** (alias: `refresh`):
- **Description:** Rescans agent directories (`~/.gemini/agents` and
`.gemini/agents`) and reloads the registry.
- **Usage:** `/agents reload`
- **`enable`**:
- **Description:** Enables a specific subagent.
- **Usage:** `/agents enable <agent-name>`
- **`disable`**:
- **Description:** Disables a specific subagent.
- **Usage:** `/agents disable <agent-name>`
- **`config`**:
- **Description:** Opens a configuration dialog for the specified agent to
adjust its model, temperature, or execution limits.
- **Usage:** `/agents config <agent-name>`
### `/auth`
- **Description:** Open a dialog that lets you change the authentication method.
+11
View File
@@ -706,6 +706,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`agents.browser.allowedDomains`** (array):
- **Description:** A list of allowed domains for the browser agent (e.g.,
["github.com", "*.google.com"]).
- **Default:**
```json
["github.com", "*.google.com", "localhost"]
```
- **Requires restart:** Yes
- **`agents.browser.disableUserInput`** (boolean):
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
+1 -1
View File
@@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => {
* Ensures that when the user asks a question about style, the agent does NOT
* automatically modify the file.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not edit files when asked about style',
prompt: 'Is app.ts following good style?',
files: FILES,
+72 -33
View File
@@ -5,31 +5,62 @@
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
import { EvalPolicy } from './test-helper.js';
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
return appEvalTest(policy, {
...evalCase,
configOverrides: {
...evalCase.configOverrides,
general: {
...evalCase.configOverrides?.general,
approvalMode: 'default',
enableAutoUpdate: false,
enableAutoUpdateNotification: false,
},
},
files: {
...evalCase.files,
},
});
}
describe('ask_user', () => {
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to present multiple choice options',
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
setup: async (rig) => {
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(
confirmation,
'Expected a pending confirmation for ask_user tool',
).toBeDefined();
},
});
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
files: {
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
},
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
setup: async (rig) => {
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(
confirmation,
'Expected a pending confirmation for ask_user tool',
).toBeDefined();
},
});
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
files: {
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
@@ -39,28 +70,37 @@ describe('ask_user', () => {
}),
'README.md': '# Gemini CLI',
},
prompt: `Refactor the entire core package to be better.`,
prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`,
setup: async (rig) => {
rig.setBreakpoint(['enter_plan_mode', 'ask_user']);
},
assert: async (rig) => {
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
true,
);
// It might call enter_plan_mode first.
let confirmation = await rig.waitForPendingConfirmation([
'enter_plan_mode',
'ask_user',
]);
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
if (confirmation?.name === 'enter_plan_mode') {
rig.acceptConfirmation('enter_plan_mode');
confirmation = await rig.waitForPendingConfirmation('ask_user');
}
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
expect(
wasAskUserCalled,
'Expected ask_user tool to be called to clarify the significant rework',
).toBe(true);
confirmation?.toolName,
'Expected ask_user to be called to clarify the significant rework',
).toBe('ask_user');
},
});
// --- Regression Tests for Recent Fixes ---
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
// Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to
// confirm shell commands. Fixed via prompt refinements and tool definition
// updates to clarify that shell command confirmation is handled by the UI.
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent does NOT use AskUser to confirm shell commands',
files: {
'package.json': JSON.stringify({
@@ -68,25 +108,24 @@ describe('ask_user', () => {
}),
},
prompt: `Run 'npm run build' in the current directory.`,
setup: async (rig) => {
rig.setBreakpoint(['run_shell_command', 'ask_user']);
},
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const wasShellCalled = toolLogs.some(
(log) => log.toolRequest.name === 'run_shell_command',
);
const wasAskUserCalled = toolLogs.some(
(log) => log.toolRequest.name === 'ask_user',
);
const confirmation = await rig.waitForPendingConfirmation([
'run_shell_command',
'ask_user',
]);
expect(
wasShellCalled,
'Expected run_shell_command tool to be called',
).toBe(true);
confirmation,
'Expected a pending confirmation for a tool',
).toBeDefined();
expect(
wasAskUserCalled,
confirmation?.toolName,
'ask_user should not be called to confirm shell commands',
).toBe(false);
).toBe('run_shell_command');
},
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
+3 -3
View File
@@ -14,7 +14,7 @@ import {
describe('save_memory', () => {
const TEST_PREFIX = 'Save memory test: ';
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -79,7 +79,7 @@ describe('save_memory', () => {
const ignoringTemporaryInformation =
'Agent ignores temporary conversation details';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -104,7 +104,7 @@ describe('save_memory', () => {
});
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
+29
View File
@@ -203,4 +203,33 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
// Should successfully complete all operations
assertModelHasOutput(result);
});
it('should handle tool confirmation for write_file without crashing', async () => {
rig.setup('tool-confirmation', {
fakeResponsesPath: join(
__dirname,
'browser-agent.confirmation.responses',
),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const run = await rig.runInteractive({ approvalMode: 'default' });
await run.type('Write hello to test.txt');
await run.type('\r');
await run.expectText('Allow', 15000);
await run.type('y');
await run.type('\r');
await run.expectText('successfully written', 15000);
});
});
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"workspaces": [
"packages/*"
],
@@ -16890,7 +16890,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17005,7 +17005,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17177,7 +17177,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17439,7 +17439,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17454,7 +17454,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17471,7 +17471,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17488,7 +17488,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+5 -2
View File
@@ -4,13 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions, type Config } from '@google/gemini-cli-core';
import {
listExtensions,
type Config,
getErrorMessage,
} from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
@@ -22,7 +22,7 @@ import {
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getErrorMessage } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
@@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn(),
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
@@ -6,8 +6,7 @@
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
@@ -11,8 +11,8 @@ import {
debugLogger,
FolderTrustDiscoveryService,
getRealPath,
getErrorMessage,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
promptForConsentNonInteractive,
@@ -13,26 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
import { type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{ stripAnsi: true },
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
return { ...mocked, getErrorMessage: vi.fn() };
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
+1 -1
View File
@@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import {
debugLogger,
getErrorMessage,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
@@ -5,27 +5,23 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
stripAnsi: false,
},
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
return { ...mocked, getErrorMessage: vi.fn() };
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
+1 -2
View File
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
@@ -18,7 +18,7 @@ import { type Argv } from 'yargs';
import { handleUninstall, uninstallCommand } from './uninstall.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getErrorMessage } from '@google/gemini-cli-core';
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
@@ -66,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn(),
};
});
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
@@ -12,9 +12,12 @@ import {
updateExtension,
} from '../../config/extensions/update.js';
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
coreEvents,
debugLogger,
getErrorMessage,
} from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
@@ -5,11 +5,10 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import semver from 'semver';
import { getErrorMessage } from '../../utils/errors.js';
import type { ExtensionConfig } from '../../config/extension.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
@@ -28,6 +28,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
import { handleInstall, installCommand } from './install.js';
+5 -2
View File
@@ -5,8 +5,11 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
debugLogger,
type SkillDefinition,
getErrorMessage,
} from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { installSkill } from '../../utils/skillUtils.js';
import chalk from 'chalk';
@@ -24,6 +24,9 @@ const { debugLogger } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
vi.mock('../../config/extensions/consent.js', () => ({
+1 -2
View File
@@ -5,10 +5,9 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { getErrorMessage } from '../../utils/errors.js';
import { exitCli } from '../utils.js';
import {
requestConsentNonInteractive,
@@ -21,6 +21,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
import { handleUninstall, uninstallCommand } from './uninstall.js';
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { uninstallSkill } from '../../utils/skillUtils.js';
import chalk from 'chalk';
+1 -1
View File
@@ -5,9 +5,9 @@
*/
import { simpleGit } from 'simple-git';
import { getErrorMessage } from '../../utils/errors.js';
import {
debugLogger,
getErrorMessage,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
+5 -2
View File
@@ -11,9 +11,12 @@ import {
} from '../../ui/state/extensions.js';
import { loadInstallMetadata } from '../extension.js';
import { checkForExtensionUpdate } from './github.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import {
debugLogger,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
import { ExtensionStorage } from './storage.js';
+1 -1
View File
@@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => {
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'There was an error saving your latest settings changes.',
'Failed to save settings: Write failed',
error,
);
});
+5 -2
View File
@@ -14,6 +14,7 @@ import {
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
getFsErrorMessage,
Storage,
coreEvents,
homedir,
@@ -1072,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
settingsToSave as Record<string, unknown>,
);
} catch (error) {
const detailedErrorMessage = getFsErrorMessage(error);
coreEvents.emitFeedback(
'error',
'There was an error saving your latest settings changes.',
`Failed to save settings: ${detailedErrorMessage}`,
error,
);
}
@@ -1087,9 +1089,10 @@ export function saveModelChange(
try {
loadedSettings.setValue(SettingScope.User, 'model.name', model);
} catch (error) {
const detailedErrorMessage = getFsErrorMessage(error);
coreEvents.emitFeedback(
'error',
'There was an error saving your preferred model.',
`Failed to save preferred model: ${detailedErrorMessage}`,
error,
);
}
+13
View File
@@ -1117,6 +1117,19 @@ const SETTINGS_SCHEMA = {
description: 'Model override for the visual agent.',
showInDialog: false,
},
allowedDomains: {
type: 'array',
label: 'Allowed Domains',
category: 'Advanced',
requiresRestart: true,
default: ['github.com', '*.google.com', 'localhost'] as string[],
description: oneLine`
A list of allowed domains for the browser agent
(e.g., ["github.com", "*.google.com"]).
`,
showInDialog: false,
items: { type: 'string' },
},
disableUserInput: {
type: 'boolean',
label: 'Disable User Input',
+7 -1
View File
@@ -487,7 +487,7 @@ export class AppRig {
}
async waitForPendingConfirmation(
toolNameOrDisplayName?: string | RegExp,
toolNameOrDisplayName?: string | RegExp | string[],
timeout = 30000,
): Promise<PendingConfirmation> {
const matches = (p: PendingConfirmation) => {
@@ -498,6 +498,12 @@ export class AppRig {
p.toolDisplayName === toolNameOrDisplayName
);
}
if (Array.isArray(toolNameOrDisplayName)) {
return (
toolNameOrDisplayName.includes(p.toolName) ||
toolNameOrDisplayName.includes(p.toolDisplayName || '')
);
}
return (
toolNameOrDisplayName.test(p.toolName) ||
toolNameOrDisplayName.test(p.toolDisplayName || '')
@@ -7,10 +7,10 @@
import {
debugLogger,
listExtensions,
getErrorMessage,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionUpdateInfo } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import {
emptyIcon,
MessageType,
@@ -16,9 +16,8 @@ import {
MessageType,
} from '../types.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
import {
linkSkill,
renderSkillActionFeedback,
@@ -116,38 +116,9 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
</>
);
/**
* Loading state component displayed while sessions are being loaded.
*/
const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
/**
* Error state component displayed when session loading fails.
*/
const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Empty state component displayed when no sessions are found.
*/
const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Empty state component displayed when no sessions are found.
*/
export const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Error state component displayed when session loading fails.
*/
export const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Loading state component displayed while sessions are being loaded.
*/
export const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SessionBrowserLoading } from './SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser UI States', () => {
it('SessionBrowserLoading renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserLoading />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserError renders correctly', async () => {
const mockState = { error: 'Test error message' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionBrowserError state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserEmpty renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserEmpty />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
" No auto-saved conversations found.
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
" Error: Test error message
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
" Loading sessions…
"
`;
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
const uiState = useUIState();
const todos: TodoList | null = useMemo(() => {
// Find the most recent todo list written by the WriteTodosTool
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
for (let i = uiState.history.length - 1; i >= 0; i--) {
const entry = uiState.history[i];
if (entry.type !== 'tool_group') {
@@ -760,6 +760,48 @@ describe('BaseSettingsDialog', () => {
});
unmount();
});
it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Type 'j' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('j');
});
await waitUntilReady();
// Type 'k' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('k');
});
await waitUntilReady();
// Commit with Enter
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// j and k should be typed into the field
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalledWith(
'string-setting',
'test-valuejk', // entered value + j and k
expect.objectContaining({ type: 'string' }),
);
});
unmount();
});
});
describe('custom key handling', () => {
@@ -325,13 +325,18 @@ export function BaseSettingsDialog({
return;
}
// Up/Down in edit mode - commit and navigate
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
// Up/Down in edit mode - commit and navigate.
// Only trigger on non-insertable keys (arrow keys) so that typing
// j/k characters into the edit buffer is not intercepted.
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && !key.insertable) {
commitEdit();
moveUp();
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
if (
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
!key.insertable
) {
commitEdit();
moveDown();
return;
@@ -7,9 +7,9 @@
import {
debugLogger,
checkExhaustive,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
ExtensionUpdateState,
extensionUpdatesReducer,
-20
View File
@@ -21,7 +21,6 @@ import {
coreEvents,
} from '@google/gemini-cli-core';
import {
getErrorMessage,
handleError,
handleToolError,
handleCancellationError,
@@ -152,25 +151,6 @@ describe('errors', () => {
processExitSpy.mockRestore();
});
describe('getErrorMessage', () => {
it('should return error message for Error instances', () => {
const error = new Error('Test error message');
expect(getErrorMessage(error)).toBe('Test error message');
});
it('should convert non-Error values to strings', () => {
expect(getErrorMessage('string error')).toBe('string error');
expect(getErrorMessage(123)).toBe('123');
expect(getErrorMessage(null)).toBe('null');
expect(getErrorMessage(undefined)).toBe('undefined');
});
it('should handle objects', () => {
const obj = { message: 'test' };
expect(getErrorMessage(obj)).toBe('[object Object]');
});
});
describe('handleError', () => {
describe('in text mode', () => {
beforeEach(() => {
+1 -7
View File
@@ -18,16 +18,10 @@ import {
isFatalToolError,
debugLogger,
coreEvents,
getErrorMessage,
} from '@google/gemini-cli-core';
import { runSyncCleanup } from './cleanup.js';
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
interface ErrorWithCode extends Error {
exitCode?: number;
code?: string | number;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -53,9 +53,22 @@ When you need to identify elements by visual attributes not in the AX tree (e.g.
* Extracted from prototype (computer_use_subagent_cdt branch).
*
* @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
* @param allowedDomains Optional list of allowed domains to restrict navigation.
*/
export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
export function buildBrowserSystemPrompt(
visionEnabled: boolean,
allowedDomains?: string[],
): string {
const allowedDomainsInstruction =
allowedDomains && allowedDomains.length > 0
? `\n\nSECURITY DOMAIN RESTRICTION - CRITICAL:\nYou are strictly limited to the following allowed domains (and their subdomains if specified with '*.'):\n${allowedDomains
.map((d) => `- ${d}`)
.join(
'\n',
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.`
: '';
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
Use these uid values directly with your tools:
@@ -166,7 +179,10 @@ export const BrowserAgentDefinition = (
</task>
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
systemPrompt: buildBrowserSystemPrompt(visionEnabled),
systemPrompt: buildBrowserSystemPrompt(
visionEnabled,
config.getBrowserAgentConfig().customConfig.allowedDomains,
),
},
};
};
@@ -239,6 +239,25 @@ describe('browserAgentFactory', () => {
expect(toolNames).toContain('analyze_screenshot');
});
it('should include domain restrictions in system prompt when configured', async () => {
const configWithDomains = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['restricted.com'],
},
},
});
const { definition } = await createBrowserAgentDefinition(
configWithDomains,
mockMessageBus,
);
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
expect(systemPrompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
expect(systemPrompt).toContain('- restricted.com');
});
it('should include all MCP navigation tools (new_page, navigate_page) in definition', async () => {
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
{ name: 'take_snapshot', description: 'Take snapshot' },
@@ -323,4 +342,22 @@ describe('buildBrowserSystemPrompt', () => {
expect(prompt).toContain('complete_task');
}
});
it('should include allowed domains restriction when provided', () => {
const prompt = buildBrowserSystemPrompt(false, [
'github.com',
'*.google.com',
]);
expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
expect(prompt).toContain('- github.com');
expect(prompt).toContain('- *.google.com');
});
it('should exclude allowed domains restriction when not provided or empty', () => {
let prompt = buildBrowserSystemPrompt(false);
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
prompt = buildBrowserSystemPrompt(false, []);
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
});
});
@@ -143,6 +143,75 @@ describe('BrowserManager', () => {
isError: false,
});
});
it('should block navigate_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
expect(Client).not.toHaveBeenCalled();
});
it('should allow navigate_page to allowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://google.com/search',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should allow navigate_page to subdomain when wildcard is used', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://mail.google.com',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should block new_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
});
});
describe('MCP connection', () => {
@@ -172,6 +241,40 @@ describe('BrowserManager', () => {
expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
});
it('should pass --host-rules when allowedDomains is configured', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com', '*.openai.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain(
'--chromeArg="--host-rules=MAP * 127.0.0.1, EXCLUDE google.com, EXCLUDE *.openai.com, EXCLUDE 127.0.0.1"',
);
});
it('should throw error when invalid domain is configured in allowedDomains', async () => {
const invalidConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['invalid domain!'],
},
},
});
const manager = new BrowserManager(invalidConfig);
await expect(manager.ensureConnection()).rejects.toThrow(
'Invalid domain in allowedDomains: invalid domain!',
);
});
it('should pass headless flag when configured', async () => {
const headlessConfig = makeFakeConfig({
agents: {
@@ -147,6 +147,19 @@ export class BrowserManager {
throw signal.reason ?? new Error('Operation cancelled');
}
const errorMessage = this.checkNavigationRestrictions(toolName, args);
if (errorMessage) {
return {
content: [
{
type: 'text',
text: errorMessage,
},
],
isError: true,
};
}
const client = await this.getRawMcpClient();
const callPromise = client.callTool(
{ name: toolName, arguments: args },
@@ -342,6 +355,23 @@ export class BrowserManager {
mcpArgs.push('--userDataDir', defaultProfilePath);
}
if (
browserConfig.customConfig.allowedDomains &&
browserConfig.customConfig.allowedDomains.length > 0
) {
const exclusionRules = browserConfig.customConfig.allowedDomains
.map((domain) => {
if (!/^(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/.test(domain)) {
throw new Error(`Invalid domain in allowedDomains: ${domain}`);
}
return `EXCLUDE ${domain}`;
})
.join(', ');
mcpArgs.push(
`--chromeArg="--host-rules=MAP * 127.0.0.1, ${exclusionRules}, EXCLUDE 127.0.0.1"`,
);
}
debugLogger.log(
`Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
);
@@ -502,6 +532,63 @@ export class BrowserManager {
);
}
/**
* Check navigation restrictions based on tools and the args sent
* along with them.
*
* @returns error message if failed, undefined if passed.
*/
private checkNavigationRestrictions(
toolName: string,
args: Record<string, unknown>,
): string | undefined {
const pageNavigationTools = ['navigate_page', 'new_page'];
if (!pageNavigationTools.includes(toolName)) {
return undefined;
}
const allowedDomains =
this.config.getBrowserAgentConfig().customConfig.allowedDomains;
if (!allowedDomains || allowedDomains.length === 0) {
return undefined;
}
const url = args['url'];
if (!url) {
return undefined;
}
if (typeof url !== 'string') {
return `Invalid URL: URL must be a string.`;
}
try {
const parsedUrl = new URL(url);
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
for (const domainPattern of allowedDomains) {
if (domainPattern.startsWith('*.')) {
const baseDomain = domainPattern.substring(2);
if (
urlHostname === baseDomain ||
urlHostname.endsWith(`.${baseDomain}`)
) {
return undefined;
}
} else {
if (urlHostname === domainPattern) {
return undefined;
}
}
}
} catch {
return `Invalid URL: Malformed URL string.`;
}
// If none matched, then deny
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
}
/**
* Registers a fallback notification handler on the MCP client to
* automatically re-inject the input blocker after any server-side
@@ -10,7 +10,7 @@ import {
type ToolInvocation,
type ToolResult,
} from '../tools/tools.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
@@ -54,10 +54,6 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
);
}
private get config(): Config {
return this.context.config;
}
/**
* Creates an invocation instance for executing the subagent.
*
@@ -89,7 +85,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
// Special handling for browser agent - needs async MCP setup
if (definition.name === BROWSER_AGENT_NAME) {
return new BrowserAgentInvocation(
this.config,
this.context,
params,
effectiveMessageBus,
_toolName,
+3
View File
@@ -316,6 +316,8 @@ export interface BrowserAgentCustomConfig {
profilePath?: string;
/** Model override for the visual agent. */
visualModel?: string;
/** List of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). */
allowedDomains?: string[];
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
disableUserInput?: boolean;
}
@@ -2902,6 +2904,7 @@ export class Config implements McpContext, AgentLoopContext {
headless: customConfig.headless ?? false,
profilePath: customConfig.profilePath,
visualModel: customConfig.visualModel,
allowedDomains: customConfig.allowedDomains,
disableUserInput: customConfig.disableUserInput,
},
};
+1
View File
@@ -68,6 +68,7 @@ export * from './utils/checks.js';
export * from './utils/headless.js';
export * from './utils/schemaValidator.js';
export * from './utils/errors.js';
export * from './utils/fsErrorMessages.js';
export * from './utils/exitCodes.js';
export * from './utils/getFolderStructure.js';
export * from './utils/memoryDiscovery.js';
@@ -23,10 +23,14 @@ vi.mock('node:fs', () => ({
},
}));
vi.mock('node:path', () => ({
dirname: vi.fn(),
join: vi.fn(),
}));
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
dirname: vi.fn(),
join: vi.fn(),
};
});
vi.mock('../config/storage.js', () => ({
Storage: {
@@ -40,14 +44,14 @@ vi.mock('../utils/events.js', () => ({
},
}));
const mockHybridTokenStorage = {
const mockHybridTokenStorage = vi.hoisted(() => ({
listServers: vi.fn(),
setCredentials: vi.fn(),
getCredentials: vi.fn(),
deleteCredentials: vi.fn(),
clearAll: vi.fn(),
getAllCredentials: vi.fn(),
};
}));
vi.mock('./token-storage/hybrid-token-storage.js', () => ({
HybridTokenStorage: vi.fn(() => mockHybridTokenStorage),
}));
@@ -1,360 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { FileTokenStorage } from './file-token-storage.js';
import type { OAuthCredentials } from './types.js';
import { GEMINI_DIR } from '../../utils/paths.js';
vi.mock('node:fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
rename: vi.fn(),
},
}));
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(() => '/home/test'),
hostname: vi.fn(() => 'test-host'),
userInfo: vi.fn(() => ({ username: 'test-user' })),
},
homedir: vi.fn(() => '/home/test'),
hostname: vi.fn(() => 'test-host'),
userInfo: vi.fn(() => ({ username: 'test-user' })),
}));
describe('FileTokenStorage', () => {
let storage: FileTokenStorage;
const mockFs = fs as unknown as {
readFile: ReturnType<typeof vi.fn>;
writeFile: ReturnType<typeof vi.fn>;
unlink: ReturnType<typeof vi.fn>;
mkdir: ReturnType<typeof vi.fn>;
rename: ReturnType<typeof vi.fn>;
};
const existingCredentials: OAuthCredentials = {
serverName: 'existing-server',
token: {
accessToken: 'existing-token',
tokenType: 'Bearer',
},
updatedAt: Date.now() - 10000,
};
beforeEach(() => {
vi.clearAllMocks();
storage = new FileTokenStorage('test-storage');
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getCredentials', () => {
it('should return null when file does not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
it('should return null for expired tokens', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() - 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toBeNull();
});
it('should return credentials for valid tokens', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600000,
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.getCredentials('test-server');
expect(result).toEqual(credentials);
});
it('should throw error with file path when file is corrupted', async () => {
mockFs.readFile.mockResolvedValue('corrupted-data');
try {
await storage.getCredentials('test-server');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('auth type switching', () => {
it('should throw error when trying to save credentials with corrupted file', async () => {
// Simulate corrupted file on first read
mockFs.readFile.mockResolvedValue('corrupted-data');
// Try to save new credentials (simulating switch from OAuth to API key)
const newCredentials: OAuthCredentials = {
serverName: 'new-auth-server',
token: {
accessToken: 'new-api-key',
tokenType: 'ApiKey',
},
updatedAt: Date.now(),
};
// Should throw error with file path
try {
await storage.setCredentials(newCredentials);
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const err = error as Error;
expect(err.message).toContain('Corrupted token file detected at:');
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
expect(err.message).toContain('delete or rename');
}
});
});
describe('setCredentials', () => {
it('should save credentials with encryption', async () => {
const encryptedData = storage['encrypt'](
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(credentials);
expect(mockFs.mkdir).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR),
{ recursive: true, mode: 0o700 },
);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
expect(writeCall[1]).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
expect(writeCall[2]).toEqual({ mode: 0o600 });
});
it('should update existing credentials', async () => {
const encryptedData = storage['encrypt'](
JSON.stringify({ 'existing-server': existingCredentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.writeFile.mockResolvedValue(undefined);
const newCredentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'new-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
await storage.setCredentials(newCredentials);
expect(mockFs.writeFile).toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
const decrypted = storage['decrypt'](writeCall[1]);
const saved = JSON.parse(decrypted);
expect(saved['existing-server']).toEqual(existingCredentials);
expect(saved['test-server'].token.accessToken).toBe('new-token');
});
});
describe('deleteCredentials', () => {
it('should throw when credentials do not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
await expect(storage.deleteCredentials('test-server')).rejects.toThrow(
'No credentials found for test-server',
);
});
it('should delete file when last credential is removed', async () => {
const credentials: OAuthCredentials = {
serverName: 'test-server',
token: {
accessToken: 'access-token',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ 'test-server': credentials }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.unlink.mockResolvedValue(undefined);
await storage.deleteCredentials('test-server');
expect(mockFs.unlink).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR, 'mcp-oauth-tokens-v2.json'),
);
});
it('should update file when other credentials remain', async () => {
const credentials1: OAuthCredentials = {
serverName: 'server1',
token: {
accessToken: 'token1',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const credentials2: OAuthCredentials = {
serverName: 'server2',
token: {
accessToken: 'token2',
tokenType: 'Bearer',
},
updatedAt: Date.now(),
};
const encryptedData = storage['encrypt'](
JSON.stringify({ server1: credentials1, server2: credentials2 }),
);
mockFs.readFile.mockResolvedValue(encryptedData);
mockFs.writeFile.mockResolvedValue(undefined);
await storage.deleteCredentials('server1');
expect(mockFs.writeFile).toHaveBeenCalled();
expect(mockFs.unlink).not.toHaveBeenCalled();
const writeCall = mockFs.writeFile.mock.calls[0];
const decrypted = storage['decrypt'](writeCall[1]);
const saved = JSON.parse(decrypted);
expect(saved['server1']).toBeUndefined();
expect(saved['server2']).toEqual(credentials2);
});
});
describe('listServers', () => {
it('should return empty list when file does not exist', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const result = await storage.listServers();
expect(result).toEqual([]);
});
it('should return list of server names', async () => {
const credentials: Record<string, OAuthCredentials> = {
server1: {
serverName: 'server1',
token: { accessToken: 'token1', tokenType: 'Bearer' },
updatedAt: Date.now(),
},
server2: {
serverName: 'server2',
token: { accessToken: 'token2', tokenType: 'Bearer' },
updatedAt: Date.now(),
},
};
const encryptedData = storage['encrypt'](JSON.stringify(credentials));
mockFs.readFile.mockResolvedValue(encryptedData);
const result = await storage.listServers();
expect(result).toEqual(['server1', 'server2']);
});
});
describe('clearAll', () => {
it('should delete the token file', async () => {
mockFs.unlink.mockResolvedValue(undefined);
await storage.clearAll();
expect(mockFs.unlink).toHaveBeenCalledWith(
path.join('/home/test', GEMINI_DIR, 'mcp-oauth-tokens-v2.json'),
);
});
it('should not throw when file does not exist', async () => {
mockFs.unlink.mockRejectedValue({ code: 'ENOENT' });
await expect(storage.clearAll()).resolves.not.toThrow();
});
});
describe('encryption', () => {
it('should encrypt and decrypt data correctly', () => {
const original = 'test-data-123';
const encrypted = storage['encrypt'](original);
const decrypted = storage['decrypt'](encrypted);
expect(decrypted).toBe(original);
expect(encrypted).not.toBe(original);
expect(encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
});
it('should produce different encrypted output each time', () => {
const original = 'test-data';
const encrypted1 = storage['encrypt'](original);
const encrypted2 = storage['encrypt'](original);
expect(encrypted1).not.toBe(encrypted2);
expect(storage['decrypt'](encrypted1)).toBe(original);
expect(storage['decrypt'](encrypted2)).toBe(original);
});
it('should throw on invalid encrypted data format', () => {
expect(() => storage['decrypt']('invalid-data')).toThrow(
'Invalid encrypted data format',
);
});
});
});
@@ -1,194 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage.js';
import type { OAuthCredentials } from './types.js';
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
export class FileTokenStorage extends BaseTokenStorage {
private readonly tokenFilePath: string;
private readonly encryptionKey: Buffer;
constructor(serviceName: string) {
super(serviceName);
const configDir = path.join(homedir(), GEMINI_DIR);
this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens-v2.json');
this.encryptionKey = this.deriveEncryptionKey();
}
private deriveEncryptionKey(): Buffer {
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
return crypto.scryptSync('gemini-cli-oauth', salt, 32);
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
private decrypt(encryptedData: string): string {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private async ensureDirectoryExists(): Promise<void> {
const dir = path.dirname(this.tokenFilePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
}
private async loadTokens(): Promise<Map<string, OAuthCredentials>> {
try {
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const tokens = JSON.parse(decrypted) as Record<string, OAuthCredentials>;
return new Map(Object.entries(tokens));
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException & { message?: string };
if (err.code === 'ENOENT') {
return new Map();
}
if (
err.message?.includes('Invalid encrypted data format') ||
err.message?.includes(
'Unsupported state or unable to authenticate data',
)
) {
// Decryption failed - this can happen when switching between auth types
// or if the file is genuinely corrupted.
throw new Error(
`Corrupted token file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
}
private async saveTokens(
tokens: Map<string, OAuthCredentials>,
): Promise<void> {
await this.ensureDirectoryExists();
const data = Object.fromEntries(tokens);
const json = JSON.stringify(data, null, 2);
const encrypted = this.encrypt(json);
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
}
async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
const tokens = await this.loadTokens();
const credentials = tokens.get(serverName);
if (!credentials) {
return null;
}
if (this.isTokenExpired(credentials)) {
return null;
}
return credentials;
}
async setCredentials(credentials: OAuthCredentials): Promise<void> {
this.validateCredentials(credentials);
const tokens = await this.loadTokens();
const updatedCredentials: OAuthCredentials = {
...credentials,
updatedAt: Date.now(),
};
tokens.set(credentials.serverName, updatedCredentials);
await this.saveTokens(tokens);
}
async deleteCredentials(serverName: string): Promise<void> {
const tokens = await this.loadTokens();
if (!tokens.has(serverName)) {
throw new Error(`No credentials found for ${serverName}`);
}
tokens.delete(serverName);
if (tokens.size === 0) {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
} else {
await this.saveTokens(tokens);
}
}
async listServers(): Promise<string[]> {
const tokens = await this.loadTokens();
return Array.from(tokens.keys());
}
async getAllCredentials(): Promise<Map<string, OAuthCredentials>> {
const tokens = await this.loadTokens();
const result = new Map<string, OAuthCredentials>();
for (const [serverName, credentials] of tokens) {
if (!this.isTokenExpired(credentials)) {
result.set(serverName, credentials);
}
}
return result;
}
async clearAll(): Promise<void> {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
}
}
@@ -7,12 +7,12 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HybridTokenStorage } from './hybrid-token-storage.js';
import { KeychainTokenStorage } from './keychain-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import { type OAuthCredentials, TokenStorageType } from './types.js';
vi.mock('./keychain-token-storage.js', () => ({
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
isAvailable: vi.fn(),
isUsingFileFallback: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
@@ -36,19 +36,9 @@ vi.mock('../../core/apiKeyCredentialStorage.js', () => ({
clearApiKey: vi.fn(),
}));
vi.mock('./file-token-storage.js', () => ({
FileTokenStorage: vi.fn().mockImplementation(() => ({
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
listServers: vi.fn(),
getAllCredentials: vi.fn(),
clearAll: vi.fn(),
})),
}));
interface MockStorage {
isAvailable?: ReturnType<typeof vi.fn>;
isUsingFileFallback: ReturnType<typeof vi.fn>;
getCredentials: ReturnType<typeof vi.fn>;
setCredentials: ReturnType<typeof vi.fn>;
deleteCredentials: ReturnType<typeof vi.fn>;
@@ -60,7 +50,6 @@ interface MockStorage {
describe('HybridTokenStorage', () => {
let storage: HybridTokenStorage;
let mockKeychainStorage: MockStorage;
let mockFileStorage: MockStorage;
const originalEnv = process.env;
beforeEach(() => {
@@ -70,15 +59,7 @@ describe('HybridTokenStorage', () => {
// Create mock instances before creating HybridTokenStorage
mockKeychainStorage = {
isAvailable: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
listServers: vi.fn(),
getAllCredentials: vi.fn(),
clearAll: vi.fn(),
};
mockFileStorage = {
isUsingFileFallback: vi.fn(),
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
@@ -90,9 +71,6 @@ describe('HybridTokenStorage', () => {
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
(
FileTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockFileStorage);
storage = new HybridTokenStorage('test-service');
});
@@ -102,74 +80,31 @@ describe('HybridTokenStorage', () => {
});
describe('storage selection', () => {
it('should use keychain when available', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
it('should use keychain normally', async () => {
mockKeychainStorage.isUsingFileFallback.mockResolvedValue(false);
mockKeychainStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(TokenStorageType.KEYCHAIN);
});
it('should use file storage when GEMINI_FORCE_FILE_STORAGE is set', async () => {
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).not.toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should fall back to file storage when keychain is unavailable', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(false);
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should fall back to file storage when keychain throws error', async () => {
mockKeychainStorage.isAvailable!.mockRejectedValue(
new Error('Keychain error'),
);
mockFileStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalled();
expect(mockFileStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await storage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
it('should cache storage selection', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
it('should use file storage when isUsingFileFallback is true', async () => {
mockKeychainStorage.isUsingFileFallback.mockResolvedValue(true);
mockKeychainStorage.getCredentials.mockResolvedValue(null);
await storage.getCredentials('test-server');
await storage.getCredentials('another-server');
const forceStorage = new HybridTokenStorage('test-service-forced');
await forceStorage.getCredentials('test-server');
expect(mockKeychainStorage.isAvailable).toHaveBeenCalledTimes(1);
expect(mockKeychainStorage.getCredentials).toHaveBeenCalledWith(
'test-server',
);
expect(await forceStorage.getStorageType()).toBe(
TokenStorageType.ENCRYPTED_FILE,
);
});
});
@@ -184,7 +119,6 @@ describe('HybridTokenStorage', () => {
updatedAt: Date.now(),
};
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.getCredentials.mockResolvedValue(credentials);
const result = await storage.getCredentials('test-server');
@@ -207,7 +141,6 @@ describe('HybridTokenStorage', () => {
updatedAt: Date.now(),
};
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.setCredentials.mockResolvedValue(undefined);
await storage.setCredentials(credentials);
@@ -220,7 +153,6 @@ describe('HybridTokenStorage', () => {
describe('deleteCredentials', () => {
it('should delegate to selected storage', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.deleteCredentials.mockResolvedValue(undefined);
await storage.deleteCredentials('test-server');
@@ -234,7 +166,6 @@ describe('HybridTokenStorage', () => {
describe('listServers', () => {
it('should delegate to selected storage', async () => {
const servers = ['server1', 'server2'];
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.listServers.mockResolvedValue(servers);
const result = await storage.listServers();
@@ -265,7 +196,6 @@ describe('HybridTokenStorage', () => {
],
]);
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.getAllCredentials.mockResolvedValue(credentialsMap);
const result = await storage.getAllCredentials();
@@ -277,7 +207,6 @@ describe('HybridTokenStorage', () => {
describe('clearAll', () => {
it('should delegate to selected storage', async () => {
mockKeychainStorage.isAvailable!.mockResolvedValue(true);
mockKeychainStorage.clearAll.mockResolvedValue(undefined);
await storage.clearAll();
@@ -5,7 +5,7 @@
*/
import { BaseTokenStorage } from './base-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import { KeychainTokenStorage } from './keychain-token-storage.js';
import {
TokenStorageType,
type TokenStorage,
@@ -13,8 +13,7 @@ import {
} from './types.js';
import { coreEvents } from '../../utils/events.js';
import { TokenStorageInitializationEvent } from '../../telemetry/types.js';
const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
import { FORCE_FILE_STORAGE_ENV_VAR } from '../../services/keychainService.js';
export class HybridTokenStorage extends BaseTokenStorage {
private storage: TokenStorage | null = null;
@@ -28,34 +27,20 @@ export class HybridTokenStorage extends BaseTokenStorage {
private async initializeStorage(): Promise<TokenStorage> {
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
if (!forceFileStorage) {
try {
const { KeychainTokenStorage } = await import(
'./keychain-token-storage.js'
);
const keychainStorage = new KeychainTokenStorage(this.serviceName);
const keychainStorage = new KeychainTokenStorage(this.serviceName);
this.storage = keychainStorage;
const isAvailable = await keychainStorage.isAvailable();
if (isAvailable) {
this.storage = keychainStorage;
this.storageType = TokenStorageType.KEYCHAIN;
const isUsingFileFallback = await keychainStorage.isUsingFileFallback();
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('keychain', forceFileStorage),
);
return this.storage;
}
} catch (_e) {
// Fallback to file storage if keychain fails to initialize
}
}
this.storage = new FileTokenStorage(this.serviceName);
this.storageType = TokenStorageType.ENCRYPTED_FILE;
this.storageType = isUsingFileFallback
? TokenStorageType.ENCRYPTED_FILE
: TokenStorageType.KEYCHAIN;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('encrypted_file', forceFileStorage),
new TokenStorageInitializationEvent(
isUsingFileFallback ? 'encrypted_file' : 'keychain',
forceFileStorage,
),
);
return this.storage;
+1 -1
View File
@@ -6,8 +6,8 @@
export * from './types.js';
export * from './base-token-storage.js';
export * from './file-token-storage.js';
export * from './hybrid-token-storage.js';
export * from './keychain-token-storage.js';
export const DEFAULT_SERVICE_NAME = 'gemini-cli-oauth';
export const FORCE_ENCRYPTED_FILE_ENV_VAR =
@@ -159,6 +159,10 @@ export class KeychainTokenStorage
return this.keychainService.isAvailable();
}
async isUsingFileFallback(): Promise<boolean> {
return this.keychainService.isUsingFileFallback();
}
async setSecret(key: string, value: string): Promise<void> {
await this.keychainService.setPassword(`${SECRET_PREFIX}${key}`, value);
}
@@ -676,6 +676,43 @@ describe('policy.ts', () => {
}),
);
});
it('should work when context is created via Object.create (prototype chain)', async () => {
const mockConfig = {
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const baseContext = {
config: mockConfig,
messageBus: mockMessageBus,
};
const protoContext: AgentLoopContext = Object.create(baseContext);
expect(Object.keys(protoContext)).toHaveLength(0);
expect(protoContext.config).toBe(mockConfig);
expect(protoContext.messageBus).toBe(mockMessageBus);
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
protoContext,
mockMessageBus,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
persist: false,
}),
);
});
});
describe('getPolicyDenialError', () => {
+160
View File
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import type { Keychain } from './keychainTypes.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
export class FileKeychain implements Keychain {
private readonly tokenFilePath: string;
private readonly encryptionKey: Buffer;
constructor() {
const configDir = path.join(homedir(), GEMINI_DIR);
this.tokenFilePath = path.join(configDir, 'gemini-credentials.json');
this.encryptionKey = this.deriveEncryptionKey();
}
private deriveEncryptionKey(): Buffer {
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
return crypto.scryptSync('gemini-cli-oauth', salt, 32);
}
private encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
private decrypt(encryptedData: string): string {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
private async ensureDirectoryExists(): Promise<void> {
const dir = path.dirname(this.tokenFilePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
}
private async loadData(): Promise<Record<string, Record<string, string>>> {
try {
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
const decrypted = this.decrypt(data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException & { message?: string };
if (err.code === 'ENOENT') {
return {};
}
if (
err.message?.includes('Invalid encrypted data format') ||
err.message?.includes(
'Unsupported state or unable to authenticate data',
)
) {
throw new Error(
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
`Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
}
private async saveData(
data: Record<string, Record<string, string>>,
): Promise<void> {
await this.ensureDirectoryExists();
const json = JSON.stringify(data, null, 2);
const encrypted = this.encrypt(json);
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
}
async getPassword(service: string, account: string): Promise<string | null> {
const data = await this.loadData();
return data[service]?.[account] ?? null;
}
async setPassword(
service: string,
account: string,
password: string,
): Promise<void> {
const data = await this.loadData();
if (!data[service]) {
data[service] = {};
}
data[service][account] = password;
await this.saveData(data);
}
async deletePassword(service: string, account: string): Promise<boolean> {
const data = await this.loadData();
if (data[service] && account in data[service]) {
delete data[service][account];
if (Object.keys(data[service]).length === 0) {
delete data[service];
}
if (Object.keys(data).length === 0) {
try {
await fs.unlink(this.tokenFilePath);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
throw error;
}
}
} else {
await this.saveData(data);
}
return true;
}
return false;
}
async findCredentials(
service: string,
): Promise<Array<{ account: string; password: string }>> {
const data = await this.loadData();
const serviceData = data[service] || {};
return Object.entries(serviceData).map(([account, password]) => ({
account,
password,
}));
}
}
@@ -4,10 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { KeychainService } from './keychainService.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { FileKeychain } from './fileKeychain.js';
type MockKeychain = {
getPassword: Mock | undefined;
@@ -23,8 +32,19 @@ const mockKeytar: MockKeychain = {
findCredentials: vi.fn(),
};
const mockFileKeychain: MockKeychain = {
getPassword: vi.fn(),
setPassword: vi.fn(),
deletePassword: vi.fn(),
findCredentials: vi.fn(),
};
vi.mock('keytar', () => ({ default: mockKeytar }));
vi.mock('./fileKeychain.js', () => ({
FileKeychain: vi.fn(() => mockFileKeychain),
}));
vi.mock('../utils/events.js', () => ({
coreEvents: { emitTelemetryKeychainAvailability: vi.fn() },
}));
@@ -37,13 +57,15 @@ describe('KeychainService', () => {
let service: KeychainService;
const SERVICE_NAME = 'test-service';
let passwords: Record<string, string> = {};
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
service = new KeychainService(SERVICE_NAME);
passwords = {};
// Stateful mock implementation to verify behavioral correctness
// Stateful mock implementation for native keychain
mockKeytar.setPassword?.mockImplementation((_svc, acc, val) => {
passwords[acc] = val;
return Promise.resolve();
@@ -64,10 +86,36 @@ describe('KeychainService', () => {
})),
),
);
// Stateful mock implementation for fallback file keychain
mockFileKeychain.setPassword?.mockImplementation((_svc, acc, val) => {
passwords[acc] = val;
return Promise.resolve();
});
mockFileKeychain.getPassword?.mockImplementation((_svc, acc) =>
Promise.resolve(passwords[acc] ?? null),
);
mockFileKeychain.deletePassword?.mockImplementation((_svc, acc) => {
const exists = !!passwords[acc];
delete passwords[acc];
return Promise.resolve(exists);
});
mockFileKeychain.findCredentials?.mockImplementation(() =>
Promise.resolve(
Object.entries(passwords).map(([account, password]) => ({
account,
password,
})),
),
);
});
afterEach(() => {
process.env = originalEnv;
});
describe('isAvailable', () => {
it('should return true and emit telemetry on successful functional test', async () => {
it('should return true and emit telemetry on successful functional test with native keychain', async () => {
const available = await service.isAvailable();
expect(available).toBe(true);
@@ -77,12 +125,13 @@ describe('KeychainService', () => {
);
});
it('should return false, log error, and emit telemetry on failed functional test', async () => {
it('should return true (via fallback), log error, and emit telemetry indicating native is unavailable on failed functional test', async () => {
mockKeytar.setPassword?.mockRejectedValue(new Error('locked'));
const available = await service.isAvailable();
expect(available).toBe(false);
// Because it falls back to FileKeychain, it is always available.
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('encountered an error'),
'locked',
@@ -90,15 +139,19 @@ describe('KeychainService', () => {
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Using FileKeychain fallback'),
);
expect(FileKeychain).toHaveBeenCalled();
});
it('should return false, log validation error, and emit telemetry on module load failure', async () => {
it('should return true (via fallback), log validation error, and emit telemetry on module load failure', async () => {
const originalMock = mockKeytar.getPassword;
mockKeytar.getPassword = undefined; // Break schema
const available = await service.isAvailable();
expect(available).toBe(false);
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('failed structural validation'),
expect.objectContaining({ getPassword: expect.any(Array) }),
@@ -106,19 +159,31 @@ describe('KeychainService', () => {
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
expect(FileKeychain).toHaveBeenCalled();
mockKeytar.getPassword = originalMock;
});
it('should log failure if functional test cycle returns false', async () => {
it('should log failure if functional test cycle returns false, then fallback', async () => {
mockKeytar.getPassword?.mockResolvedValue('wrong-password');
const available = await service.isAvailable();
expect(available).toBe(false);
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('functional verification failed'),
);
expect(FileKeychain).toHaveBeenCalled();
});
it('should fallback to FileKeychain when GEMINI_FORCE_FILE_STORAGE is true', async () => {
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
const available = await service.isAvailable();
expect(available).toBe(true);
expect(FileKeychain).toHaveBeenCalled();
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
});
it('should cache the result and handle concurrent initialization attempts once', async () => {
@@ -159,25 +224,5 @@ describe('KeychainService', () => {
});
});
describe('When Unavailable', () => {
beforeEach(() => {
mockKeytar.setPassword?.mockRejectedValue(new Error('Unavailable'));
});
it.each([
{ method: 'getPassword', args: ['acc'] },
{ method: 'setPassword', args: ['acc', 'val'] },
{ method: 'deletePassword', args: ['acc'] },
{ method: 'findCredentials', args: [] },
])('$method should throw a consistent error', async ({ method, args }) => {
await expect(
(
service as unknown as Record<
string,
(...args: unknown[]) => Promise<unknown>
>
)[method](...args),
).rejects.toThrow('Keychain is not available');
});
});
// Removing 'When Unavailable' tests since the service is always available via fallback
});
+37 -12
View File
@@ -14,6 +14,9 @@ import {
KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
import { isRecord } from '../utils/markdownUtils.js';
import { FileKeychain } from './fileKeychain.js';
export const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
/**
* Service for interacting with OS-level secure storage (e.g. keytar).
@@ -31,6 +34,14 @@ export class KeychainService {
return (await this.getKeychain()) !== null;
}
/**
* Returns true if the service is using the encrypted file fallback backend.
*/
async isUsingFileFallback(): Promise<boolean> {
const keychain = await this.getKeychain();
return keychain instanceof FileKeychain;
}
/**
* Retrieves a secret for the given account.
* @throws Error if the keychain is unavailable.
@@ -85,26 +96,40 @@ export class KeychainService {
// High-level orchestration of the loading and testing cycle.
private async initializeKeychain(): Promise<Keychain | null> {
let resultKeychain: Keychain | null = null;
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
try {
const keychainModule = await this.loadKeychainModule();
if (keychainModule) {
if (await this.isKeychainFunctional(keychainModule)) {
resultKeychain = keychainModule;
} else {
debugLogger.log('Keychain functional verification failed');
if (!forceFileStorage) {
try {
const keychainModule = await this.loadKeychainModule();
if (keychainModule) {
if (await this.isKeychainFunctional(keychainModule)) {
resultKeychain = keychainModule;
} else {
debugLogger.log('Keychain functional verification failed');
}
}
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.log(
'Keychain initialization encountered an error:',
message,
);
}
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.log('Keychain initialization encountered an error:', message);
}
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(resultKeychain !== null),
new KeychainAvailabilityEvent(
resultKeychain !== null && !forceFileStorage,
),
);
// Fallback to FileKeychain if native keychain is unavailable or file storage is forced
if (!resultKeychain) {
resultKeychain = new FileKeychain();
debugLogger.log('Using FileKeychain fallback for secure storage.');
}
return resultKeychain;
}
@@ -13,6 +13,12 @@ export enum TaskType {
}
export const TaskTypeSchema = z.nativeEnum(TaskType);
export const TASK_TYPE_LABELS: Record<TaskType, string> = {
[TaskType.EPIC]: '[EPIC]',
[TaskType.TASK]: '[TASK]',
[TaskType.BUG]: '[BUG]',
};
export enum TaskStatus {
OPEN = 'open',
IN_PROGRESS = 'in_progress',
@@ -14,12 +14,14 @@ import {
TrackerUpdateTaskTool,
TrackerVisualizeTool,
TrackerAddDependencyTool,
buildTodosReturnDisplay,
} from './trackerTools.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { TaskStatus, TaskType } from '../services/trackerTypes.js';
import type { TrackerService } from '../services/trackerService.js';
describe('Tracker Tools Integration', () => {
let tempDir: string;
@@ -142,4 +144,90 @@ describe('Tracker Tools Integration', () => {
expect(vizResult.llmContent).toContain('Child Task');
expect(vizResult.llmContent).toContain(childId);
});
describe('buildTodosReturnDisplay', () => {
it('returns empty list for no tasks', async () => {
const mockService = {
listTasks: async () => [],
} as unknown as TrackerService;
const result = await buildTodosReturnDisplay(mockService);
expect(result.todos).toEqual([]);
});
it('returns formatted todos', async () => {
const parent = {
id: 'p1',
title: 'Parent',
type: TaskType.TASK,
status: TaskStatus.IN_PROGRESS,
dependencies: [],
};
const child = {
id: 'c1',
title: 'Child',
type: TaskType.EPIC,
status: TaskStatus.OPEN,
parentId: 'p1',
dependencies: [],
};
const closedLeaf = {
id: 'leaf',
title: 'Closed Leaf',
type: TaskType.BUG,
status: TaskStatus.CLOSED,
parentId: 'c1',
dependencies: [],
};
const mockService = {
listTasks: async () => [parent, child, closedLeaf],
} as unknown as TrackerService;
const display = await buildTodosReturnDisplay(mockService);
expect(display.todos).toEqual([
{
description: `[p1] [TASK] Parent`,
status: 'in_progress',
},
{
description: ` [c1] [EPIC] Child`,
status: 'pending',
},
{
description: ` [leaf] [BUG] Closed Leaf`,
status: 'completed',
},
]);
});
it('detects cycles', async () => {
// Since TrackerTask only has a single parentId, a true cycle is unreachable from roots.
// We simulate a database corruption (two tasks with same ID, one root, one child)
// just to exercise the protective cycle detection branch.
const rootP1 = {
id: 'p1',
title: 'Parent',
type: TaskType.TASK,
status: TaskStatus.OPEN,
dependencies: [],
};
const childP1 = { ...rootP1, parentId: 'p1' };
const mockService = {
listTasks: async () => [rootP1, childP1],
} as unknown as TrackerService;
const display = await buildTodosReturnDisplay(mockService);
expect(display.todos).toEqual([
{
description: `[p1] [TASK] Parent`,
status: 'pending',
},
{
description: ` [CYCLE DETECTED: p1]`,
status: 'cancelled',
},
]);
});
});
});
+69 -16
View File
@@ -23,11 +23,69 @@ import {
TRACKER_UPDATE_TASK_TOOL_NAME,
TRACKER_VISUALIZE_TOOL_NAME,
} from './tool-names.js';
import type { ToolResult } from './tools.js';
import type { ToolResult, TodoList } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import type { TrackerTask, TaskType } from '../services/trackerTypes.js';
import { TaskStatus } from '../services/trackerTypes.js';
import { TaskStatus, TASK_TYPE_LABELS } from '../services/trackerTypes.js';
import type { TrackerService } from '../services/trackerService.js';
export async function buildTodosReturnDisplay(
service: TrackerService,
): Promise<TodoList> {
const tasks = await service.listTasks();
const childrenMap = new Map<string, TrackerTask[]>();
const roots: TrackerTask[] = [];
for (const task of tasks) {
if (task.parentId) {
if (!childrenMap.has(task.parentId)) {
childrenMap.set(task.parentId, []);
}
childrenMap.get(task.parentId)!.push(task);
} else {
roots.push(task);
}
}
const todos: TodoList['todos'] = [];
const addTask = (task: TrackerTask, depth: number, visited: Set<string>) => {
if (visited.has(task.id)) {
todos.push({
description: `${' '.repeat(depth)}[CYCLE DETECTED: ${task.id}]`,
status: 'cancelled',
});
return;
}
visited.add(task.id);
let status: 'pending' | 'in_progress' | 'completed' | 'cancelled' =
'pending';
if (task.status === TaskStatus.IN_PROGRESS) {
status = 'in_progress';
} else if (task.status === TaskStatus.CLOSED) {
status = 'completed';
}
const indent = ' '.repeat(depth);
const description = `${indent}[${task.id}] ${TASK_TYPE_LABELS[task.type]} ${task.title}`;
todos.push({ description, status });
const children = childrenMap.get(task.id) ?? [];
for (const child of children) {
addTask(child, depth + 1, visited);
}
visited.delete(task.id);
};
for (const root of roots) {
addTask(root, 0, new Set());
}
return { todos };
}
// --- tracker_create_task ---
@@ -71,7 +129,7 @@ class TrackerCreateTaskInvocation extends BaseToolInvocation<
});
return {
llmContent: `Created task ${task.id}: ${task.title}`,
returnDisplay: `Created task ${task.id}.`,
returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -155,7 +213,7 @@ class TrackerUpdateTaskInvocation extends BaseToolInvocation<
const task = await this.service.updateTask(id, updates);
return {
llmContent: `Updated task ${task.id}. Status: ${task.status}`,
returnDisplay: `Updated task ${task.id}.`,
returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -239,7 +297,7 @@ class TrackerGetTaskInvocation extends BaseToolInvocation<
}
return {
llmContent: JSON.stringify(task, null, 2),
returnDisplay: `Retrieved task ${task.id}.`,
returnDisplay: await buildTodosReturnDisplay(this.service),
};
}
}
@@ -327,7 +385,7 @@ class TrackerListTasksInvocation extends BaseToolInvocation<
.join('\n');
return {
llmContent: content,
returnDisplay: `Listed ${tasks.length} tasks.`,
returnDisplay: await buildTodosReturnDisplay(this.service),
};
}
}
@@ -427,7 +485,7 @@ class TrackerAddDependencyInvocation extends BaseToolInvocation<
await this.service.updateTask(task.id, { dependencies: newDeps });
return {
llmContent: `Linked ${task.id} -> ${dep.id}.`,
returnDisplay: 'Dependency added.',
returnDisplay: await buildTodosReturnDisplay(this.service),
};
} catch (error) {
const errorMessage =
@@ -516,12 +574,6 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
closed: '✅',
};
const typeLabels: Record<TaskType, string> = {
epic: '[EPIC]',
task: '[TASK]',
bug: '[BUG]',
};
const childrenMap = new Map<string, TrackerTask[]>();
const roots: TrackerTask[] = [];
@@ -550,14 +602,15 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
visited.add(task.id);
const indent = ' '.repeat(depth);
output += `${indent}${statusEmojis[task.status]} ${task.id} ${typeLabels[task.type]} ${task.title}\n`;
output += `${indent}${statusEmojis[task.status]} ${task.id} ${TASK_TYPE_LABELS[task.type]} ${task.title}\n`;
if (task.dependencies.length > 0) {
output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`;
}
const children = childrenMap.get(task.id) ?? [];
for (const child of children) {
renderTask(child, depth + 1, new Set(visited));
renderTask(child, depth + 1, visited);
}
visited.delete(task.id);
};
for (const root of roots) {
@@ -566,7 +619,7 @@ class TrackerVisualizeInvocation extends BaseToolInvocation<
return {
llmContent: output,
returnDisplay: output,
returnDisplay: await buildTodosReturnDisplay(this.service),
};
}
}
@@ -0,0 +1,206 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { getFsErrorMessage } from './fsErrorMessages.js';
/**
* Helper to create a mock NodeJS.ErrnoException
*/
function createNodeError(
code: string,
message: string,
path?: string,
): NodeJS.ErrnoException {
const error = new Error(message) as NodeJS.ErrnoException;
error.code = code;
if (path) {
error.path = path;
}
return error;
}
interface FsErrorCase {
code: string;
message: string;
path?: string;
expected: string;
}
interface FallbackErrorCase {
value: unknown;
expected: string;
}
describe('getFsErrorMessage', () => {
describe('known filesystem error codes', () => {
const testCases: FsErrorCase[] = [
{
code: 'EACCES',
message: 'EACCES: permission denied',
path: '/etc/gemini-cli/settings.json',
expected:
"Permission denied: cannot access '/etc/gemini-cli/settings.json'. Check file permissions or run with elevated privileges.",
},
{
code: 'EACCES',
message: 'EACCES: permission denied',
expected:
'Permission denied. Check file permissions or run with elevated privileges.',
},
{
code: 'ENOENT',
message: 'ENOENT: no such file or directory',
path: '/nonexistent/file.txt',
expected:
"File or directory not found: '/nonexistent/file.txt'. Check if the path exists and is spelled correctly.",
},
{
code: 'ENOENT',
message: 'ENOENT: no such file or directory',
expected:
'File or directory not found. Check if the path exists and is spelled correctly.',
},
{
code: 'ENOSPC',
message: 'ENOSPC: no space left on device',
expected:
'No space left on device. Free up some disk space and try again.',
},
{
code: 'EISDIR',
message: 'EISDIR: illegal operation on a directory',
path: '/some/directory',
expected:
"Path is a directory, not a file: '/some/directory'. Please provide a path to a file instead.",
},
{
code: 'EISDIR',
message: 'EISDIR: illegal operation on a directory',
expected:
'Path is a directory, not a file. Please provide a path to a file instead.',
},
{
code: 'EROFS',
message: 'EROFS: read-only file system',
expected:
'Read-only file system. Ensure the file system allows write operations.',
},
{
code: 'EPERM',
message: 'EPERM: operation not permitted',
path: '/protected/file',
expected:
"Operation not permitted: '/protected/file'. Ensure you have the required permissions for this action.",
},
{
code: 'EPERM',
message: 'EPERM: operation not permitted',
expected:
'Operation not permitted. Ensure you have the required permissions for this action.',
},
{
code: 'EEXIST',
message: 'EEXIST: file already exists',
path: '/existing/file',
expected:
"File or directory already exists: '/existing/file'. Try using a different name or path.",
},
{
code: 'EEXIST',
message: 'EEXIST: file already exists',
expected:
'File or directory already exists. Try using a different name or path.',
},
{
code: 'EBUSY',
message: 'EBUSY: resource busy or locked',
path: '/locked/file',
expected:
"Resource busy or locked: '/locked/file'. Close any programs that might be using the file.",
},
{
code: 'EBUSY',
message: 'EBUSY: resource busy or locked',
expected:
'Resource busy or locked. Close any programs that might be using the file.',
},
{
code: 'EMFILE',
message: 'EMFILE: too many open files',
expected:
'Too many open files. Close some unused files or applications.',
},
{
code: 'ENFILE',
message: 'ENFILE: file table overflow',
expected:
'Too many open files in system. Close some unused files or applications.',
},
];
it.each(testCases)(
'returns friendly message for $code (path: $path)',
({ code, message, path, expected }) => {
const error = createNodeError(code, message, path);
expect(getFsErrorMessage(error)).toBe(expected);
},
);
});
describe('unknown node error codes', () => {
const testCases: FsErrorCase[] = [
{
code: 'EUNKNOWN',
message: 'Some unknown error occurred',
expected: 'Some unknown error occurred (EUNKNOWN)',
},
{
code: 'toString',
message: 'Unexpected error',
path: '/some/path',
expected: 'Unexpected error (toString)',
},
];
it.each(testCases)(
'includes code in fallback message for $code',
({ code, message, path, expected }) => {
const error = createNodeError(code, message, path);
expect(getFsErrorMessage(error)).toBe(expected);
},
);
});
describe('non-node and nullish errors', () => {
const fallbackCases: FallbackErrorCase[] = [
{
value: new Error('Something went wrong'),
expected: 'Something went wrong',
},
{ value: 'string error', expected: 'string error' },
{ value: 12345, expected: '12345' },
{ value: null, expected: 'An unknown error occurred' },
{ value: undefined, expected: 'An unknown error occurred' },
];
it.each(fallbackCases)(
'returns a message for $value',
({ value, expected }) => {
expect(getFsErrorMessage(value)).toBe(expected);
},
);
it.each([null, undefined] as const)(
'uses custom default for %s',
(value) => {
expect(getFsErrorMessage(value, 'Custom default')).toBe(
'Custom default',
);
},
);
});
});
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { isNodeError, getErrorMessage } from './errors.js';
/**
* Map of Node.js filesystem error codes to user-friendly message generators.
* Each function takes the path (if available) and returns a descriptive message.
*/
const errorMessageGenerators: Record<string, (path?: string) => string> = {
EACCES: (path) =>
(path
? `Permission denied: cannot access '${path}'. `
: 'Permission denied. ') +
'Check file permissions or run with elevated privileges.',
ENOENT: (path) =>
(path
? `File or directory not found: '${path}'. `
: 'File or directory not found. ') +
'Check if the path exists and is spelled correctly.',
ENOSPC: () =>
'No space left on device. Free up some disk space and try again.',
EISDIR: (path) =>
(path
? `Path is a directory, not a file: '${path}'. `
: 'Path is a directory, not a file. ') +
'Please provide a path to a file instead.',
EROFS: () =>
'Read-only file system. Ensure the file system allows write operations.',
EPERM: (path) =>
(path
? `Operation not permitted: '${path}'. `
: 'Operation not permitted. ') +
'Ensure you have the required permissions for this action.',
EEXIST: (path) =>
(path
? `File or directory already exists: '${path}'. `
: 'File or directory already exists. ') +
'Try using a different name or path.',
EBUSY: (path) =>
(path
? `Resource busy or locked: '${path}'. `
: 'Resource busy or locked. ') +
'Close any programs that might be using the file.',
EMFILE: () => 'Too many open files. Close some unused files or applications.',
ENFILE: () =>
'Too many open files in system. Close some unused files or applications.',
};
/**
* Converts a Node.js filesystem error to a user-friendly message.
*
* @param error - The error to convert
* @param defaultMessage - Optional default message if error cannot be interpreted
* @returns A user-friendly error message
*/
export function getFsErrorMessage(
error: unknown,
defaultMessage = 'An unknown error occurred',
): string {
if (error == null) {
return defaultMessage;
}
if (isNodeError(error)) {
const code = error.code;
const path = error.path;
if (code && Object.hasOwn(errorMessageGenerators, code)) {
return errorMessageGenerators[code](path);
}
// For unknown error codes, include the code in the message
if (code) {
const baseMessage = error.message || defaultMessage;
return `${baseMessage} (${code})`;
}
}
// For non-Node errors, return the error message or string representation
return getErrorMessage(error);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+22
View File
@@ -353,6 +353,7 @@ export class TestRig {
testName: string,
options: {
settings?: Record<string, unknown>;
state?: Record<string, unknown>;
fakeResponsesPath?: string;
} = {},
) {
@@ -382,6 +383,9 @@ export class TestRig {
// Create a settings file to point the CLI to the local collector
this._createSettingsFile(options.settings);
// Create persistent state file
this._createStateFile(options.state);
}
private _cleanDir(dir: string) {
@@ -473,6 +477,24 @@ export class TestRig {
);
}
private _createStateFile(overrideState?: Record<string, unknown>) {
if (!this.homeDir) throw new Error('TestRig homeDir is not initialized');
const userGeminiDir = join(this.homeDir, GEMINI_DIR);
mkdirSync(userGeminiDir, { recursive: true });
const state = deepMerge(
{
terminalSetupPromptShown: true, // Default to true in tests to avoid blocking prompts
},
overrideState ?? {},
);
writeFileSync(
join(userGeminiDir, 'state.json'),
JSON.stringify(state, null, 2),
);
}
createFile(fileName: string, content: string) {
const filePath = join(this.testDir!, fileName);
writeFileSync(filePath, content);
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+10
View File
@@ -1188,6 +1188,16 @@
"markdownDescription": "Model override for the visual agent.\n\n- Category: `Advanced`\n- Requires restart: `yes`",
"type": "string"
},
"allowedDomains": {
"title": "Allowed Domains",
"description": "A list of allowed domains for the browser agent (e.g., [\"github.com\", \"*.google.com\"]).",
"markdownDescription": "A list of allowed domains for the browser agent (e.g., [\"github.com\", \"*.google.com\"]).\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `[\n \"github.com\",\n \"*.google.com\",\n \"localhost\"\n]`",
"default": ["github.com", "*.google.com", "localhost"],
"type": "array",
"items": {
"type": "string"
}
},
"disableUserInput": {
"title": "Disable User Input",
"description": "Disable user input on browser window during automation.",