Compare commits

...

18 Commits

Author SHA1 Message Date
Abhi 78750e8251 refactor(ui): extract SessionBrowser list components 2026-03-13 22:41:07 -04:00
Abhi 4fcc3b7647 refactor(ui): extract SessionBrowser search and navigation components 2026-03-13 18:09:13 -04:00
Christian Gunderman fe8d93c75a Promote stable tests. (#22253) 2026-03-13 21:32:00 +00:00
Tommaso Sciortino 24933a90d0 fix(cli): support CJK input and full Unicode scalar values in terminal protocols (#22353) 2026-03-13 21:24:26 +00:00
Gal Zahavi fa024133e6 feat(core): integrate SandboxManager to sandbox all process-spawning tools (#22231) 2026-03-13 21:11:51 +00: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
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
129 changed files with 3672 additions and 1371 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
+1
View File
@@ -125,6 +125,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` |
+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.
+20 -3
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`
@@ -773,9 +784,10 @@ their corresponding top-level category object in your `settings.json` file.
#### `tools`
- **`tools.sandbox`** (string):
- **Description:** Sandbox execution environment. Set to a boolean to enable
or disable the sandbox, provide a string path to a sandbox profile, or
specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
- **Description:** Legacy full-process sandbox execution environment. Set to a
boolean to enable or disable the sandbox, provide a string path to a sandbox
profile, or specify an explicit sandbox command (e.g., "docker", "podman",
"lxc").
- **Default:** `undefined`
- **Requires restart:** Yes
@@ -879,6 +891,11 @@ their corresponding top-level category object in your `settings.json` file.
#### `security`
- **`security.toolSandboxing`** (boolean):
- **Description:** Experimental tool-level sandboxing (implementation in
progress).
- **Default:** `false`
- **`security.disableYoloMode`** (boolean):
- **Description:** Disable YOLO mode, even if enabled by a flag.
- **Default:** `false`
+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);
});
});
+34 -10
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/*"
],
@@ -2195,6 +2195,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2375,6 +2376,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2424,6 +2426,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2798,6 +2801,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2831,6 +2835,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2885,6 +2890,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4087,6 +4093,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4361,6 +4368,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5234,6 +5242,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7765,6 +7774,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8275,6 +8285,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9559,6 +9570,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9838,6 +9850,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13440,6 +13453,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13450,6 +13464,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15497,6 +15512,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15720,7 +15736,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15728,6 +15745,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -15887,6 +15905,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16109,6 +16128,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16222,6 +16242,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16234,6 +16255,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16875,6 +16897,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -16890,7 +16913,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 +17028,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 +17200,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",
@@ -17417,6 +17440,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17439,7 +17463,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 +17478,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 +17495,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 +17512,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",
@@ -104,6 +104,7 @@ export class AddMemoryCommand implements Command {
const signal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.config.sandboxManager,
});
await refreshMemory(context.config);
return {
@@ -21,6 +21,7 @@ import {
tmpdir,
type Config,
type Storage,
NoopSandboxManager,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
@@ -97,6 +98,14 @@ export function createMockConfig(
}),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
...overrides,
} as unknown as Config;
+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 {
+1
View File
@@ -105,6 +105,7 @@ export class AddMemoryCommand implements Command {
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.config.sandboxManager,
});
await refreshMemory(context.config);
return {
@@ -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
View File
@@ -744,6 +744,7 @@ export async function loadCliConfig(
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
toolSandboxing: settings.security?.toolSandboxing ?? false,
targetDir: cwd,
includeDirectoryTree,
includeDirectories,
@@ -20,7 +20,12 @@ import {
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
type Config,
tmpdir,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => {
terminalHeight: 24,
showColor: false,
pager: 'cat',
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
+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';
+3 -1
View File
@@ -34,7 +34,9 @@ const VALID_SANDBOX_COMMANDS = [
function isSandboxCommand(
value: string,
): value is Exclude<SandboxConfig['command'], undefined> {
return VALID_SANDBOX_COMMANDS.includes(value);
return (VALID_SANDBOX_COMMANDS as ReadonlyArray<string | undefined>).includes(
value,
);
}
function getSandboxCommand(
+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,
);
}
+24 -1
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',
@@ -1287,7 +1300,7 @@ const SETTINGS_SCHEMA = {
default: undefined as boolean | string | SandboxConfig | undefined,
ref: 'BooleanOrStringOrObject',
description: oneLine`
Sandbox execution environment.
Legacy full-process sandbox execution environment.
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
`,
@@ -1509,6 +1522,16 @@ const SETTINGS_SCHEMA = {
description: 'Security-related settings.',
showInDialog: false,
properties: {
toolSandboxing: {
type: 'boolean',
label: 'Tool Sandboxing',
category: 'Security',
requiresRestart: false,
default: false,
description:
'Experimental tool-level sandboxing (implementation in progress).',
showInDialog: true,
},
disableYoloMode: {
type: 'boolean',
label: 'Disable YOLO Mode',
@@ -13,6 +13,7 @@ import {
ApprovalMode,
getShellConfiguration,
PolicyDecision,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai';
@@ -77,7 +78,14 @@ describe('ShellProcessor', () => {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
+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 || '')
+9 -1
View File
@@ -5,6 +5,7 @@
*/
import { vi } from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import {
createTestMergedSettings,
@@ -131,7 +132,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
setShellExecutionConfig: vi.fn(),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
+1
View File
@@ -1425,6 +1425,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
sandboxManager: config.sandboxManager,
});
const { isFocused, hasReceivedFocusEvent } = useFocus();
@@ -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,
@@ -6,9 +6,7 @@
import type React from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { Box } from 'ink';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
@@ -107,346 +105,16 @@ export interface SessionBrowserState {
const SESSIONS_PER_PAGE = 20;
// Approximate total width reserved for non-message columns and separators
// (prefix, index, message count, age, pipes, and padding) in a session row.
// If the SessionItem layout changes, update this accordingly.
const FIXED_SESSION_COLUMNS_WIDTH = 30;
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* 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 { SearchModeDisplay } from './SessionBrowser/SearchModeDisplay.js';
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
import { NoResultsDisplay } from './SessionBrowser/NoResultsDisplay.js';
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { SessionList } from './SessionBrowser/SessionList.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
*/
const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Table header component with column labels and scroll indicators.
*/
const SessionTableHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" marginTop={1}>
<Text>{state.scrollOffset > 0 ? <Text> </Text> : ' '}</Text>
<Box width={5} flexShrink={0}>
<Text color={Colors.Gray} bold>
Index
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Msgs
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Age
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box flexShrink={0}>
<Text color={Colors.Gray} bold>
{state.searchQuery ? 'Match' : 'Name'}
</Text>
</Box>
</Box>
);
/**
* No results display component for empty search results.
*/
const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
/**
* Match snippet display component for search results.
*/
const MatchSnippetDisplay = ({
session,
textColor,
}: {
session: SessionInfo;
textColor: (color?: string) => string;
}): React.JSX.Element | null => {
if (!session.matchSnippets || session.matchSnippets.length === 0) {
return null;
}
const firstMatch = session.matchSnippets[0];
const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:';
const roleColor = textColor(
firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue,
);
return (
<>
<Text color={roleColor} bold>
{rolePrefix}{' '}
</Text>
{firstMatch.before}
<Text color={textColor(Colors.AccentRed)} bold>
{firstMatch.match}
</Text>
{firstMatch.after}
</>
);
};
/**
* Individual session row component.
*/
const SessionItem = ({
session,
state,
terminalWidth,
formatRelativeTime,
}: {
session: SessionInfo;
state: SessionBrowserState;
terminalWidth: number;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => {
const originalIndex =
state.startIndex + state.visibleSessions.indexOf(session);
const isActive = originalIndex === state.activeIndex;
const isDisabled = session.isCurrentSession;
const textColor = (c: string = Colors.Foreground) => {
if (isDisabled) {
return Colors.Gray;
}
return isActive ? theme.ui.focus : c;
};
const prefix = isActive ? ' ' : ' ';
let additionalInfo = '';
let matchDisplay = null;
// Add "(current)" label for the current session
if (session.isCurrentSession) {
additionalInfo = ' (current)';
}
// Show match snippets if searching and matches exist
if (
state.searchQuery &&
session.matchSnippets &&
session.matchSnippets.length > 0
) {
matchDisplay = (
<MatchSnippetDisplay session={session} textColor={textColor} />
);
if (session.matchCount && session.matchCount > 1) {
additionalInfo += ` (+${session.matchCount - 1} more)`;
}
}
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
const availableMessageWidth = Math.max(
20,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
);
const truncatedMessage =
matchDisplay ||
(session.displayName.length === 0 ? (
<Text color={textColor(Colors.Gray)} dimColor>
(No messages)
</Text>
) : session.displayName.length > availableMessageWidth ? (
session.displayName.slice(0, availableMessageWidth - 1) + '…'
) : (
session.displayName
));
return (
<Box
flexDirection="row"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Text color={textColor()} dimColor={isDisabled}>
{prefix}
</Text>
<Box width={5}>
<Text color={textColor()} dimColor={isDisabled}>
#{originalIndex + 1}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{session.messageCount}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{formatRelativeTime(session.lastUpdated, 'short')}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box flexGrow={1}>
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
{truncatedMessage}
{additionalInfo && (
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
{additionalInfo}
</Text>
)}
</Text>
</Box>
</Box>
);
};
/**
* Session list container component.
*/
const SessionList = ({
state,
formatRelativeTime,
}: {
state: SessionBrowserState;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => (
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelp />}
<SessionTableHeader state={state} />
</Box>
{state.visibleSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
state={state}
terminalWidth={state.terminalWidth}
formatRelativeTime={formatRelativeTime}
/>
))}
<Text color={Colors.Gray}>
{state.endIndex < state.totalSessions ? <></> : <Text dimColor></Text>}
</Text>
</Box>
);
/**
* Hook to manage all SessionBrowser state.
*/
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
/**
* Match snippet display component for search results.
*/
export const MatchSnippetDisplay = ({
session,
textColor,
}: {
session: SessionInfo;
textColor: (color?: string) => string;
}): React.JSX.Element | null => {
if (!session.matchSnippets || session.matchSnippets.length === 0) {
return null;
}
const firstMatch = session.matchSnippets[0];
const rolePrefix = firstMatch.role === 'user' ? 'You: ' : 'Gemini:';
const roleColor = textColor(
firstMatch.role === 'user' ? Colors.AccentGreen : Colors.AccentBlue,
);
return (
<Text>
<Text color={roleColor} bold>
{rolePrefix}{' '}
</Text>
{firstMatch.before}
<Text color={textColor(Colors.AccentRed)} bold>
{firstMatch.match}
</Text>
{firstMatch.after}
</Text>
);
};
@@ -0,0 +1,41 @@
/**
* @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';
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
export const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
@@ -0,0 +1,25 @@
/**
* @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';
/**
* No results display component for empty search results.
*/
export const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
@@ -0,0 +1,25 @@
/**
* @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';
/**
* Search input display component.
*/
export const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
@@ -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,99 @@
/**
* @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 { SessionList } from './SessionList.js';
import { SessionItem } from './SessionItem.js';
import { SessionTableHeader } from './SessionTableHeader.js';
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
describe('SessionBrowser List Components', () => {
const mockSession: SessionInfo = {
id: '1',
file: 'session-1',
fileName: 'session-1.json',
startTime: new Date().toISOString(),
displayName: 'Test Session',
firstUserMessage: 'Test Session',
messageCount: 5,
lastUpdated: new Date().toISOString(),
isCurrentSession: false,
index: 1,
};
const mockState = {
totalSessions: 1,
startIndex: 0,
endIndex: 1,
visibleSessions: [mockSession],
activeIndex: 0,
scrollOffset: 0,
terminalWidth: 80,
searchQuery: '',
isSearchMode: false,
} as SessionBrowserState;
it('SessionTableHeader renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionTableHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('MatchSnippetDisplay returns null when no snippets', () => {
const { lastFrame } = render(
<MatchSnippetDisplay session={mockSession} textColor={(c) => c || ''} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('MatchSnippetDisplay renders correctly with snippets', async () => {
const sessionWithSnippets = {
...mockSession,
matchSnippets: [
{
role: 'user' as const,
before: 'hello ',
match: 'world',
after: ' !',
},
],
};
const { lastFrame, waitUntilReady } = render(
<MatchSnippetDisplay
session={sessionWithSnippets}
textColor={(c) => c || ''}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionItem renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionItem
session={mockSession}
state={mockState}
terminalWidth={80}
formatRelativeTime={() => '10m ago'}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionList renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(
<SessionList state={mockState} formatRelativeTime={() => '10m ago'} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -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,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// import type React from 'react';
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SearchModeDisplay } from './SearchModeDisplay.js';
import { NavigationHelp } from './NavigationHelp.js';
import { SessionListHeader } from './SessionListHeader.js';
import { NoResultsDisplay } from './NoResultsDisplay.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser Search and Navigation Components', () => {
it('SearchModeDisplay renders correctly with query', async () => {
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SearchModeDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NavigationHelp renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<NavigationHelp />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly', async () => {
const mockState = {
totalSessions: 10,
searchQuery: '',
sortOrder: 'date',
sortReverse: false,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly with filter', async () => {
const mockState = {
totalSessions: 5,
searchQuery: 'test',
sortOrder: 'name',
sortReverse: true,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NoResultsDisplay renders correctly', async () => {
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<NoResultsDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -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,132 @@
/**
* @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 { theme } from '../../semantic-colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
import type { SessionInfo } from '../../../utils/sessionUtils.js';
import { MatchSnippetDisplay } from './MatchSnippetDisplay.js';
const FIXED_SESSION_COLUMNS_WIDTH = 30;
/**
* Individual session row component.
*/
export const SessionItem = ({
session,
state,
terminalWidth,
formatRelativeTime,
}: {
session: SessionInfo;
state: SessionBrowserState;
terminalWidth: number;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => {
const originalIndex =
state.startIndex + state.visibleSessions.indexOf(session);
const isActive = originalIndex === state.activeIndex;
const isDisabled = session.isCurrentSession;
const textColor = (c: string = Colors.Foreground) => {
if (isDisabled) {
return Colors.Gray;
}
return isActive ? theme.ui.focus : c;
};
const prefix = isActive ? ' ' : ' ';
let additionalInfo = '';
let matchDisplay = null;
// Add "(current)" label for the current session
if (session.isCurrentSession) {
additionalInfo = ' (current)';
}
// Show match snippets if searching and matches exist
if (
state.searchQuery &&
session.matchSnippets &&
session.matchSnippets.length > 0
) {
matchDisplay = (
<MatchSnippetDisplay session={session} textColor={textColor} />
);
if (session.matchCount && session.matchCount > 1) {
additionalInfo += ` (+${session.matchCount - 1} more)`;
}
}
// Reserve a few characters for metadata like " (current)" so the name doesn't wrap awkwardly.
const reservedForMeta = additionalInfo ? additionalInfo.length + 1 : 0;
const availableMessageWidth = Math.max(
20,
terminalWidth - FIXED_SESSION_COLUMNS_WIDTH - reservedForMeta,
);
const truncatedMessage =
matchDisplay ||
(session.displayName.length === 0 ? (
<Text color={textColor(Colors.Gray)} dimColor>
(No messages)
</Text>
) : session.displayName.length > availableMessageWidth ? (
session.displayName.slice(0, availableMessageWidth - 1) + '…'
) : (
session.displayName
));
return (
<Box
flexDirection="row"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Text color={textColor()} dimColor={isDisabled}>
{prefix}
</Text>
<Box width={5}>
<Text color={textColor()} dimColor={isDisabled}>
#{originalIndex + 1}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{session.messageCount}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box width={4}>
<Text color={textColor()} dimColor={isDisabled}>
{formatRelativeTime(session.lastUpdated, 'short')}
</Text>
</Box>
<Text color={textColor(Colors.Gray)} dimColor={isDisabled}>
{' '}
{' '}
</Text>
<Box flexGrow={1}>
<Text color={textColor(Colors.Comment)} dimColor={isDisabled}>
{truncatedMessage}
{additionalInfo && (
<Text color={textColor(Colors.Gray)} dimColor bold={false}>
{additionalInfo}
</Text>
)}
</Text>
</Box>
</Box>
);
};
@@ -0,0 +1,46 @@
/**
* @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';
import { SessionItem } from './SessionItem.js';
import { SessionTableHeader } from './SessionTableHeader.js';
import { NavigationHelp } from './NavigationHelp.js';
/**
* Session list container component.
*/
export const SessionList = ({
state,
formatRelativeTime,
}: {
state: SessionBrowserState;
formatRelativeTime: (dateString: string, style: 'short' | 'long') => string;
}): React.JSX.Element => (
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelp />}
<SessionTableHeader state={state} />
</Box>
{state.visibleSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
state={state}
terminalWidth={state.terminalWidth}
formatRelativeTime={formatRelativeTime}
/>
))}
<Text color={Colors.Gray}>
{state.endIndex < state.totalSessions ? <></> : <Text dimColor></Text>}
</Text>
</Box>
);
@@ -0,0 +1,29 @@
/**
* @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';
/**
* Header component showing session count and sort information.
*/
export const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
@@ -0,0 +1,47 @@
/**
* @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';
/**
* Table header component with column labels and scroll indicators.
*/
export const SessionTableHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" marginTop={1}>
<Text>{state.scrollOffset > 0 ? <Text> </Text> : ' '}</Text>
<Box width={5} flexShrink={0}>
<Text color={Colors.Gray} bold>
Index
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Msgs
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box width={4} flexShrink={0}>
<Text color={Colors.Gray} bold>
Age
</Text>
</Box>
<Text color={Colors.Gray}> </Text>
<Box flexShrink={0}>
<Text color={Colors.Gray} bold>
{state.searchQuery ? 'Match' : 'Name'}
</Text>
</Box>
</Box>
);
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser List Components > MatchSnippetDisplay renders correctly with snippets 1`] = `
"You: hello world !
"
`;
exports[`SessionBrowser List Components > SessionItem renders correctly 1`] = `
" #1 │ 5 │ 10m │ Test Session
ago
"
`;
exports[`SessionBrowser List Components > SessionList renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
Index │ Msgs │ Age │ Name
#1 │ 5 │ 10m │ Test Session
ago
"
`;
exports[`SessionBrowser List Components > SessionTableHeader renders correctly 1`] = `
"
Index │ Msgs │ Age │ Name
"
`;
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
"
`;
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
"
No sessions found matching 'no match'.
"
`;
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
"
Search: test query (Esc to cancel)
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
"Chat Sessions (10 total) sorted by date desc
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
"Chat Sessions (5 total, filtered) sorted by name asc
"
`;
@@ -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;
@@ -647,6 +647,15 @@ describe('KeypressContext', () => {
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', shift: true, ctrl: true },
},
// Unicode CJK (Kitty/modifyOtherKeys scalar values)
{
sequence: '\x1b[44032u',
expected: { name: '가', sequence: '가', insertable: true },
},
{
sequence: '\x1b[27;1;44032~',
expected: { name: '가', sequence: '가', insertable: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
@@ -1403,7 +1412,7 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
for (const char of inputString) {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ sequence: char }),
expect.objectContaining({ sequence: char, name: char.toLowerCase() }),
);
}
});
@@ -610,20 +610,28 @@ function* emitKeys(
if (code.endsWith('u') || code.endsWith('~')) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (codeNumber >= 33 && codeNumber <= 126) {
const char = String.fromCharCode(codeNumber);
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
} else if (
codeNumber >= 33 && // Printable characters start after space (32),
codeNumber <= 0x10ffff && // Valid Unicode scalar values (excluding control characters)
(codeNumber < 0xd800 || codeNumber > 0xdfff) // Exclude UTF-16 surrogate halves
) {
// Valid printable Unicode scalar values (up to Unicode maximum)
// Note: Kitty maps its special keys to the PUA (57344+), which are handled by KITTY_CODE_MAP above.
const char = String.fromCodePoint(codeNumber);
name = char.toLowerCase();
if (char >= 'A' && char <= 'Z') {
if (char !== name) {
shift = true;
}
} else {
const mapped = KITTY_CODE_MAP[codeNumber];
if (mapped) {
name = mapped.name;
if (mapped.sequence && !ctrl && !cmd && !alt) {
sequence = mapped.sequence;
insertable = true;
}
if (!ctrl && !cmd && !alt) {
sequence = char;
insertable = true;
}
}
}
@@ -696,6 +704,10 @@ function* emitKeys(
alt = ch.length > 0;
} else {
// Any other character is considered printable.
name = ch.toLowerCase();
if (ch !== name) {
shift = true;
}
insertable = true;
}
@@ -16,6 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -109,8 +110,14 @@ describe('useShellCommandProcessor', () => {
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
} as Config;
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
vi.mocked(os.platform).mockReturnValue('linux');
@@ -7,9 +7,9 @@
import {
debugLogger,
checkExhaustive,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
ExtensionUpdateState,
extensionUpdatesReducer,
+11 -11
View File
@@ -22,7 +22,7 @@ describe('KeyBinding', () => {
describe('constructor', () => {
it('should parse a simple key', () => {
const binding = new KeyBinding('a');
expect(binding.key).toBe('a');
expect(binding.name).toBe('a');
expect(binding.ctrl).toBe(false);
expect(binding.shift).toBe(false);
expect(binding.alt).toBe(false);
@@ -31,45 +31,45 @@ describe('KeyBinding', () => {
it('should parse ctrl+key', () => {
const binding = new KeyBinding('ctrl+c');
expect(binding.key).toBe('c');
expect(binding.name).toBe('c');
expect(binding.ctrl).toBe(true);
});
it('should parse shift+key', () => {
const binding = new KeyBinding('shift+z');
expect(binding.key).toBe('z');
expect(binding.name).toBe('z');
expect(binding.shift).toBe(true);
});
it('should parse alt+key', () => {
const binding = new KeyBinding('alt+left');
expect(binding.key).toBe('left');
expect(binding.name).toBe('left');
expect(binding.alt).toBe(true);
});
it('should parse cmd+key', () => {
const binding = new KeyBinding('cmd+f');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.cmd).toBe(true);
});
it('should handle aliases (option/opt/meta)', () => {
const optionBinding = new KeyBinding('option+b');
expect(optionBinding.key).toBe('b');
expect(optionBinding.name).toBe('b');
expect(optionBinding.alt).toBe(true);
const optBinding = new KeyBinding('opt+b');
expect(optBinding.key).toBe('b');
expect(optBinding.name).toBe('b');
expect(optBinding.alt).toBe(true);
const metaBinding = new KeyBinding('meta+enter');
expect(metaBinding.key).toBe('enter');
expect(metaBinding.name).toBe('enter');
expect(metaBinding.cmd).toBe(true);
});
it('should parse multiple modifiers', () => {
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
expect(binding.key).toBe('x');
expect(binding.name).toBe('x');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
expect(binding.alt).toBe(true);
@@ -78,14 +78,14 @@ describe('KeyBinding', () => {
it('should be case-insensitive', () => {
const binding = new KeyBinding('CTRL+Shift+F');
expect(binding.key).toBe('f');
expect(binding.name).toBe('f');
expect(binding.ctrl).toBe(true);
expect(binding.shift).toBe(true);
});
it('should handle named keys with modifiers', () => {
const binding = new KeyBinding('ctrl+enter');
expect(binding.key).toBe('enter');
expect(binding.name).toBe('enter');
expect(binding.ctrl).toBe(true);
});
+17 -14
View File
@@ -144,14 +144,14 @@ export class KeyBinding {
]);
/** The key name (e.g., 'a', 'enter', 'tab', 'escape') */
readonly key: string;
readonly name: string;
readonly shift: boolean;
readonly alt: boolean;
readonly ctrl: boolean;
readonly cmd: boolean;
constructor(pattern: string) {
let remains = pattern.toLowerCase().trim();
let remains = pattern.trim();
let shift = false;
let alt = false;
let ctrl = false;
@@ -160,31 +160,32 @@ export class KeyBinding {
let matched: boolean;
do {
matched = false;
if (remains.startsWith('ctrl+')) {
const lowerRemains = remains.toLowerCase();
if (lowerRemains.startsWith('ctrl+')) {
ctrl = true;
remains = remains.slice(5);
matched = true;
} else if (remains.startsWith('shift+')) {
} else if (lowerRemains.startsWith('shift+')) {
shift = true;
remains = remains.slice(6);
matched = true;
} else if (remains.startsWith('alt+')) {
} else if (lowerRemains.startsWith('alt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('option+')) {
} else if (lowerRemains.startsWith('option+')) {
alt = true;
remains = remains.slice(7);
matched = true;
} else if (remains.startsWith('opt+')) {
} else if (lowerRemains.startsWith('opt+')) {
alt = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('cmd+')) {
} else if (lowerRemains.startsWith('cmd+')) {
cmd = true;
remains = remains.slice(4);
matched = true;
} else if (remains.startsWith('meta+')) {
} else if (lowerRemains.startsWith('meta+')) {
cmd = true;
remains = remains.slice(5);
matched = true;
@@ -193,15 +194,17 @@ export class KeyBinding {
const key = remains;
if ([...key].length !== 1 && !KeyBinding.VALID_LONG_KEYS.has(key)) {
const isSingleChar = [...key].length === 1;
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
throw new Error(
`Invalid keybinding key: "${key}" in "${pattern}".` +
` Must be a single character or one of: ${[...KeyBinding.VALID_LONG_KEYS].join(', ')}`,
);
}
this.key = key;
this.shift = shift;
this.name = key.toLowerCase();
this.shift = shift || (isSingleChar && this.name !== key);
this.alt = alt;
this.ctrl = ctrl;
this.cmd = cmd;
@@ -209,7 +212,7 @@ export class KeyBinding {
matches(key: Key): boolean {
return (
this.key === key.name &&
key.name === this.name &&
!!key.shift === !!this.shift &&
!!key.alt === !!this.alt &&
!!key.ctrl === !!this.ctrl &&
@@ -219,7 +222,7 @@ export class KeyBinding {
equals(other: KeyBinding): boolean {
return (
this.key === other.key &&
this.name === other.name &&
this.shift === other.shift &&
this.alt === other.alt &&
this.ctrl === other.ctrl &&
@@ -475,6 +475,22 @@ describe('keyMatchers', () => {
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
it('should support matching non-ASCII and CJK characters', () => {
const config = new Map(defaultKeyBindingConfig);
config.set(Command.QUIT, [new KeyBinding('Å'), new KeyBinding('가')]);
const matchers = createKeyMatchers(config);
// Å is normalized to å with shift=true by the parser
expect(matchers[Command.QUIT](createKey('å', { shift: true }))).toBe(
true,
);
expect(matchers[Command.QUIT](createKey('å'))).toBe(false);
// CJK characters do not have a lower/upper case
expect(matchers[Command.QUIT](createKey('가'))).toBe(true);
expect(matchers[Command.QUIT](createKey('나'))).toBe(false);
});
});
describe('Edge Cases', () => {
+1 -1
View File
@@ -86,7 +86,7 @@ export function formatKeyBinding(
if (binding.shift) parts.push(modMap.shift);
if (binding.cmd) parts.push(modMap.cmd);
const keyName = KEY_NAME_MAP[binding.key] || binding.key.toUpperCase();
const keyName = KEY_NAME_MAP[binding.name] || binding.name.toUpperCase();
parts.push(keyName);
return parts.join('+');
-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,
@@ -7,6 +7,7 @@
import type { GeminiClient } from '../core/client.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { SandboxManager } from '../services/sandboxManager.js';
import type { Config } from './config.js';
/**
@@ -28,4 +29,7 @@ export interface AgentLoopContext {
/** The client used to communicate with the LLM in this context. */
readonly geminiClient: GeminiClient;
/** The service used to prepare commands for sandboxed execution. */
readonly sandboxManager: SandboxManager;
}
+31 -1
View File
@@ -41,6 +41,10 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
import {
createSandboxManager,
type SandboxManager,
} from '../services/sandboxManager.js';
import {
initializeTelemetry,
DEFAULT_TELEMETRY_TARGET,
@@ -316,6 +320,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;
}
@@ -508,6 +514,7 @@ export interface ConfigParameters {
clientVersion?: string;
embeddingModel?: string;
sandbox?: SandboxConfig;
toolSandboxing?: boolean;
targetDir: string;
debugMode: boolean;
question?: string;
@@ -684,6 +691,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private _geminiClient!: GeminiClient;
private readonly _sandboxManager: SandboxManager;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -853,7 +861,19 @@ export class Config implements McpContext, AgentLoopContext {
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled ?? false,
allowedPaths: params.sandbox.allowedPaths ?? [],
networkAccess: params.sandbox.networkAccess ?? false,
command: params.sandbox.command,
image: params.sandbox.image,
}
: {
enabled: false,
allowedPaths: [],
networkAccess: false,
};
this.targetDir = path.resolve(params.targetDir);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
@@ -983,6 +1003,7 @@ export class Config implements McpContext, AgentLoopContext {
showColor: params.shellExecutionConfig?.showColor ?? false,
pager: params.shellExecutionConfig?.pager ?? 'cat',
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this.sandboxManager,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
@@ -1100,6 +1121,8 @@ export class Config implements McpContext, AgentLoopContext {
}
}
this._geminiClient = new GeminiClient(this);
this._sandboxManager = createSandboxManager(params.toolSandboxing ?? false);
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
this.modelRouterService = new ModelRouterService(this);
// HACK: The settings loading logic doesn't currently merge the default
@@ -1421,6 +1444,10 @@ export class Config implements McpContext, AgentLoopContext {
return this._geminiClient;
}
get sandboxManager(): SandboxManager {
return this._sandboxManager;
}
getSessionId(): string {
return this.promptId;
}
@@ -2808,6 +2835,8 @@ export class Config implements McpContext, AgentLoopContext {
sanitizationConfig:
config.sanitizationConfig ??
this.shellExecutionConfig.sanitizationConfig,
sandboxManager:
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
};
}
getScreenReader(): boolean {
@@ -2902,6 +2931,7 @@ export class Config implements McpContext, AgentLoopContext {
headless: customConfig.headless ?? false,
profilePath: customConfig.profilePath,
visualModel: customConfig.visualModel,
allowedDomains: customConfig.allowedDomains,
disableUserInput: customConfig.disableUserInput,
},
};
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { Config } from './config.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
// Minimal mocks for Config dependencies to allow instantiation
vi.mock('../core/client.js');
vi.mock('../core/contentGenerator.js');
vi.mock('../telemetry/index.js');
vi.mock('../core/tokenLimits.js');
vi.mock('../services/fileDiscoveryService.js');
vi.mock('../services/gitService.js');
vi.mock('../services/trackerService.js');
vi.mock('../confirmation-bus/message-bus.js', () => ({
MessageBus: vi.fn(),
}));
vi.mock('../policy/policy-engine.js', () => ({
PolicyEngine: vi.fn().mockImplementation(() => ({
getExcludedTools: vi.fn().mockReturnValue(new Set()),
})),
}));
vi.mock('../skills/skillManager.js', () => ({
SkillManager: vi.fn().mockImplementation(() => ({
setAdminSettings: vi.fn(),
})),
}));
vi.mock('../agents/registry.js', () => ({
AgentRegistry: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
}));
vi.mock('../agents/acknowledgedAgents.js', () => ({
AcknowledgedAgentsService: vi.fn(),
}));
vi.mock('../services/modelConfigService.js', () => ({
ModelConfigService: vi.fn(),
}));
vi.mock('./models.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./models.js')>();
return {
...actual,
isPreviewModel: vi.fn().mockReturnValue(false),
resolveModel: vi.fn().mockReturnValue('test-model'),
};
});
describe('Sandbox Integration', () => {
it('should have a NoopSandboxManager by default in Config', () => {
const config = new Config({
sessionId: 'test-session',
targetDir: '.',
model: 'test-model',
cwd: '.',
debugMode: false,
});
expect(config.sandboxManager).toBeDefined();
expect(config.sandboxManager).toBeInstanceOf(NoopSandboxManager);
});
});
@@ -34,6 +34,7 @@ import {
GeminiCliOperation,
} from '../index.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
import {
MockModifiableTool,
MockTool,
@@ -274,6 +275,7 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
storage: {
getProjectTempDir: () => '/tmp',
@@ -1211,6 +1213,7 @@ describe('CoreToolScheduler request queueing', () => {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
isInteractive: () => false,
});
@@ -1320,6 +1323,7 @@ describe('CoreToolScheduler request queueing', () => {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
getToolRegistry: () => toolRegistry,
getHookSystem: () => undefined,
+2
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';
@@ -145,6 +146,7 @@ export * from './ide/types.js';
// Export Shell Execution Service
export * from './services/shellExecutionService.js';
export * from './services/sandboxManager.js';
// Export base tool definitions
export * from './tools/tools.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();

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