mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78750e8251 | |||
| 4fcc3b7647 | |||
| fe8d93c75a | |||
| 24933a90d0 | |||
| fa024133e6 | |||
| 24adacdbc2 | |||
| aa23da67af | |||
| bfbd3c40a7 | |||
| dd8d4c98b3 | |||
| d368997ca3 | |||
| bbd80c9393 | |||
| 3b601b3d90 | |||
| b4bcd1a015 | |||
| aa000d7d30 | |||
| 2a7e602356 | |||
| 8d0b2d7f1b | |||
| c156bac5f7 | |||
| 263b8cd3b3 |
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}}]}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+34
-10
@@ -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
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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,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', () => ({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 '{state.searchQuery}'.
|
||||
</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 '{state.searchQuery}'.
|
||||
</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>
|
||||
);
|
||||
+29
@@ -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
|
||||
"
|
||||
`;
|
||||
+29
@@ -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
|
||||
"
|
||||
`;
|
||||
+18
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('+');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user