mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 00:30:59 -07:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0904c3767e | |||
| f18e45d34d | |||
| a3af4a8cae | |||
| 821355c429 | |||
| 3afc8f25e1 | |||
| d79478689f | |||
| 650980af37 | |||
| bd156e6832 | |||
| b987e1780d | |||
| 0012d95848 | |||
| fedc0c5d60 | |||
| 99e523a15f | |||
| a0b6602d09 | |||
| b39cefe14e | |||
| 94f4e5cc15 | |||
| d866e7e6e7 | |||
| ed02b94570 | |||
| aba8c5f662 | |||
| d1cde575d9 | |||
| 71f46f1160 | |||
| d63c34b6e1 | |||
| 7a6dfa3704 | |||
| 69f8273481 | |||
| 1fc59484b1 | |||
| 53027af94c | |||
| 69c0585ab2 | |||
| 3e954930f1 | |||
| 2cf3a14439 | |||
| 0f918f0cc8 | |||
| 75dbf9022c |
@@ -2,8 +2,7 @@
|
||||
name: docs-writer
|
||||
description:
|
||||
Always use this skill when the task involves writing, reviewing, or editing
|
||||
documentation, specifically for any files in the `/docs` directory or any
|
||||
`.md` files in the repository.
|
||||
files in the `/docs` directory or any `.md` files in the repository.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
@@ -28,59 +28,22 @@ jobs:
|
||||
permission: 'write'
|
||||
issue-type: 'pull-request'
|
||||
|
||||
- name: 'Get Commits'
|
||||
id: 'get_commits'
|
||||
- name: 'Get PR Status'
|
||||
id: 'pr_status'
|
||||
if: "startsWith(github.event.comment.body, '/patch')"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
CURRENT_PR: '${{ github.event.issue.number }}'
|
||||
run: |
|
||||
# Find all PR numbers in the comment body (e.g., #123, owner/repo#456)
|
||||
# The regex handles both formats and extracts just the number
|
||||
comment_prs=$(echo "$COMMENT_BODY" | grep -oP '(?:\S+/)?\S+#\K\d+')
|
||||
|
||||
# Combine with the current PR number, ensuring no duplicates
|
||||
all_prs=$(echo -e "${CURRENT_PR}\n${comment_prs}" | sort -u)
|
||||
|
||||
echo "Found PRs to patch: ${all_prs}"
|
||||
|
||||
commit_shas=()
|
||||
unmerged_prs=()
|
||||
merged_prs=()
|
||||
|
||||
for pr in $all_prs; do
|
||||
echo "Checking status of PR #${pr}..."
|
||||
# Get PR status (state and merge commit)
|
||||
pr_status=$(gh pr view "$pr" --json mergeCommit,state)
|
||||
state=$(echo "$pr_status" | jq -r .state)
|
||||
|
||||
if [[ "$state" != "MERGED" ]]; then
|
||||
unmerged_prs+=("$pr")
|
||||
else
|
||||
commit_sha=$(echo "$pr_status" | jq -r .mergeCommit.oid)
|
||||
commit_shas+=("$commit_sha")
|
||||
merged_prs+=("$pr")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#unmerged_prs[@]} -gt 0 ]]; then
|
||||
echo "ALL_MERGED=false" >> "$GITHUB_OUTPUT"
|
||||
echo "UNMERGED_PRS=$(IFS=,; echo "${unmerged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ALL_MERGED=true" >> "$GITHUB_OUTPUT"
|
||||
echo "COMMITS=$(IFS=,; echo "${commit_shas[*]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "PR_NUMBERS=$(IFS=,; echo "${merged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json
|
||||
echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Dispatch if Merged'
|
||||
if: "steps.get_commits.outputs.ALL_MERGED == 'true'"
|
||||
if: "steps.pr_status.outputs.STATE == 'MERGED'"
|
||||
id: 'dispatch_patch'
|
||||
uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325'
|
||||
env:
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
COMMITS: '${{ steps.get_commits.outputs.COMMITS }}'
|
||||
PR_NUMBERS: '${{ steps.get_commits.outputs.PR_NUMBERS }}'
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
@@ -97,10 +60,17 @@ jobs:
|
||||
// /patch preview
|
||||
if (commentBody.trim() === '/patch' || commentBody.trim() === '/patch both') {
|
||||
channels = ['stable', 'preview'];
|
||||
} else if (commentBody.includes('stable')) {
|
||||
} else if (commentBody.trim() === '/patch stable') {
|
||||
channels = ['stable'];
|
||||
} else if (commentBody.includes('preview')) {
|
||||
} else if (commentBody.trim() === '/patch preview') {
|
||||
channels = ['preview'];
|
||||
} else {
|
||||
// Fallback parsing for legacy formats
|
||||
if (commentBody.includes('channel=preview')) {
|
||||
channels = ['preview'];
|
||||
} else if (commentBody.includes('--channel preview')) {
|
||||
channels = ['preview'];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Detected channels:', channels);
|
||||
@@ -117,9 +87,9 @@ jobs:
|
||||
workflow_id: 'release-patch-1-create-pr.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
commits: process.env.COMMITS,
|
||||
commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}',
|
||||
channel: channel,
|
||||
original_prs: process.env.PR_NUMBERS,
|
||||
original_pr: '${{ github.event.issue.number }}',
|
||||
environment: 'prod'
|
||||
}
|
||||
});
|
||||
@@ -153,13 +123,13 @@ jobs:
|
||||
}
|
||||
|
||||
- name: 'Comment on Failure'
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.get_commits.outputs.ALL_MERGED == 'false'"
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.pr_status.outputs.STATE != 'MERGED'"
|
||||
uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d'
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
issue-number: '${{ github.event.issue.number }}'
|
||||
body: |
|
||||
:x: The `/patch` command failed. The following pull request(s) must be merged before a patch can be created: #${{ steps.get_commits.outputs.UNMERGED_PRS }}
|
||||
:x: The `/patch` command failed. This pull request must be merged before a patch can be created.
|
||||
|
||||
- name: 'Final Status Comment - Success'
|
||||
if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && steps.dispatch_patch.outputs.dispatched_run_urls"
|
||||
@@ -172,8 +142,7 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
@@ -191,8 +160,7 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: 'Release: Patch (1) Create PR'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commits }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commits:
|
||||
description: 'The commit SHAs to cherry-pick for the patch (comma-separated).'
|
||||
commit:
|
||||
description: 'The commit SHA to cherry-pick for the patch.'
|
||||
required: true
|
||||
type: 'string'
|
||||
channel:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
required: false
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on (comma-separated).'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -85,9 +85,9 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}'
|
||||
PATCH_COMMITS: '${{ github.event.inputs.commits }}'
|
||||
PATCH_COMMIT: '${{ github.event.inputs.commit }}'
|
||||
PATCH_CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
DRY_RUN: '${{ github.event.inputs.dry_run }}'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
{
|
||||
node scripts/releasing/create-patch-pr.js \
|
||||
--cli-package-name="${CLI_PACKAGE_NAME}" \
|
||||
--commits="${PATCH_COMMITS}" \
|
||||
--commit="${PATCH_COMMIT}" \
|
||||
--channel="${PATCH_CHANNEL}" \
|
||||
--pullRequestNumbers="${ORIGINAL_PRS}" \
|
||||
--pullRequestNumber="${ORIGINAL_PR}" \
|
||||
--dry-run="${DRY_RUN}"
|
||||
} 2>&1 | tee >(
|
||||
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Comment on Original PR'
|
||||
if: 'always() && inputs.original_prs'
|
||||
if: 'always() && inputs.original_pr'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}'
|
||||
COMMITS: '${{ github.event.inputs.commits }}'
|
||||
COMMIT: '${{ github.event.inputs.commit }}'
|
||||
CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
GITHUB_RUN_ID: '${{ github.run_id }}'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Release: Patch (3) Release'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
description: 'The branch, tag, or SHA to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on.'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -208,10 +208,10 @@ jobs:
|
||||
--label 'release-failure,priority/p0'
|
||||
|
||||
- name: 'Comment Success on Original PR'
|
||||
if: '${{ success() && github.event.inputs.original_prs }}'
|
||||
if: '${{ success() && github.event.inputs.original_pr }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
SUCCESS: 'true'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
@@ -225,10 +225,10 @@ jobs:
|
||||
node scripts/releasing/patch-comment.js
|
||||
|
||||
- name: 'Comment Failure on Original PR'
|
||||
if: '${{ failure() && github.event.inputs.original_prs }}'
|
||||
if: '${{ failure() && github.event.inputs.original_pr }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
SUCCESS: 'false'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
|
||||
@@ -124,6 +124,8 @@ npm install -g @google/gemini-cli@nightly
|
||||
|
||||
### Advanced Capabilities
|
||||
|
||||
- **Automated Iterative Loops**: Use [Ralph Wiggum mode](./docs/ralph-wiggum.md)
|
||||
to repeatedly execute prompts until a goal is met (e.g., fixing tests).
|
||||
- Ground your queries with built-in
|
||||
[Google Search](https://ai.google.dev/gemini-api/docs/grounding) for real-time
|
||||
information
|
||||
@@ -256,6 +258,33 @@ use `--output-format stream-json` to get newline-delimited JSON events:
|
||||
gemini -p "Run tests and deploy" --output-format stream-json
|
||||
```
|
||||
|
||||
### Ralph Wiggum Mode (Iterative Automation)
|
||||
|
||||
Ralph Wiggum mode is an advanced automation feature that allows Gemini CLI to
|
||||
repeatedly execute a prompt in a loop until a specific goal is achieved. This is
|
||||
ideal for tasks like fixing failing tests or complex refactoring.
|
||||
|
||||
To use Ralph Wiggum mode, provide a prompt and a **completion promise** (a
|
||||
string to look for in the output). The CLI will:
|
||||
|
||||
1. Enter **YOLO mode** to auto-approve all tool calls.
|
||||
2. Run the prompt and check the response for your completion string.
|
||||
3. If not found, it repeats the process up to a specified **max iterations**.
|
||||
4. **Persistent Context**: It uses a **memory file** (`memories.md` by default)
|
||||
to pass notes between iterations. **Note:** Use a unique `--memory-file` for
|
||||
different tasks in the same directory to ensure context isolation.
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the failing tests in this repo" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "ALL TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "task-fix-tests.md"
|
||||
```
|
||||
|
||||
At the end of the run, a summary table displays the result of each iteration and
|
||||
extracted test statistics.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
#### Start a new project
|
||||
|
||||
@@ -35,6 +35,9 @@ and parameters.
|
||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
||||
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--ralph-wiggum` | - | boolean | `false` | Enable Ralph Wiggum iterative loop mode |
|
||||
| `--completion-promise` | - | string | - | String to look for to signal completion in Ralph Wiggum mode |
|
||||
| `--max-iterations` | - | number | `10` | Maximum loop iterations for Ralph Wiggum mode |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
@@ -99,3 +102,18 @@ See [Extensions Documentation](../extensions/index.md) for more details.
|
||||
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
|
||||
|
||||
See [MCP Server Integration](../tools/mcp-server.md) for more details.
|
||||
|
||||
## Skills management
|
||||
|
||||
| Command | Description | Example |
|
||||
| -------------------------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| `gemini skills list` | List all discovered agent skills | `gemini skills list` |
|
||||
| `gemini skills install <source>` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` |
|
||||
| `gemini skills link <path>` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` |
|
||||
| `gemini skills uninstall <name>` | Uninstall an agent skill | `gemini skills uninstall my-skill` |
|
||||
| `gemini skills enable <name>` | Enable an agent skill | `gemini skills enable my-skill` |
|
||||
| `gemini skills disable <name>` | Disable an agent skill | `gemini skills disable my-skill` |
|
||||
| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` |
|
||||
| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` |
|
||||
|
||||
See [Agent Skills Documentation](./skills.md) for more details.
|
||||
|
||||
@@ -115,7 +115,7 @@ they appear in the UI.
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
+16
-1
@@ -52,6 +52,7 @@ locations override lower ones: **Workspace > User > Extension**.
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
@@ -67,6 +68,13 @@ The `gemini skills` command provides management utilities:
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Link agent skills from a local directory via symlink
|
||||
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills (user)
|
||||
gemini skills link /path/to/my-skills-repo
|
||||
|
||||
# Link to the workspace scope (.gemini/skills)
|
||||
gemini skills link /path/to/my-skills-repo --scope workspace
|
||||
|
||||
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||
# Uses the user scope by default (~/.gemini/skills)
|
||||
gemini skills install https://github.com/user/repo.git
|
||||
@@ -89,7 +97,7 @@ gemini skills enable my-expertise
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## How it Works (Security & Privacy)
|
||||
## How it Works
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
tiers and injects the name and description of all enabled skills into the
|
||||
@@ -106,6 +114,13 @@ gemini skills disable my-expertise --scope workspace
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
|
||||
### Skill activation
|
||||
|
||||
Once a skill is activated (typically by Gemini identifying a task that matches
|
||||
the skill's description and your approval), its specialized instructions and
|
||||
resources are loaded into the agent's context. A skill remains active and its
|
||||
guidance is prioritized for the duration of the session.
|
||||
|
||||
## Creating your own skills
|
||||
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
|
||||
@@ -146,8 +146,8 @@ it yourself; just report it.
|
||||
| `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. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. |
|
||||
| `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`. |
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
|
||||
@@ -792,7 +792,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.allowed`** (array):
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Ralph Wiggum mode
|
||||
|
||||
Ralph Wiggum mode is an iterative automation technique that lets Gemini CLI
|
||||
repeatedly execute a prompt until a specific goal is met. This mode is designed
|
||||
for tasks that benefit from persistent refinement, such as fixing failing tests
|
||||
or performing complex refactoring.
|
||||
|
||||
> **Note:** This is a preview feature currently under active development.
|
||||
|
||||
## Overview
|
||||
|
||||
Inspired by the "Ralph Wiggum" technique, this mode treats failures as data and
|
||||
uses a feedback loop to reach a successful state. When you enable Ralph Wiggum
|
||||
mode, Gemini CLI enters YOLO (auto-approval) mode and continues to process the
|
||||
provided prompt until it detects your specified completion string in the model's
|
||||
output or reaches the maximum number of iterations.
|
||||
|
||||
## Usage
|
||||
|
||||
To use Ralph Wiggum mode, you must provide a prompt using the `-p` or `--prompt`
|
||||
flag. You then configure the loop behavior using the following flags:
|
||||
|
||||
| Flag | Description |
|
||||
| :--------------------- | :--------------------------------------------------------- |
|
||||
| `--ralph-wiggum` | Enables the Ralph Wiggum iterative loop mode. |
|
||||
| `--completion-promise` | The string to look for in the output to signal completion. |
|
||||
| `--max-iterations` | The maximum number of times to run the loop (default: 10). |
|
||||
| `--memory-file` | Task-specific memory file (default: `memories.md`). |
|
||||
|
||||
### Example
|
||||
|
||||
The following command attempts to fix tests by running the loop up to 5 times
|
||||
until the string "TESTS PASSED" appears in the output, using a specific memory
|
||||
file for this task:
|
||||
|
||||
```bash
|
||||
gemini -p "Fix the tests in packages/core" \
|
||||
--ralph-wiggum \
|
||||
--completion-promise "TESTS PASSED" \
|
||||
--max-iterations 5 \
|
||||
--memory-file "fix-core-tests.md"
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When you run Gemini CLI with the `--ralph-wiggum` flag, the following process
|
||||
occurs:
|
||||
|
||||
1. **Enforces YOLO mode:** The tool automatically sets the approval mode to
|
||||
`yolo`. This ensures that tool calls (like writing files or running shell
|
||||
commands) are approved automatically to allow the automation to proceed
|
||||
without human intervention.
|
||||
2. **Iterative execution:** The CLI executes the provided prompt in a loop.
|
||||
3. **Completion check:** After each iteration, the CLI scans the full text of
|
||||
the assistant's response for the string provided in `--completion-promise`.
|
||||
4. **Loop termination:**
|
||||
- If the completion string is found, the loop exits successfully.
|
||||
- If the completion string is not found, the CLI starts a new iteration
|
||||
using the same initial prompt.
|
||||
- If the number of iterations reaches the `--max-iterations` limit, the loop
|
||||
stops.
|
||||
|
||||
## Persistent context (Memories)
|
||||
|
||||
To help the agent learn from previous attempts, Ralph Wiggum mode uses a
|
||||
`memories.md` file in your current working directory.
|
||||
|
||||
- **Automatic creation:** If the file doesn't exist, the CLI creates it with a
|
||||
default header.
|
||||
- **Context injection:** At the start of each iteration, the content of
|
||||
`memories.md` is read and prepended to your prompt.
|
||||
- **Usage:** You (or the agent, via tool use) can write notes, error logs, or
|
||||
successful patterns into this file. This allows the agent to "remember" what
|
||||
failed in iteration 1 and avoid repeating the same mistake in iteration 2.
|
||||
|
||||
## Summary statistics
|
||||
|
||||
At the end of the execution, Ralph Wiggum mode provides a summary table in the
|
||||
terminal. This table details the performance of each iteration, including:
|
||||
|
||||
- **Iteration number:** The sequence of the run.
|
||||
- **Status:** Whether the iteration met the completion promise ("Success") or
|
||||
failed to do so ("Failed").
|
||||
- **Tests Passed/Failed:** If the output contains recognizable test runner
|
||||
patterns (such as those from Vitest, Jest, or Mocha), the CLI extracts and
|
||||
displays the number of passing and failing tests.
|
||||
|
||||
### Example summary table
|
||||
|
||||
```text
|
||||
--- Ralph Wiggum Mode Summary ---
|
||||
| Iteration | Status | Tests Passed | Tests Failed |
|
||||
|-----------|---------|--------------|--------------|
|
||||
| 1 | Failed | 2 | 10 |
|
||||
| 2 | Failed | 8 | 4 |
|
||||
| 3 | Success | 12 | 0 |
|
||||
---------------------------------
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
To get the most out of Ralph Wiggum mode, we recommend the following:
|
||||
|
||||
- **Clear completion criteria:** Ensure your prompt instructs the model to emit
|
||||
a specific, unique string (like "ALL TESTS PASSED") only when the task is
|
||||
truly complete.
|
||||
- **Incremental goals:** Use prompts that encourage the model to make small,
|
||||
verifiable changes in each iteration.
|
||||
- **Safety nets:** Always set a reasonable `--max-iterations` limit to prevent
|
||||
unintended long-running processes.
|
||||
|
||||
## Development and rebuilding
|
||||
|
||||
If you're modifying Ralph Wiggum mode or enabling it in a development
|
||||
environment, you must recompile the TypeScript source code.
|
||||
|
||||
### Full rebuild
|
||||
|
||||
To build all packages in the monorepo, run the following command from the root
|
||||
directory:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Fast CLI rebuild
|
||||
|
||||
If you've already performed a full build and are only making changes to the CLI
|
||||
package, you can run a targeted build:
|
||||
|
||||
```bash
|
||||
npm run build -w @google/gemini-cli
|
||||
```
|
||||
|
||||
### Running in development
|
||||
|
||||
After rebuilding, test your changes using the `npm run start` script:
|
||||
|
||||
```bash
|
||||
npm run start -- -p "Your task" --ralph-wiggum --completion-promise "SUCCESS"
|
||||
```
|
||||
@@ -45,6 +45,7 @@
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
|
||||
{ "label": "Ralph Wiggum mode", "slug": "docs/ralph-wiggum" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Automated tool use', () => {
|
||||
/**
|
||||
* Tests that the agent always utilizes --fix when calling eslint.
|
||||
* We provide a 'lint' script in the package.json, which helps elicit
|
||||
* a repro by guiding the agent into using the existing deficient script.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use automated tools (eslint --fix) to fix code style issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'typescript-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
lint: 'eslint .',
|
||||
},
|
||||
devDependencies: {
|
||||
eslint: '^9.0.0',
|
||||
globals: '^15.0.0',
|
||||
typescript: '^5.0.0',
|
||||
'typescript-eslint': '^8.0.0',
|
||||
'@eslint/js': '^9.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'eslint.config.js': `
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts}"],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"prefer-const": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
`,
|
||||
'src/app.ts': `
|
||||
export function main() {
|
||||
let count = 10;
|
||||
console.log(count);
|
||||
}
|
||||
`,
|
||||
},
|
||||
prompt:
|
||||
'Fix the linter errors in this project. Make sure to avoid interactive commands.',
|
||||
assert: async (rig) => {
|
||||
// Check if run_shell_command was used with --fix
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCommands = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasFixCommand = shellCommands.some((call) => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const cmd = (args as any)['command'];
|
||||
return (
|
||||
cmd &&
|
||||
(cmd.includes('eslint') || cmd.includes('npm run lint')) &&
|
||||
cmd.includes('--fix')
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasFixCommand,
|
||||
'Expected agent to use eslint --fix via run_shell_command',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests that the agent uses prettier --write to fix formatting issues in files
|
||||
* instead of trying to edit the files itself.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use automated tools (prettier --write) to fix formatting issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'typescript-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {},
|
||||
devDependencies: {
|
||||
prettier: '^3.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'.prettierrc': JSON.stringify(
|
||||
{
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'src/app.ts': `
|
||||
export function main() {
|
||||
const data={ name:'test',
|
||||
val:123
|
||||
}
|
||||
console.log(data)
|
||||
}
|
||||
`,
|
||||
},
|
||||
prompt:
|
||||
'Fix the formatting errors in this project. Make sure to avoid interactive commands.',
|
||||
assert: async (rig) => {
|
||||
// Check if run_shell_command was used with --write
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCommands = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasFixCommand = shellCommands.some((call) => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const cmd = (args as any)['command'];
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('prettier') &&
|
||||
(cmd.includes('--write') || cmd.includes('-w'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasFixCommand,
|
||||
'Expected agent to use prettier --write via run_shell_command',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('interactive_commands', () => {
|
||||
/**
|
||||
* Validates that the agent does not use interactive commands unprompted.
|
||||
* Interactive commands block the progress of the agent, requiring user
|
||||
* intervention.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not use interactive commands',
|
||||
prompt: 'Execute tests.',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'example',
|
||||
type: 'module',
|
||||
devDependencies: {
|
||||
vitest: 'latest',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'example.test.js': `
|
||||
import { test, expect } from 'vitest';
|
||||
test('it works', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const vitestCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
l.toolRequest.args.toLowerCase().includes('vitest'),
|
||||
);
|
||||
|
||||
expect(vitestCall, 'Agent should have called vitest').toBeDefined();
|
||||
expect(
|
||||
vitestCall?.toolRequest.args,
|
||||
'Agent should have passed run arg',
|
||||
).toMatch(/\b(run|--run)\b/);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,14 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
try {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
// Symlink node modules to reduce the amount of time needed to
|
||||
// bootstrap test projects.
|
||||
const rootNodeModules = path.join(process.cwd(), 'node_modules');
|
||||
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
|
||||
if (fs.existsSync(rootNodeModules)) {
|
||||
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
|
||||
}
|
||||
|
||||
if (evalCase.files) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
|
||||
Generated
+31
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2251,6 +2251,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",
|
||||
@@ -2431,6 +2432,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"
|
||||
}
|
||||
@@ -2464,6 +2466,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2832,6 +2835,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2865,6 +2869,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2917,6 +2922,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4122,6 +4128,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4399,6 +4406,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",
|
||||
@@ -5391,6 +5399,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"
|
||||
},
|
||||
@@ -8400,6 +8409,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8940,6 +8950,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",
|
||||
@@ -10541,6 +10552,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14299,6 +14311,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14309,6 +14322,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16545,6 +16559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16768,7 +16783,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",
|
||||
@@ -16776,6 +16792,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"
|
||||
@@ -16948,6 +16965,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17155,6 +17173,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",
|
||||
@@ -17268,6 +17287,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17280,6 +17300,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",
|
||||
@@ -17984,6 +18005,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"
|
||||
}
|
||||
@@ -17999,7 +18021,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18055,7 +18077,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18142,7 +18164,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -18278,6 +18300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18300,7 +18323,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18317,7 +18340,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
getCodeAssistServer,
|
||||
Config,
|
||||
ExperimentFlags,
|
||||
fetchAdminControlsOnce,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -19,11 +24,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Config: vi.fn().mockImplementation((params) => ({
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
...params, // Expose params for assertion
|
||||
})),
|
||||
Config: vi.fn().mockImplementation((params) => {
|
||||
const mockConfig = {
|
||||
...params,
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getExperiments: vi.fn().mockReturnValue({
|
||||
flags: {
|
||||
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
boolValue: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
return mockConfig;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
@@ -31,6 +48,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
getCodeAssistServer: vi.fn(),
|
||||
fetchAdminControlsOnce: vi.fn(),
|
||||
coreEvents: {
|
||||
emitAdminSettingsChanged: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +78,121 @@ describe('loadConfig', () => {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
describe('admin settings overrides', () => {
|
||||
it('should not fetch admin controls if experiment is disabled', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when admin controls experiment is enabled', () => {
|
||||
beforeEach(() => {
|
||||
// We need to cast to any here to modify the mock implementation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Config as any).mockImplementation((params: unknown) => {
|
||||
const mockConfig = {
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getExperiments: vi.fn().mockReturnValue({
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
boolValue: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
return mockConfig;
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch admin controls and apply them', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: false,
|
||||
},
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {
|
||||
extensionsEnabled: false,
|
||||
},
|
||||
},
|
||||
strictModeDisabled: false,
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !mockAdminSettings.strictModeDisabled,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled:
|
||||
mockAdminSettings.cliFeatureSetting?.extensionsSetting
|
||||
?.extensionsEnabled,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat unset admin settings as false when admin settings are passed', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
},
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !false,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass default unset admin settings when no admin settings are present', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
|
||||
});
|
||||
|
||||
it('should fetch admin controls using the code assist server when available', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
},
|
||||
strictModeDisabled: true,
|
||||
};
|
||||
const mockCodeAssistServer = { projectId: 'test-project' };
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockCodeAssistServer as any,
|
||||
);
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
|
||||
mockCodeAssistServer,
|
||||
true,
|
||||
);
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !mockAdminSettings.strictModeDisabled,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
ExperimentFlags,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
@@ -124,37 +127,50 @@ export async function loadConfig(
|
||||
configParams.userMemory = memoryContent;
|
||||
configParams.geminiMdFileCount = fileCount;
|
||||
configParams.geminiMdFilePaths = filePaths;
|
||||
const config = new Config({
|
||||
|
||||
// Set an initial config to use to get a code assist server.
|
||||
// This is needed to fetch admin controls.
|
||||
const initialConfig = new Config({
|
||||
...configParams,
|
||||
});
|
||||
|
||||
const codeAssistServer = getCodeAssistServer(initialConfig);
|
||||
|
||||
const adminControlsEnabled =
|
||||
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
|
||||
?.boolValue ?? false;
|
||||
|
||||
// Initialize final config parameters to the previous parameters.
|
||||
// If no admin controls are needed, these will be used as-is for the final
|
||||
// config.
|
||||
const finalConfigParams = { ...configParams };
|
||||
if (adminControlsEnabled) {
|
||||
const adminSettings = await fetchAdminControlsOnce(
|
||||
codeAssistServer,
|
||||
adminControlsEnabled,
|
||||
);
|
||||
|
||||
// Admin settings are able to be undefined if unset, but if any are present,
|
||||
// we should initialize them all.
|
||||
// If any are present, undefined settings should be treated as if they were
|
||||
// set to false.
|
||||
// If NONE are present, disregard admin settings entirely, and pass the
|
||||
// final config as is.
|
||||
if (Object.keys(adminSettings).length !== 0) {
|
||||
finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
|
||||
finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
|
||||
finalConfigParams.extensionsEnabled =
|
||||
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
const config = new Config(finalConfigParams);
|
||||
|
||||
// Needed to initialize ToolRegistry, and git checkpointing if enabled
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info('[Config] Using CCPA Auth:');
|
||||
try {
|
||||
if (adcFilePath) {
|
||||
path.resolve(adcFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[Config] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
||||
);
|
||||
}
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
logger.info(
|
||||
`[Config] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
logger.info('[Config] Using Gemini API Key');
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
} else {
|
||||
const errorMessage =
|
||||
'[Config] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.';
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -222,3 +238,33 @@ function findEnvFile(startDir: string): string | null {
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
adcFilePath: string | undefined,
|
||||
logPrefix: string,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
try {
|
||||
if (adcFilePath) {
|
||||
path.resolve(adcFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
||||
);
|
||||
}
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
|
||||
@@ -8,7 +8,10 @@ import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
type ResolvedExtensionSetting,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export async function getExtensionManager() {
|
||||
const workspaceDir = process.cwd();
|
||||
@@ -35,3 +38,15 @@ export async function getExtensionAndManager(name: string) {
|
||||
|
||||
return { extension, extensionManager };
|
||||
}
|
||||
|
||||
export function getFormattedSettingValue(
|
||||
setting: ResolvedExtensionSetting,
|
||||
): string {
|
||||
if (!setting.value) {
|
||||
return '[not set]';
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
return '***';
|
||||
}
|
||||
return setting.value;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actualFs,
|
||||
existsSync: vi.fn(actualFs.existsSync),
|
||||
readFileSync: vi.fn(actualFs.readFileSync),
|
||||
writeFileSync: vi.fn(actualFs.writeFileSync),
|
||||
mkdirSync: vi.fn(actualFs.mkdirSync),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
@@ -30,6 +41,14 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(() => ({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
})),
|
||||
isFolderTrustEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('mcp remove command', () => {
|
||||
describe('unit tests with mocks', () => {
|
||||
let parser: Argv;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listCommand } from './skills/list.js';
|
||||
import { enableCommand } from './skills/enable.js';
|
||||
import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { linkCommand } from './skills/link.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
@@ -27,6 +28,7 @@ export const skillsCommand: CommandModule = {
|
||||
.command(defer(enableCommand, 'skills'))
|
||||
.command(defer(disableCommand, 'skills'))
|
||||
.command(defer(installCommand, 'skills'))
|
||||
.command(defer(linkCommand, 'skills'))
|
||||
.command(defer(uninstallCommand, 'skills'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
|
||||
const mockLinkSkill = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', () => ({
|
||||
linkSkill: mockLinkSkill,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
skillsConsentString: mockSkillsConsentString,
|
||||
}));
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
describe('skills link command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
describe('linkCommand', () => {
|
||||
it('should have correct command and describe', () => {
|
||||
expect(linkCommand.command).toBe('link <path>');
|
||||
expect(linkCommand.describe).toContain('Links an agent skill');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call linkSkill with correct arguments', async () => {
|
||||
const sourcePath = '/source/path';
|
||||
mockLinkSkill.mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/dest/path' },
|
||||
]);
|
||||
|
||||
await handleLink({ path: sourcePath, scope: 'user' });
|
||||
|
||||
expect(mockLinkSkill).toHaveBeenCalledWith(
|
||||
sourcePath,
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully linked skills'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle linkSkill failure', async () => {
|
||||
mockLinkSkill.mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await handleLink({ path: '/some/path' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith('Link failed');
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
interface LinkArgs {
|
||||
path: string;
|
||||
scope?: 'user' | 'workspace';
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
export async function handleLink(args: LinkArgs) {
|
||||
try {
|
||||
const { scope = 'user', consent } = args;
|
||||
|
||||
await linkSkill(
|
||||
args.path,
|
||||
scope,
|
||||
(msg) => debugLogger.log(msg),
|
||||
async (skills, targetDir) => {
|
||||
const consentString = await skillsConsentString(
|
||||
skills,
|
||||
args.path,
|
||||
targetDir,
|
||||
true,
|
||||
);
|
||||
if (consent) {
|
||||
debugLogger.log('You have consented to the following:');
|
||||
debugLogger.log(consentString);
|
||||
return true;
|
||||
}
|
||||
return requestConsentNonInteractive(consentString);
|
||||
},
|
||||
);
|
||||
|
||||
debugLogger.log(chalk.green('\nSuccessfully linked skills.'));
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
'Links an agent skill from a local path. Updates to the source will be reflected immediately.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The local path of the skill to link.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to link the skill into. Defaults to "user" (global).',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
})
|
||||
.option('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of linking a skill and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.path) {
|
||||
throw new Error('The path argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
path: argv['path'] as string,
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
@@ -1014,7 +1015,9 @@ describe('mergeExcludeTools', () => {
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set([...defaultExcludes, ASK_USER_TOOL_NAME]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
@@ -1098,6 +1101,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
|
||||
@@ -1118,6 +1122,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
|
||||
@@ -1138,9 +1143,10 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude no interactive tools in non-interactive mode with yolo approval mode', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
@@ -1158,6 +1164,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => {
|
||||
@@ -1182,9 +1189,10 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with legacy yolo flag', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
@@ -1195,6 +1203,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => {
|
||||
@@ -1219,6 +1228,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(ASK_USER_TOOL_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1556,12 +1566,12 @@ describe('loadCliConfig folderTrust', () => {
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false by default', async () => {
|
||||
it('should be true by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getFolderTrust()).toBe(false);
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1777,6 +1787,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).not.toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
|
||||
@@ -1791,6 +1802,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).not.toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
|
||||
@@ -1805,9 +1817,10 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).toContain('replace');
|
||||
expect(config.getExcludeTools()).toContain('write_file');
|
||||
expect(config.getExcludeTools()).toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with YOLO', async () => {
|
||||
process.stdin.isTTY = false;
|
||||
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1819,6 +1832,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HookDefinition,
|
||||
@@ -69,6 +70,11 @@ export interface CliArgs {
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
|
||||
ralphWiggum: boolean | undefined;
|
||||
completionPromise: string | undefined;
|
||||
maxIterations: number | undefined;
|
||||
memoryFile: string | undefined;
|
||||
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
@@ -140,6 +146,31 @@ export async function parseArguments(
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
|
||||
.option('ralph-wiggum', {
|
||||
alias: 'ralphWiggum',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable Ralph Wiggum mode (iterative loop with YOLO mode).',
|
||||
})
|
||||
.option('completion-promise', {
|
||||
alias: 'completionPromise',
|
||||
type: 'string',
|
||||
description:
|
||||
'The string to look for in the output to signal completion in Ralph Wiggum mode.',
|
||||
})
|
||||
.option('max-iterations', {
|
||||
alias: 'maxIterations',
|
||||
type: 'number',
|
||||
description: 'Maximum number of iterations for Ralph Wiggum mode.',
|
||||
})
|
||||
.option('memory-file', {
|
||||
alias: 'memoryFile',
|
||||
type: 'string',
|
||||
description:
|
||||
'Task-specific memory file for Ralph Wiggum mode (defaults to memories.md).',
|
||||
default: 'memories.md',
|
||||
})
|
||||
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -596,6 +627,10 @@ export async function loadCliConfig(
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive) {
|
||||
// ask_user requires user interaction and must be excluded in all
|
||||
// non-interactive modes, regardless of the approval mode.
|
||||
extraExcludes.push(ASK_USER_TOOL_NAME);
|
||||
|
||||
const defaultExcludes = [
|
||||
SHELL_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -146,6 +147,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -182,6 +184,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -195,7 +198,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('[not set]');
|
||||
expect(setting?.value).toBeUndefined();
|
||||
expect(setting?.scope).toBeUndefined();
|
||||
|
||||
// Verify output string does not contain scope
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
}),
|
||||
isTrustedFolder: () => true,
|
||||
getImportFormat: () => 'tree',
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
@@ -208,6 +209,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
getAgentRegistry: () => ({
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import { getFormattedSettingValue } from '../commands/extensions/utils.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
@@ -648,12 +649,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
resolvedSettings.push({
|
||||
name: setting.name,
|
||||
envVar: setting.envVar,
|
||||
value:
|
||||
value === undefined
|
||||
? '[not set]'
|
||||
: setting.sensitive
|
||||
? '***'
|
||||
: value,
|
||||
value,
|
||||
sensitive: setting.sensitive ?? false,
|
||||
scope,
|
||||
source,
|
||||
@@ -941,7 +937,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
scope += ')';
|
||||
}
|
||||
output += `\n ${setting.name}: ${setting.value} ${scope}`;
|
||||
output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -28,14 +28,19 @@ export async function skillsConsentString(
|
||||
skills: SkillDefinition[],
|
||||
source: string,
|
||||
targetDir?: string,
|
||||
isLink = false,
|
||||
): Promise<string> {
|
||||
const action = isLink ? 'Linking' : 'Installing';
|
||||
const output: string[] = [];
|
||||
output.push(`Installing agent skill(s) from "${source}".`);
|
||||
output.push('\nThe following agent skill(s) will be installed:\n');
|
||||
output.push(`${action} agent skill(s) from "${source}".`);
|
||||
output.push(
|
||||
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
|
||||
);
|
||||
output.push(...(await renderSkillsList(skills)));
|
||||
|
||||
if (targetDir) {
|
||||
output.push(`Install Destination: ${targetDir}`);
|
||||
const destLabel = isLink ? 'Link' : 'Install';
|
||||
output.push(`${destLabel} Destination: ${targetDir}`);
|
||||
}
|
||||
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
||||
|
||||
|
||||
@@ -786,6 +786,23 @@ describe('extensionSettings', () => {
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete a non-sensitive setting if the new value is empty', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).not.toContain('VAR1=');
|
||||
});
|
||||
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
|
||||
@@ -251,7 +251,11 @@ export async function updateSetting(
|
||||
}
|
||||
|
||||
const parsedEnv = dotenv.parse(envContent);
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
if (!newValue) {
|
||||
delete parsedEnv[settingToUpdate.envVar];
|
||||
} else {
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
}
|
||||
|
||||
// We only want to write back the variables that are not sensitive.
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
|
||||
@@ -67,13 +67,14 @@ import {
|
||||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
type Settings,
|
||||
saveSettings,
|
||||
type SettingsFile,
|
||||
saveSettings,
|
||||
getDefaultsFromSchema,
|
||||
loadEnvironment,
|
||||
migrateDeprecatedSettings,
|
||||
SettingScope,
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
} from './settings.js';
|
||||
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
MergeStrategy,
|
||||
type SettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
// Use the (mocked) GEMINI_DIR for consistency
|
||||
@@ -105,7 +107,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,9 +121,11 @@ const mockCoreEvents = vi.hoisted(() => ({
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
homedir: vi.fn(() => os.homedir()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1460,6 +1464,44 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
|
||||
const mockHomeDir = '/mock/home/user';
|
||||
const mockSymlinkDir = '/mock/symlink/to/home';
|
||||
const mockWorkspaceSettingsPath = path.join(
|
||||
mockSymlinkDir,
|
||||
GEMINI_DIR,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
const resolved = path.resolve(pStr);
|
||||
if (
|
||||
resolved === path.resolve(mockSymlinkDir) ||
|
||||
resolved === path.resolve(mockHomeDir)
|
||||
) {
|
||||
return mockHomeDir;
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
@@ -1706,6 +1748,7 @@ describe('Settings Loading and Merging', () => {
|
||||
isFolderTrustEnabled = true,
|
||||
isWorkspaceTrustedValue = true as boolean | undefined,
|
||||
}) {
|
||||
delete process.env['GEMINI_API_KEY']; // reset
|
||||
delete process.env['TESTTEST']; // reset
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
@@ -1739,7 +1782,8 @@ describe('Settings Loading and Merging', () => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (normalizedP === geminiEnvPath) return 'TESTTEST=1234';
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -1753,6 +1797,7 @@ describe('Settings Loading and Merging', () => {
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files from untrusted spaces', () => {
|
||||
@@ -1777,6 +1822,36 @@ describe('Settings Loading and Merging', () => {
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.merged.tools.sandbox = true;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
|
||||
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
// TESTTEST is NOT in the whitelist, so it should be blocked.
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => {
|
||||
const originalArgv = [...process.argv];
|
||||
process.argv.push('-s');
|
||||
try {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure sandbox is NOT in settings to test argv sniffing
|
||||
settings.merged.tools.sandbox = undefined;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1935,29 +2010,7 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = new LoadedSettings(
|
||||
{
|
||||
path: getSystemSettingsPath(),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: getSystemDefaultsPath(),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettingsContent as unknown as Settings,
|
||||
originalSettings: userSettingsContent as unknown as Settings,
|
||||
},
|
||||
{
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
@@ -2126,11 +2179,8 @@ describe('Settings Loading and Merging', () => {
|
||||
describe('saveSettings', () => {
|
||||
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: { ui: { theme: 'dark' } },
|
||||
originalSettings: { ui: { theme: 'dark' } },
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2144,11 +2194,8 @@ describe('Settings Loading and Merging', () => {
|
||||
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
||||
mockFsExistsSync.mockReturnValue(false);
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/new/dir/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/new/dir/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2165,11 +2212,8 @@ describe('Settings Loading and Merging', () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2216,8 +2260,11 @@ describe('Settings Loading and Merging', () => {
|
||||
// 2. Now, set remote admin settings.
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Verify that remote admin settings take precedence.
|
||||
@@ -2257,8 +2304,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const newRemoteSettings = {
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
loadedSettings.setRemoteAdminSettings(newRemoteSettings);
|
||||
@@ -2269,13 +2319,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
// Non-admin settings should remain untouched
|
||||
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
|
||||
|
||||
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
|
||||
// and missing properties revert to schema defaults.
|
||||
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
|
||||
});
|
||||
|
||||
it('should correctly handle undefined remote admin settings', () => {
|
||||
@@ -2307,84 +2350,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly handle missing properties in remote admin settings', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const systemSettingsContent = {
|
||||
admin: {
|
||||
secureModeEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure initial state from defaults (as file-based admin settings are ignored)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
|
||||
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
|
||||
// Verify secureModeEnabled is updated, others default to false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only mcpSetting.mcpEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
});
|
||||
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
});
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Verify that missing strictModeDisabled falls back to secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
// Verify strictModeDisabled takes precedence over secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: true,
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
|
||||
const loadedSettings = loadSettings();
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
@@ -2402,51 +2367,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default mcp.enabled to false if mcpSetting is present but mcpEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default extensions.enabled to false if extensionsSetting is present but extensionsEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {},
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to false to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = false;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
try {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have strictModeDisabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to true by the logic (default secure), overriding the mock default of false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle completely empty remote admin settings response', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
@@ -2496,4 +2416,391 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security and Sandbox', () => {
|
||||
let originalArgv: string[];
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalArgv = [...process.argv];
|
||||
originalEnv = { ...process.env };
|
||||
// Clear relevant env vars
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
delete process.env['GOOGLE_CLOUD_LOCATION'];
|
||||
delete process.env['CLOUD_SHELL'];
|
||||
delete process.env['MALICIOUS_VAR'];
|
||||
delete process.env['FOO'];
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('sandbox detection', () => {
|
||||
it('should detect sandbox when -s is a real flag', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'some prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'FOO=bar\nGEMINI_API_KEY=secret',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be.
|
||||
expect(process.env['FOO']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret');
|
||||
});
|
||||
|
||||
it('should detect sandbox when --sandbox is a real flag', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret');
|
||||
});
|
||||
|
||||
it('should ignore sandbox flags if they appear after --', () => {
|
||||
process.argv = ['node', 'gemini', '--', '-s', 'some prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT be tricked by positional arguments that look like flags', () => {
|
||||
process.argv = ['node', 'gemini', 'my -s prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('env var sanitization', () => {
|
||||
it('should strictly enforce whitelist in untrusted/sandboxed mode', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(`
|
||||
GEMINI_API_KEY=secret-key
|
||||
MALICIOUS_VAR=should-be-ignored
|
||||
GOOGLE_API_KEY=another-secret
|
||||
`);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret-key');
|
||||
expect(process.env['GOOGLE_API_KEY']).toBe('another-secret');
|
||||
expect(process.env['MALICIOUS_VAR']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
|
||||
const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}';
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`GEMINI_API_KEY=${maliciousPayload}`,
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '')
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-');
|
||||
});
|
||||
|
||||
it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
|
||||
const complexPayload = 'secret-123/path.to/somewhere;rm -rf /';
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`GEMINI_API_KEY=${complexPayload}`,
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe(
|
||||
'secret-123/path.to/somewhererm-rf/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT sanitize variables from trusted sources', () => {
|
||||
process.argv = ['node', 'gemini', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Trusted source, no sanitization
|
||||
expect(process.env['FOO']).toBe('$(bar)');
|
||||
});
|
||||
|
||||
it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(`
|
||||
GEMINI_API_KEY=un-sanitized;key!
|
||||
MALICIOUS_VAR=allowed-because-trusted
|
||||
`);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!');
|
||||
expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted');
|
||||
});
|
||||
|
||||
it('should sanitize value in sanitizeEnvVar helper', () => {
|
||||
expect(sanitizeEnvVar('$(calc)')).toBe('calc');
|
||||
expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/');
|
||||
expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123');
|
||||
expect(sanitizeEnvVar('us-central1')).toBe('us-central1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Shell security', () => {
|
||||
it('should handle Cloud Shell special defaults securely when untrusted', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// No .env file
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
});
|
||||
|
||||
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GOOGLE_CLOUD_PROJECT=attacker-project;inject',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe(
|
||||
'attacker-projectinject',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings Isolation and Serializability', () => {
|
||||
let loadedSettings: LoadedSettings;
|
||||
|
||||
interface TestData {
|
||||
a: {
|
||||
b: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Create a minimal LoadedSettings instance
|
||||
const emptyScope = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
|
||||
loadedSettings = new LoadedSettings(
|
||||
emptyScope, // system
|
||||
emptyScope, // systemDefaults
|
||||
{ ...emptyScope }, // user
|
||||
emptyScope, // workspace
|
||||
true, // isTrusted
|
||||
);
|
||||
});
|
||||
|
||||
describe('setValue Isolation', () => {
|
||||
it('should isolate state between settings and originalSettings', () => {
|
||||
const complexValue: TestData = { a: { b: 1 } };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', complexValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as TestData;
|
||||
const originalValue = (
|
||||
userSettings.originalSettings as Record<string, unknown>
|
||||
)['test'] as TestData;
|
||||
|
||||
// Verify they are equal but different references
|
||||
expect(settingsValue).toEqual(complexValue);
|
||||
expect(originalValue).toEqual(complexValue);
|
||||
expect(settingsValue).not.toBe(complexValue);
|
||||
expect(originalValue).not.toBe(complexValue);
|
||||
expect(settingsValue).not.toBe(originalValue);
|
||||
|
||||
// Modify the in-memory setting object
|
||||
settingsValue.a.b = 2;
|
||||
|
||||
// originalSettings should NOT be affected
|
||||
expect(originalValue.a.b).toBe(1);
|
||||
});
|
||||
|
||||
it('should not share references between settings and originalSettings (original servers test)', () => {
|
||||
const mcpServers = {
|
||||
'test-server': { command: 'echo' },
|
||||
};
|
||||
|
||||
loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers);
|
||||
|
||||
// Modify the original object
|
||||
delete (mcpServers as Record<string, unknown>)['test-server'];
|
||||
|
||||
// The settings in LoadedSettings should still have the server
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
expect(
|
||||
(userSettings.settings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(userSettings.originalSettings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
|
||||
// They should also be different objects from each other
|
||||
expect(userSettings.settings.mcpServers).not.toBe(
|
||||
userSettings.originalSettings.mcpServers,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue Serializability', () => {
|
||||
it('should preserve Map/Set types (via structuredClone)', () => {
|
||||
const mapValue = { myMap: new Map([['key', 'value']]) };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', mapValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as { myMap: Map<string, string> };
|
||||
|
||||
// Map is preserved by structuredClone
|
||||
expect(settingsValue.myMap).toBeInstanceOf(Map);
|
||||
expect(settingsValue.myMap.get('key')).toBe('value');
|
||||
|
||||
// But it should be a different reference
|
||||
expect(settingsValue.myMap).not.toBe(mapValue.myMap);
|
||||
});
|
||||
|
||||
it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => {
|
||||
const circular: Record<string, unknown> = { a: 1 };
|
||||
circular['self'] = circular;
|
||||
|
||||
// structuredClone(circular) works, but LoadedSettings.setValue calls
|
||||
// computeMergedSettings() -> customDeepMerge() which blows up on circularity.
|
||||
expect(() => {
|
||||
loadedSettings.setValue(SettingScope.User, 'test', circular);
|
||||
}).toThrow(/Maximum call stack size exceeded/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+188
-148
@@ -16,7 +16,7 @@ import {
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
@@ -77,6 +77,21 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const AUTH_ENV_VAR_WHITELIST = [
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitizes an environment variable value to prevent shell injection.
|
||||
* Restricts values to a safe character set: alphanumeric, -, _, ., /
|
||||
*/
|
||||
export function sanitizeEnvVar(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
|
||||
}
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
@@ -277,8 +292,11 @@ export class LoadedSettings {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this._workspaceFile = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? workspace
|
||||
: this.createEmptyWorkspace(workspace);
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
@@ -286,10 +304,11 @@ export class LoadedSettings {
|
||||
readonly system: SettingsFile;
|
||||
readonly systemDefaults: SettingsFile;
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
workspace: SettingsFile;
|
||||
isTrusted: boolean;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _workspaceFile: SettingsFile;
|
||||
private _merged: MergedSettings;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
@@ -297,6 +316,26 @@ export class LoadedSettings {
|
||||
return this._merged;
|
||||
}
|
||||
|
||||
setTrusted(isTrusted: boolean): void {
|
||||
if (this.isTrusted === isTrusted) {
|
||||
return;
|
||||
}
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? this._workspaceFile
|
||||
: this.createEmptyWorkspace(this._workspaceFile);
|
||||
this._merged = this.computeMergedSettings();
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
|
||||
return {
|
||||
...workspace,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
}
|
||||
|
||||
private computeMergedSettings(): MergedSettings {
|
||||
const merged = mergeSettings(
|
||||
this.system.settings,
|
||||
@@ -341,21 +380,30 @@ export class LoadedSettings {
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
|
||||
// Clone value to prevent reference sharing between settings and originalSettings
|
||||
const valueToSet =
|
||||
typeof value === 'object' && value !== null
|
||||
? structuredClone(value)
|
||||
: value;
|
||||
|
||||
setNestedProperty(settingsFile.settings, key, valueToSet);
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
|
||||
setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void {
|
||||
const admin: Settings['admin'] = {};
|
||||
const {
|
||||
secureModeEnabled,
|
||||
strictModeDisabled,
|
||||
mcpSetting,
|
||||
cliFeatureSetting,
|
||||
} = remoteSettings;
|
||||
const { strictModeDisabled, mcpSetting, cliFeatureSetting } =
|
||||
remoteSettings;
|
||||
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -363,19 +411,13 @@ export class LoadedSettings {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strictModeDisabled !== undefined) {
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
} else if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
} else {
|
||||
admin.secureModeEnabled = true;
|
||||
}
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
|
||||
};
|
||||
admin.skills = {
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled,
|
||||
};
|
||||
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -412,26 +454,30 @@ function findEnvFile(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
export function setUpCloudShellEnvironment(
|
||||
envFilePath: string | null,
|
||||
isTrusted: boolean,
|
||||
isSandboxed: boolean,
|
||||
): void {
|
||||
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
||||
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
||||
// set by the user using "gcloud config set project" we do not want to
|
||||
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
||||
// one of the .env files, we set the Cloud Shell-specific default here.
|
||||
let value = 'cloudshell-gca';
|
||||
|
||||
if (envFilePath && fs.existsSync(envFilePath)) {
|
||||
const envFileContent = fs.readFileSync(envFilePath);
|
||||
const parsedEnv = dotenv.parse(envFileContent);
|
||||
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
|
||||
// .env file takes precedence in Cloud Shell
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
||||
} else {
|
||||
// If not in .env, set to default and override global
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
||||
if (!isTrusted && isSandboxed) {
|
||||
value = sanitizeEnvVar(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no .env file, set to default and override global
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
}
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
}
|
||||
|
||||
export function loadEnvironment(
|
||||
@@ -442,13 +488,29 @@ export function loadEnvironment(
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
|
||||
if (trustResult.isTrusted !== true) {
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
// that happens early in the CLI lifecycle. It is designed to detect the
|
||||
// sandbox flag before the full command-line parser is initialized to ensure
|
||||
// security constraints are applied when loading environment variables.
|
||||
const args = process.argv.slice(2);
|
||||
const doubleDashIndex = args.indexOf('--');
|
||||
const relevantArgs =
|
||||
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
|
||||
|
||||
const isSandboxed =
|
||||
!!settings.tools?.sandbox ||
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
if (trustResult.isTrusted !== true && !isSandboxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath);
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
@@ -464,6 +526,16 @@ export function loadEnvironment(
|
||||
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
// Sanitize the value for untrusted sources
|
||||
value = sanitizeEnvVar(value);
|
||||
}
|
||||
|
||||
// If it's a project .env file, skip loading excluded variables.
|
||||
if (isProjectEnvFile && excludedVars.includes(key)) {
|
||||
continue;
|
||||
@@ -471,7 +543,7 @@ export function loadEnvironment(
|
||||
|
||||
// Load variable only if it's not already set in the environment.
|
||||
if (!Object.hasOwn(process.env, key)) {
|
||||
process.env[key] = parsedEnv[key];
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,9 +674,10 @@ export function loadSettings(
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
getDefaultsFromSchema(),
|
||||
systemDefaultSettings,
|
||||
userSettings,
|
||||
systemSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
||||
@@ -682,57 +755,55 @@ export function migrateDeprecatedSettings(
|
||||
removeDeprecated = false,
|
||||
): boolean {
|
||||
let anyModified = false;
|
||||
|
||||
const migrateBoolean = (
|
||||
settings: Record<string, unknown>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (typeof newValue === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
// Only old exists, migrate to new (inverted)
|
||||
settings[newKey] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
|
||||
// Migrate inverted boolean settings (disableX -> enableX)
|
||||
// These settings were renamed and their boolean logic inverted
|
||||
// Migrate general settings
|
||||
const generalSettings = settings.general as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
// Migrate general settings (disableAutoUpdate, disableUpdateNag)
|
||||
if (generalSettings) {
|
||||
const newGeneral: Record<string, unknown> = { ...generalSettings };
|
||||
const newGeneral = { ...generalSettings };
|
||||
let modified = false;
|
||||
|
||||
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableAutoUpdate'];
|
||||
newGeneral['enableAutoUpdate'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof newGeneral['disableUpdateNag'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdateNotification'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableUpdateNag'];
|
||||
newGeneral['enableAutoUpdateNotification'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
modified =
|
||||
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
|
||||
modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
@@ -741,94 +812,63 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
|
||||
// Migrate ui settings
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi: Record<string, unknown> = { ...uiSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
|
||||
const newUi = { ...uiSettings };
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
accessibilitySettings &&
|
||||
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
const newAccessibility: Record<string, unknown> = {
|
||||
...accessibilitySettings,
|
||||
};
|
||||
if (
|
||||
typeof accessibilitySettings['enableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = accessibilitySettings['disableLoadingPhrases'];
|
||||
newAccessibility['enableLoadingPhrases'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
}
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
if (accessibilitySettings) {
|
||||
const newAccessibility = { ...accessibilitySettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate context settings
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext: Record<string, unknown> = { ...contextSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
|
||||
const newContext = { ...contextSettings };
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
fileFilteringSettings &&
|
||||
typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean'
|
||||
) {
|
||||
const newFileFiltering: Record<string, unknown> = {
|
||||
...fileFilteringSettings,
|
||||
};
|
||||
if (typeof fileFilteringSettings['enableFuzzySearch'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = fileFilteringSettings['disableFuzzySearch'];
|
||||
newFileFiltering['enableFuzzySearch'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
}
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
if (fileFilteringSettings) {
|
||||
const newFileFiltering = { ...fileFilteringSettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
anyModified =
|
||||
migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
) || anyModified;
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.default,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
|
||||
@@ -1312,7 +1312,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Folder Trust',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import * as osActual from 'node:os';
|
||||
import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -26,6 +30,9 @@ import {
|
||||
isWorkspaceTrusted,
|
||||
resetTrustedFoldersForTesting,
|
||||
} from './trustedFolders.js';
|
||||
import { loadEnvironment, getSettingsSchema } from './settings.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
@@ -53,6 +60,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: vi.fn().mockImplementation((p) => p),
|
||||
};
|
||||
});
|
||||
vi.mock('strip-json-comments', () => ({
|
||||
@@ -60,22 +68,23 @@ vi.mock('strip-json-comments', () => ({
|
||||
}));
|
||||
|
||||
describe('Trusted Folders Loading', () => {
|
||||
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
|
||||
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockStripJsonComments = vi.mocked(stripJsonComments);
|
||||
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
|
||||
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
||||
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
||||
(jsonString: string) => jsonString,
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,13 +99,16 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
describe('isPathTrusted', () => {
|
||||
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(config);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
@@ -124,26 +136,62 @@ describe('Trusted Folders Loading', () => {
|
||||
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
|
||||
|
||||
// No explicit rule covers this file
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
|
||||
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('prioritizes the longest matching path (precedence)', () => {
|
||||
const { folders } = setup({
|
||||
config: {
|
||||
'/a': TrustLevel.TRUST_FOLDER,
|
||||
'/a/b': TrustLevel.DO_NOT_TRUST,
|
||||
'/a/b/c': TrustLevel.TRUST_FOLDER,
|
||||
'/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent
|
||||
'/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST,
|
||||
},
|
||||
});
|
||||
|
||||
// /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6).
|
||||
// /a/b/c wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/b/c/d')).toBe(true);
|
||||
|
||||
// /a/b/x matches /a (len 2), /a/b (len 4).
|
||||
// /a/b wins (DO_NOT_TRUST).
|
||||
expect(folders.isPathTrusted('/a/b/x')).toBe(false);
|
||||
|
||||
// /a/x matches /a (len 2).
|
||||
// /a wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/x')).toBe(true);
|
||||
|
||||
// Overlap with TRUST_PARENT
|
||||
// /parent/trustme/butnotthis/file matches:
|
||||
// - /parent/trustme (len 15, TRUST_PARENT -> effective /parent)
|
||||
// - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST)
|
||||
// /parent/trustme/butnotthis wins.
|
||||
expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// /parent/other matches /parent/trustme (len 15, effective /parent)
|
||||
expect(folders.isPathTrusted('/parent/other')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user rules if only user file exists', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -154,11 +202,15 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([]);
|
||||
@@ -171,14 +223,18 @@ describe('Trusted Folders Loading', () => {
|
||||
const customPath = '/custom/path/to/trusted_folders.json';
|
||||
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === customPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -221,14 +277,16 @@ describe('isWorkspaceTrusted', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,12 +299,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
it('should throw a fatal error if the config is malformed', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
// This mock needs to be specific to this test to override the one in beforeEach
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/Please fix the configuration file/,
|
||||
@@ -255,12 +315,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
|
||||
it('should throw a fatal error if the config is not a JSON object', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/not a valid JSON object/,
|
||||
@@ -303,10 +365,10 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for a child of an untrusted folder', () => {
|
||||
it('should return false for a child of an untrusted folder', () => {
|
||||
mockCwd = '/home/user/untrusted/src';
|
||||
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when no rules match', () => {
|
||||
@@ -316,12 +378,12 @@ describe('isWorkspaceTrusted', () => {
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize trust over distrust', () => {
|
||||
it('should prioritize specific distrust over parent trust', () => {
|
||||
mockCwd = '/home/user/projectA/untrusted';
|
||||
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
@@ -351,6 +413,19 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with IDE override', () => {
|
||||
const mockCwd = '/home/user/projectA';
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString().endsWith('trustedFolders.json') ? false : true,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ideContextStore.clear();
|
||||
@@ -390,10 +465,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
});
|
||||
|
||||
it('should fall back to config when ideTrust is undefined', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
|
||||
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
@@ -419,8 +499,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
describe('Trusted Folders Caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -454,14 +537,20 @@ describe('invalid trust levels', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) =>
|
||||
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -495,3 +584,235 @@ describe('invalid trust levels', () => {
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verification: Auth and Trust Interaction', () => {
|
||||
let mockCwd: string;
|
||||
const mockRules: Record<string, TrustLevel> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
if (p === path.resolve(mockCwd, '.env')) {
|
||||
return 'GEMINI_API_KEY=shhh-secret';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) =>
|
||||
p === getTrustedFoldersPath() || p === path.resolve(mockCwd, '.env'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
|
||||
});
|
||||
|
||||
it('should verify loadEnvironment returns early and validateAuthMethod fails when untrusted', () => {
|
||||
// 1. Mock untrusted workspace
|
||||
mockCwd = '/home/user/untrusted';
|
||||
mockRules[mockCwd] = TrustLevel.DO_NOT_TRUST;
|
||||
|
||||
// 2. Load environment (should return early)
|
||||
const settings = createMockSettings({
|
||||
security: { folderTrust: { enabled: true } },
|
||||
});
|
||||
loadEnvironment(settings.merged, mockCwd);
|
||||
|
||||
// 3. Verify env var NOT loaded
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('');
|
||||
|
||||
// 4. Verify validateAuthMethod fails
|
||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||
expect(result).toContain(
|
||||
'you must specify the GEMINI_API_KEY environment variable',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify if sandbox flag is available in Settings', () => {
|
||||
const schema = getSettingsSchema();
|
||||
expect(schema.tools.properties).toBeDefined();
|
||||
expect('sandbox' in schema.tools.properties).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trusted Folders realpath caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should only call fs.realpathSync once for the same path', () => {
|
||||
const mockPath = '/some/path';
|
||||
const mockRealPath = '/real/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockReturnValue(mockRealPath);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[mockPath]: TrustLevel.TRUST_FOLDER,
|
||||
'/another/path': TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// Call isPathTrusted multiple times with the same path
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
|
||||
// fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted)
|
||||
// And once for each rule in the config (if they are different)
|
||||
|
||||
// Let's check calls for mockPath
|
||||
const mockPathCalls = realpathSpy.mock.calls.filter(
|
||||
(call) => call[0] === mockPath,
|
||||
);
|
||||
|
||||
expect(mockPathCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should cache results for rule paths in the loop', () => {
|
||||
const rulePath = '/rule/path';
|
||||
const locationPath = '/location/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[rulePath]: TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// First call
|
||||
folders.isPathTrusted(locationPath);
|
||||
const firstCallCount = realpathSpy.mock.calls.length;
|
||||
expect(firstCallCount).toBe(2); // locationPath and rulePath
|
||||
|
||||
// Second call with same location and same config
|
||||
folders.isPathTrusted(locationPath);
|
||||
const secondCallCount = realpathSpy.mock.calls.length;
|
||||
|
||||
// Should still be 2 because both were cached
|
||||
expect(secondCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with Symlinks', () => {
|
||||
const mockSettings: Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is a symlink and rule is realpath', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath);
|
||||
|
||||
// Mock fs.existsSync to return true for trust config and both paths
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync to resolve symlink to realpath
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with realpath
|
||||
const mockRules = {
|
||||
[realPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is realpath and rule is a symlink', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(realPath);
|
||||
|
||||
// Mock fs.existsSync
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with symlink path
|
||||
const mockRules = {
|
||||
[symlinkPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,9 @@ export enum TrustLevel {
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(value: unknown): value is TrustLevel {
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
@@ -63,6 +65,32 @@ export interface TrustResult {
|
||||
source: 'ide' | 'file' | undefined;
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
*/
|
||||
export function clearRealPathCacheForTesting(): void {
|
||||
realPathCache.clear();
|
||||
}
|
||||
|
||||
function getRealPath(location: string): string {
|
||||
let realPath = realPathCache.get(location);
|
||||
if (realPath !== undefined) {
|
||||
return realPath;
|
||||
}
|
||||
|
||||
try {
|
||||
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
|
||||
} catch {
|
||||
realPath = location;
|
||||
}
|
||||
|
||||
realPathCache.set(location, realPath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export class LoadedTrustedFolders {
|
||||
constructor(
|
||||
readonly user: TrustedFoldersFile,
|
||||
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
|
||||
config?: Record<string, TrustLevel>,
|
||||
): boolean | undefined {
|
||||
const configToUse = config ?? this.user.config;
|
||||
const trustedPaths: string[] = [];
|
||||
const untrustedPaths: string[] = [];
|
||||
|
||||
for (const rule of Object.entries(configToUse).map(
|
||||
([path, trustLevel]) => ({ path, trustLevel }),
|
||||
)) {
|
||||
switch (rule.trustLevel) {
|
||||
case TrustLevel.TRUST_FOLDER:
|
||||
trustedPaths.push(rule.path);
|
||||
break;
|
||||
case TrustLevel.TRUST_PARENT:
|
||||
trustedPaths.push(path.dirname(rule.path));
|
||||
break;
|
||||
case TrustLevel.DO_NOT_TRUST:
|
||||
untrustedPaths.push(rule.path);
|
||||
break;
|
||||
default:
|
||||
// Do nothing for unknown trust levels.
|
||||
break;
|
||||
// Resolve location to its realpath for canonical comparison
|
||||
const realLocation = getRealPath(location);
|
||||
|
||||
let longestMatchLen = -1;
|
||||
let longestMatchTrust: TrustLevel | undefined = undefined;
|
||||
|
||||
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
|
||||
const effectivePath =
|
||||
trustLevel === TrustLevel.TRUST_PARENT
|
||||
? path.dirname(rulePath)
|
||||
: rulePath;
|
||||
|
||||
// Resolve effectivePath to its realpath for canonical comparison
|
||||
const realEffectivePath = getRealPath(effectivePath);
|
||||
|
||||
if (isWithinRoot(realLocation, realEffectivePath)) {
|
||||
if (rulePath.length > longestMatchLen) {
|
||||
longestMatchLen = rulePath.length;
|
||||
longestMatchTrust = trustLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const trustedPath of trustedPaths) {
|
||||
if (isWithinRoot(location, trustedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const untrustedPath of untrustedPaths) {
|
||||
if (path.normalize(location) === path.normalize(untrustedPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
@@ -161,11 +187,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
const userConfig: Record<string, TrustLevel> = {};
|
||||
|
||||
const userPath = getTrustedFoldersPath();
|
||||
// Load user trusted folders
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(stripJsonComments(content));
|
||||
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
@@ -222,7 +250,7 @@ export function saveTrustedFolders(
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false;
|
||||
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './deferred.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import { createMockSettings } from './test-utils/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||
@@ -46,14 +46,9 @@ describe('deferred', () => {
|
||||
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
|
||||
});
|
||||
|
||||
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
|
||||
({
|
||||
admin: adminSettings,
|
||||
}) as unknown as MergedSettings;
|
||||
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -66,7 +61,9 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: true } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: true } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
@@ -80,7 +77,9 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -98,7 +97,9 @@ describe('deferred', () => {
|
||||
commandName: 'extensions',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { extensions: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -116,7 +117,9 @@ describe('deferred', () => {
|
||||
commandName: 'skills',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { skills: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -135,7 +138,7 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({}); // No admin settings
|
||||
const settings = createMockSettings({}).merged; // No admin settings
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
@@ -163,7 +166,7 @@ describe('deferred', () => {
|
||||
expect(originalHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings());
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
@@ -181,7 +184,9 @@ describe('deferred', () => {
|
||||
const deferredMcp = defer(commandModule, 'mcp');
|
||||
await deferredMcp.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
const mcpSettings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -205,10 +210,14 @@ describe('deferred', () => {
|
||||
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
|
||||
// and defaulted to 'unknown' (or something else safe).
|
||||
const settings = createMockSettings({
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
});
|
||||
merged: {
|
||||
admin: {
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
|
||||
+292
-527
File diff suppressed because it is too large
Load Diff
+23
-10
@@ -25,6 +25,7 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
@@ -67,7 +68,7 @@ import {
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
@@ -740,13 +741,25 @@ export async function main() {
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
if (argv.ralphWiggum) {
|
||||
await runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise: argv.completionPromise,
|
||||
maxIterations: argv.maxIterations,
|
||||
});
|
||||
} else {
|
||||
await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
}
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
@@ -809,13 +822,13 @@ export function initializeOutputListenersAndFlush() {
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
let pendingSettings: FetchAdminControlsResponse | undefined;
|
||||
let pendingSettings: AdminControlsSettings | undefined;
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: FetchAdminControlsResponse;
|
||||
settings?: AdminControlsSettings;
|
||||
};
|
||||
if (message?.type === 'admin-settings' && message.settings) {
|
||||
if (config) {
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from './utils/errors.js';
|
||||
import { TextOutput } from './ui/utils/textOutput.js';
|
||||
|
||||
interface RunNonInteractiveParams {
|
||||
export interface RunNonInteractiveParams {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
input: string;
|
||||
@@ -55,13 +55,15 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
// Moved to ralphWiggum.ts
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
}: RunNonInteractiveParams): Promise<string> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
@@ -181,6 +183,9 @@ export async function runNonInteractive({
|
||||
}
|
||||
};
|
||||
|
||||
// Store accumulated response text to return
|
||||
let fullResponseText = '';
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
@@ -316,6 +321,13 @@ export async function runNonInteractive({
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
|
||||
// Accumulate full response
|
||||
if (event.value) {
|
||||
fullResponseText += event.value;
|
||||
responseText += output;
|
||||
}
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
@@ -325,7 +337,7 @@ export async function runNonInteractive({
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
// responseText is already updated
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
@@ -381,7 +393,7 @@ export async function runNonInteractive({
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
@@ -488,7 +500,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
@@ -512,7 +524,7 @@ export async function runNonInteractive({
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
return fullResponseText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -528,5 +540,6 @@ export async function runNonInteractive({
|
||||
if (errorToHandle) {
|
||||
handleError(errorToHandle, config);
|
||||
}
|
||||
return fullResponseText;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { runRalphWiggum } from './ralphWiggum.js';
|
||||
import * as nonInteractiveCli from './nonInteractiveCli.js';
|
||||
import fs from 'node:fs';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs');
|
||||
vi.mock('./nonInteractiveCli.js');
|
||||
|
||||
describe('runRalphWiggum', () => {
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockSettings = {} as unknown as LoadedSettings;
|
||||
const mockInput = 'Fix bugs';
|
||||
const mockPromptId = 'prompt-123';
|
||||
const mockResumedSessionData = {
|
||||
conversation: { messages: [], sessionId: 'session-123' },
|
||||
filePath: 'session.json',
|
||||
} as unknown as ResumedSessionData;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default mock implementation for fs
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('');
|
||||
});
|
||||
|
||||
it('should preserve resumedSessionData in second iteration', async () => {
|
||||
// Setup runNonInteractive to return "Failed" first, then "Success"
|
||||
const runNonInteractiveMock = vi.mocked(
|
||||
nonInteractiveCli.runNonInteractive,
|
||||
);
|
||||
runNonInteractiveMock
|
||||
.mockResolvedValueOnce('Test failed')
|
||||
.mockResolvedValueOnce('Test Success');
|
||||
|
||||
await runRalphWiggum({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: mockInput,
|
||||
prompt_id: mockPromptId,
|
||||
resumedSessionData: mockResumedSessionData,
|
||||
completionPromise: 'Success',
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should have resumedSessionData
|
||||
expect(runNonInteractiveMock.mock.calls[0][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
|
||||
// Second call should have resumedSessionData (FIXED)
|
||||
expect(runNonInteractiveMock.mock.calls[1][0].resumedSessionData).toBe(
|
||||
mockResumedSessionData,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
runNonInteractive,
|
||||
type RunNonInteractiveParams,
|
||||
} from './nonInteractiveCli.js';
|
||||
|
||||
interface IterationResult {
|
||||
iteration: number;
|
||||
status: 'Success' | 'Failed';
|
||||
testsPassed?: number;
|
||||
testsFailed?: number;
|
||||
testsTotal?: number;
|
||||
}
|
||||
|
||||
function extractTestStats(output: string): {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
total?: number;
|
||||
} {
|
||||
// Common patterns for test runners (Vitest, Jest, Mocha, etc.)
|
||||
const patterns = [
|
||||
// Vitest/Jest: "Tests: 3 passed, 1 failed, 4 total"
|
||||
/Tests:\s*(?:(\d+)\s+passed)?(?:,\s*)?(?:(\d+)\s+failed)?(?:,\s*)?(?:(\d+)\s+total)?/i,
|
||||
// Mocha: "3 passing (10ms)"
|
||||
/(\d+)\s+passing/i,
|
||||
// Mocha: "1 failing"
|
||||
/(\d+)\s+failing/i,
|
||||
// Generic: "Passed: 3, Failed: 1"
|
||||
/Passed:\s*(\d+)/i,
|
||||
/Failed:\s*(\d+)/i,
|
||||
];
|
||||
|
||||
let passed: number | undefined;
|
||||
let failed: number | undefined;
|
||||
let total: number | undefined;
|
||||
|
||||
// Try Vitest/Jest pattern first as it is most comprehensive
|
||||
const vitestMatch = output.match(patterns[0]);
|
||||
if (vitestMatch && (vitestMatch[1] || vitestMatch[2] || vitestMatch[3])) {
|
||||
passed = vitestMatch[1] ? parseInt(vitestMatch[1], 10) : 0;
|
||||
failed = vitestMatch[2] ? parseInt(vitestMatch[2], 10) : 0;
|
||||
total = vitestMatch[3] ? parseInt(vitestMatch[3], 10) : 0;
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
// Fallback to individual patterns
|
||||
const passingMatch = output.match(patterns[1]);
|
||||
if (passingMatch) {
|
||||
passed = parseInt(passingMatch[1], 10);
|
||||
} else {
|
||||
const passedMatch = output.match(patterns[3]);
|
||||
if (passedMatch) passed = parseInt(passedMatch[1], 10);
|
||||
}
|
||||
|
||||
const failingMatch = output.match(patterns[2]);
|
||||
if (failingMatch) {
|
||||
failed = parseInt(failingMatch[1], 10);
|
||||
} else {
|
||||
const failedMatch = output.match(patterns[4]);
|
||||
if (failedMatch) failed = parseInt(failedMatch[1], 10);
|
||||
}
|
||||
|
||||
return { passed, failed, total };
|
||||
}
|
||||
|
||||
function printSummary(results: IterationResult[]) {
|
||||
process.stderr.write('\n--- Ralph Wiggum Mode Summary ---\n');
|
||||
process.stderr.write(
|
||||
'| Iteration | Status | Tests Passed | Tests Failed |\n',
|
||||
);
|
||||
process.stderr.write(
|
||||
'|-----------|---------|--------------|--------------|\n',
|
||||
);
|
||||
for (const result of results) {
|
||||
const passed = result.testsPassed !== undefined ? result.testsPassed : '-';
|
||||
const failed = result.testsFailed !== undefined ? result.testsFailed : '-';
|
||||
process.stderr.write(
|
||||
`| ${result.iteration.toString().padEnd(9)} | ${result.status.padEnd(7)} | ${passed.toString().padEnd(12)} | ${failed.toString().padEnd(12)} |\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write('---------------------------------\n\n');
|
||||
}
|
||||
|
||||
export async function runRalphWiggum({
|
||||
config,
|
||||
settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
completionPromise,
|
||||
maxIterations,
|
||||
memoryFile,
|
||||
}: RunNonInteractiveParams & {
|
||||
completionPromise?: string;
|
||||
maxIterations?: number;
|
||||
memoryFile?: string;
|
||||
}): Promise<void> {
|
||||
const effectiveMaxIterations = maxIterations ?? 10;
|
||||
let iterations = 0;
|
||||
const currentResumedSessionData = resumedSessionData;
|
||||
const results: IterationResult[] = [];
|
||||
const effectiveMemoryFile = memoryFile || 'memories.md';
|
||||
const memoriesPath = path.join(process.cwd(), effectiveMemoryFile);
|
||||
|
||||
if (!fs.existsSync(memoriesPath)) {
|
||||
fs.writeFileSync(
|
||||
memoriesPath,
|
||||
`# Ralph Wiggum Memories\n\nTask: ${input}\n\nUse this file (${effectiveMemoryFile}) to store notes on what worked and what didn't work across iterations. The agent will read this at the start of each run.\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Starting loop. Max iterations: ${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
while (iterations < effectiveMaxIterations) {
|
||||
iterations++;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Iteration ${iterations}/${effectiveMaxIterations}\n`,
|
||||
);
|
||||
|
||||
let currentInput = input;
|
||||
try {
|
||||
if (fs.existsSync(memoriesPath)) {
|
||||
const memories = fs.readFileSync(memoriesPath, 'utf-8');
|
||||
if (memories.trim()) {
|
||||
currentInput = `Context from previous iterations (${effectiveMemoryFile}):\n${memories}\n\nTask:\n${input}`;
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Loaded context from ${effectiveMemoryFile}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Failed to read ${effectiveMemoryFile}: ${error}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = await runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
input: currentInput,
|
||||
prompt_id,
|
||||
resumedSessionData: currentResumedSessionData,
|
||||
});
|
||||
|
||||
const stats = extractTestStats(output);
|
||||
const success =
|
||||
completionPromise && output.includes(completionPromise) ? true : false;
|
||||
|
||||
results.push({
|
||||
iteration: iterations,
|
||||
status: success ? 'Success' : 'Failed',
|
||||
testsPassed: stats.passed,
|
||||
testsFailed: stats.failed,
|
||||
testsTotal: stats.total,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Completion promise "${completionPromise}" met. Exiting.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
return;
|
||||
}
|
||||
|
||||
// currentResumedSessionData = undefined; // Fixed: Keep resumedSessionData for subsequent iterations
|
||||
}
|
||||
process.stderr.write(
|
||||
`[Ralph Wiggum] Max iterations reached without meeting completion promise.\n`,
|
||||
);
|
||||
printSummary(results);
|
||||
}
|
||||
@@ -98,6 +98,17 @@ vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
vi.mock('../ui/commands/skillsCommand.js', () => ({
|
||||
skillsCommand: { name: 'skills' },
|
||||
}));
|
||||
vi.mock('../ui/commands/planCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
planCommand: {
|
||||
name: 'plan',
|
||||
description: 'Plan command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: {
|
||||
name: 'mcp',
|
||||
@@ -115,6 +126,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
getEnableHooksUI: () => false,
|
||||
@@ -216,6 +228,22 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(agentsCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include plan command when plan mode is enabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(true);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude plan command when plan mode is disabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude agents command when agents are disabled', async () => {
|
||||
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
@@ -256,6 +284,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
vi.resetModules();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
|
||||
@@ -40,8 +40,9 @@ import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
@@ -142,8 +143,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
privacyCommand,
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings, Settings } from '../config/settings.js';
|
||||
import { createTestMergedSettings } from '../config/settings.js';
|
||||
|
||||
/**
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getPolicyEngine: vi.fn(() => ({})),
|
||||
getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })),
|
||||
getHookSystem: vi.fn(() => ({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
setSessionId: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||
getExperimentalZedIntegration: vi.fn(() => false),
|
||||
isBrowserLaunchSuppressed: vi.fn(() => false),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
isEventDrivenSchedulerEnabled: vi.fn(() => false),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getDeferredCommand: vi.fn(() => undefined),
|
||||
getFileSystemService: vi.fn(() => ({})),
|
||||
clientVersion: '1.0.0',
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTools: vi.fn().mockReturnValue([]),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({}),
|
||||
getPromptRegistry: vi.fn().mockReturnValue({}),
|
||||
getResourceRegistry: vi.fn().mockReturnValue({}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
isAdminEnabled: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileService: vi.fn().mockReturnValue({}),
|
||||
getGitService: vi.fn().mockResolvedValue({}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
|
||||
getShowMemoryUsage: vi.fn().mockReturnValue(false),
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
|
||||
getTelemetryTarget: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOutfile: vi.fn().mockReturnValue(undefined),
|
||||
getTelemetryUseCollector: vi.fn().mockReturnValue(false),
|
||||
getTelemetryUseCliAuth: vi.fn().mockReturnValue(false),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined),
|
||||
getModelRouterService: vi.fn().mockReturnValue({}),
|
||||
getModelAvailabilityService: vi.fn().mockReturnValue({}),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getCustomExcludes: vi.fn().mockReturnValue([]),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getBugCommand: vi.fn().mockReturnValue(undefined),
|
||||
getExtensionManagement: vi.fn().mockReturnValue(true),
|
||||
getExtensionLoader: vi.fn().mockReturnValue({}),
|
||||
getEnabledExtensions: vi.fn().mockReturnValue([]),
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
getDisableLLMCorrection: vi.fn().mockReturnValue(false),
|
||||
getNoBrowser: vi.fn().mockReturnValue(false),
|
||||
getAgentsSettings: vi.fn().mockReturnValue({}),
|
||||
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
|
||||
reloadSkills: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAgents: vi.fn().mockResolvedValue(undefined),
|
||||
getUseRipgrep: vi.fn().mockReturnValue(false),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
|
||||
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
|
||||
getUseWriteTodos: vi.fn().mockReturnValue(false),
|
||||
getFileExclusions: vi.fn().mockReturnValue({}),
|
||||
getEnableHooks: vi.fn().mockReturnValue(true),
|
||||
getEnableHooksUI: vi.fn().mockReturnValue(true),
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi.fn().mockReturnValue(''),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(100),
|
||||
getExcludeTools: vi.fn().mockReturnValue(new Set()),
|
||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
/**
|
||||
* Creates a mocked LoadedSettings object for tests.
|
||||
*/
|
||||
export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { act, useState } from 'react';
|
||||
import os from 'node:os';
|
||||
import { LoadedSettings, type Settings } from '../config/settings.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||
@@ -32,6 +32,7 @@ import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
||||
import { createMockSettings } from './settings.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
@@ -135,20 +136,6 @@ export const mockSettings = new LoadedSettings(
|
||||
[],
|
||||
);
|
||||
|
||||
export const createMockSettings = (
|
||||
overrides: Partial<Settings>,
|
||||
): LoadedSettings => {
|
||||
const settings = overrides as Settings;
|
||||
return new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings, originalSettings: settings },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
// A minimal mock UIState to satisfy the context provider.
|
||||
// Tests that need specific UIState values should provide their own.
|
||||
const baseMockUiState = {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
LoadedSettings,
|
||||
createTestMergedSettings,
|
||||
type SettingsError,
|
||||
} from '../config/settings.js';
|
||||
|
||||
export interface MockSettingsFile {
|
||||
settings: any;
|
||||
originalSettings: any;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CreateMockSettingsOptions {
|
||||
system?: MockSettingsFile;
|
||||
systemDefaults?: MockSettingsFile;
|
||||
user?: MockSettingsFile;
|
||||
workspace?: MockSettingsFile;
|
||||
isTrusted?: boolean;
|
||||
errors?: SettingsError[];
|
||||
merged?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock LoadedSettings object for testing.
|
||||
*
|
||||
* @param overrides - Partial settings or LoadedSettings properties to override.
|
||||
* If 'merged' is provided, it overrides the computed merged settings.
|
||||
* Any functions in overrides are assigned directly to the LoadedSettings instance.
|
||||
*/
|
||||
export const createMockSettings = (
|
||||
overrides: CreateMockSettingsOptions = {},
|
||||
): LoadedSettings => {
|
||||
const {
|
||||
system,
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
|
||||
const loaded = new LoadedSettings(
|
||||
(system as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
(user as any) || {
|
||||
path: '',
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
);
|
||||
|
||||
if (mergedOverride) {
|
||||
// @ts-expect-error - overriding private field for testing
|
||||
loaded._merged = createTestMergedSettings(mergedOverride);
|
||||
}
|
||||
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
return loaded;
|
||||
};
|
||||
@@ -21,7 +21,6 @@ import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
|
||||
import { MessageType } from './types.js';
|
||||
import {
|
||||
type Config,
|
||||
makeFakeConfig,
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
type ContentGeneratorConfig,
|
||||
type AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -47,11 +44,6 @@ const mockIdeClient = vi.hoisted(() => ({
|
||||
getInstance: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
}));
|
||||
|
||||
// Mock UserAccountManager
|
||||
const mockUserAccountManager = vi.hoisted(() => ({
|
||||
getCachedGoogleAccount: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// Mock stdout
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockStdout: { write: vi.fn() },
|
||||
@@ -81,9 +73,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
})),
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
UserAccountManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockUserAccountManager),
|
||||
FileDiscoveryService: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
@@ -428,7 +417,6 @@ describe('AppContainer State Management', () => {
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: false,
|
||||
hideWindowTitle: false,
|
||||
showUserIdentity: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
@@ -500,162 +488,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication Check', () => {
|
||||
it('displays correct message for LOGIN_WITH_GOOGLE auth type', async () => {
|
||||
// Explicitly mock implementation to ensure we control the instance
|
||||
(UserAccountManager as unknown as Mock).mockImplementation(
|
||||
() => mockUserAccountManager,
|
||||
);
|
||||
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
|
||||
'test@example.com',
|
||||
);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
// Explicitly enable showUserIdentity
|
||||
mockSettings.merged.ui = {
|
||||
...mockSettings.merged.ui,
|
||||
showUserIdentity: true,
|
||||
};
|
||||
|
||||
// Need to ensure config.getContentGeneratorConfig() returns appropriate authType
|
||||
const authConfig = makeFakeConfig();
|
||||
// Mock getTargetDir as well since makeFakeConfig might not set it up fully for the component
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserAccountManager).toHaveBeenCalled();
|
||||
expect(
|
||||
mockUserAccountManager.getCachedGoogleAccount,
|
||||
).toHaveBeenCalled();
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'Logged in with Google: test@example.com (Plan: Standard Tier)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
it('displays correct message for USE_GEMINI auth type', async () => {
|
||||
// Explicitly mock implementation to ensure we control the instance
|
||||
(UserAccountManager as unknown as Mock).mockImplementation(
|
||||
() => mockUserAccountManager,
|
||||
);
|
||||
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(null);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
const authConfig = makeFakeConfig();
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_GEMINI,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Authenticated with gemini-api-key'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display authentication message if showUserIdentity is false', async () => {
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
|
||||
'test@example.com',
|
||||
);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
mockSettings.merged.ui = {
|
||||
...mockSettings.merged.ui,
|
||||
showUserIdentity: false,
|
||||
};
|
||||
|
||||
const authConfig = makeFakeConfig();
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
// Give it some time to potentially call addItem
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Providers', () => {
|
||||
it('provides AppContext with correct values', async () => {
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
getErrorMessage,
|
||||
getAllGeminiMdFilenames,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
clearCachedCredentialFile,
|
||||
type ResumedSessionData,
|
||||
recordExitFail,
|
||||
@@ -191,51 +190,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const historyManager = useHistory({
|
||||
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
|
||||
});
|
||||
const { addItem } = historyManager;
|
||||
|
||||
const authCheckPerformed = useRef(false);
|
||||
useEffect(() => {
|
||||
if (authCheckPerformed.current) return;
|
||||
authCheckPerformed.current = true;
|
||||
|
||||
if (resumedSessionData || settings.merged.ui.showUserIdentity === false) {
|
||||
return;
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Run this asynchronously to avoid blocking the event loop.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
try {
|
||||
const userAccountManager = new UserAccountManager();
|
||||
const email = userAccountManager.getCachedGoogleAccount();
|
||||
const tierName = config.getUserTierName();
|
||||
|
||||
if (authType) {
|
||||
let message =
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
? email
|
||||
? `Logged in with Google: ${email}`
|
||||
: 'Logged in with Google'
|
||||
: `Authenticated with ${authType}`;
|
||||
if (tierName) {
|
||||
message += ` (Plan: ${tierName})`;
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors during initial auth check
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
config,
|
||||
resumedSessionData,
|
||||
settings.merged.ui.showUserIdentity,
|
||||
addItem,
|
||||
]);
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -270,7 +224,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const activeHooks = useHookDisplayState();
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
() => isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||
|
||||
@@ -86,6 +86,11 @@ describe('directoryCommand', () => {
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { planCommand } from './planCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
processSingleFileContent,
|
||||
type ProcessedFileReadResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
processSingleFileContent: vi.fn(),
|
||||
partToString: vi.fn((val) => val),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual },
|
||||
join: vi.fn((...args) => args.join('/')),
|
||||
};
|
||||
});
|
||||
|
||||
describe('planCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
isPlanEnabled: vi.fn(),
|
||||
setApprovalMode: vi.fn(),
|
||||
getApprovedPlanPath: vi.fn(),
|
||||
getApprovalMode: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(planCommand.name).toBe('plan');
|
||||
expect(planCommand.description).toBe(
|
||||
'Switch to Plan Mode and view current plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch to plan mode if enabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Switched to Plan Mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the approved plan from config', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
mockPlanPath,
|
||||
);
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: '# Approved Plan Content',
|
||||
returnDisplay: '# Approved Plan Content',
|
||||
} as ProcessedFileReadResult);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Approved Plan: approved-plan.md',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.GEMINI,
|
||||
text: '# Approved Plan Content',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
processSingleFileContent,
|
||||
partToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const planCommand: SlashCommand = {
|
||||
name: 'plan',
|
||||
description: 'Switch to Plan Mode and view current plan',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
debugLogger.debug('Plan command: config is not available in context');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousApprovalMode = config.getApprovalMode();
|
||||
config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
if (previousApprovalMode !== ApprovalMode.PLAN) {
|
||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||
}
|
||||
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!approvedPlanPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.GEMINI,
|
||||
text: partToString(content.llmContent),
|
||||
});
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
logRewind: vi.fn(),
|
||||
RewindEvent: class {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
checkExhaustive,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
logRewind,
|
||||
RewindEvent,
|
||||
type ChatRecordingService,
|
||||
type GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -144,6 +146,9 @@ export const rewindCommand: SlashCommand = {
|
||||
context.ui.removeComponent();
|
||||
}}
|
||||
onRewind={async (messageId, newText, outcome) => {
|
||||
if (outcome !== RewindOutcome.Cancel) {
|
||||
logRewind(config, new RewindEvent(outcome));
|
||||
}
|
||||
switch (outcome) {
|
||||
case RewindOutcome.Cancel:
|
||||
context.ui.removeComponent();
|
||||
|
||||
@@ -17,6 +17,27 @@ import {
|
||||
type MergedSettings,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/skillUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
linkSkill: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extensions/consent.js')>();
|
||||
return {
|
||||
...actual,
|
||||
requestConsentInteractive: vi.fn().mockResolvedValue(true),
|
||||
skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'),
|
||||
};
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
@@ -185,6 +206,80 @@ describe('skillsCommand', () => {
|
||||
expect(lastCall.skills).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('should link a skill successfully', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Successfully linked skills from "/some/path" (user).',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should link a skill with workspace scope', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/path' },
|
||||
]);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
expect(linkSkill).toHaveBeenCalledWith(
|
||||
'/some/path',
|
||||
'workspace',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await linkCmd.action!(context, '/some/path');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to link skills: Link failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if path is missing', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
await linkCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable/enable', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
|
||||
@@ -16,10 +16,18 @@ 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 { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
import {
|
||||
linkSkill,
|
||||
renderSkillActionFeedback,
|
||||
} from '../../utils/skillUtils.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
@@ -68,6 +76,69 @@ async function listAction(
|
||||
context.ui.addItem(skillsListItem);
|
||||
}
|
||||
|
||||
async function linkAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const sourcePath = parts[0];
|
||||
|
||||
if (!sourcePath) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /skills link <path> [--scope user|workspace]',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let scopeArg = 'user';
|
||||
if (parts.length >= 3 && parts[1] === '--scope') {
|
||||
scopeArg = parts[2];
|
||||
} else if (parts.length >= 2 && parts[1].startsWith('--scope=')) {
|
||||
scopeArg = parts[1].split('=')[1];
|
||||
}
|
||||
|
||||
const scope = scopeArg === 'workspace' ? 'workspace' : 'user';
|
||||
|
||||
try {
|
||||
await linkSkill(
|
||||
sourcePath,
|
||||
scope,
|
||||
(msg) =>
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: msg,
|
||||
}),
|
||||
async (skills, targetDir) => {
|
||||
const consentString = await skillsConsentString(
|
||||
skills,
|
||||
sourcePath,
|
||||
targetDir,
|
||||
true,
|
||||
);
|
||||
return requestConsentInteractive(
|
||||
consentString,
|
||||
context.ui.setConfirmationRequest.bind(context.ui),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully linked skills from "${sourcePath}" (${scope}).`,
|
||||
});
|
||||
|
||||
if (context.services.config) {
|
||||
await context.services.config.reloadSkills();
|
||||
}
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to link skills: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
@@ -301,6 +372,13 @@ export const skillsCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
description:
|
||||
'Link an agent skill from a local path. Usage: /skills link <path> [--scope user|workspace]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: linkAction,
|
||||
},
|
||||
{
|
||||
name: 'disable',
|
||||
description: 'Disable a skill by name. Usage: /skills disable <name>',
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface CommandContext {
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
/**
|
||||
* Sets a confirmation request to be displayed to the user.
|
||||
*
|
||||
* @param value The confirmation request details.
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { UserIdentity } from './UserIdentity.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
@@ -40,6 +41,9 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
// Helper to write to stdin with proper act() wrapping
|
||||
@@ -941,6 +942,125 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown rendering', () => {
|
||||
it('auto-bolds plain single-line questions', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Which option do you prefer?',
|
||||
header: 'Test',
|
||||
options: [{ label: 'Yes', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={40}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Plain text should be rendered as bold
|
||||
expect(frame).toContain(chalk.bold('Which option do you prefer?'));
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-bold questions that already have markdown', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Is **this** working?',
|
||||
header: 'Test',
|
||||
options: [{ label: 'Yes', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={40}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Should NOT have double-bold (the whole question bolded AND "this" bolded)
|
||||
// "Is " should not be bold, only "this" should be bold
|
||||
expect(frame).toContain('Is ');
|
||||
expect(frame).toContain(chalk.bold('this'));
|
||||
expect(frame).not.toContain('**this**');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders bold markdown in question', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Is **this** working?',
|
||||
header: 'Test',
|
||||
options: [{ label: 'Yes', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={40}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Check for chalk.bold('this') - asterisks should be gone, text should be bold
|
||||
expect(frame).toContain(chalk.bold('this'));
|
||||
expect(frame).not.toContain('**this**');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders inline code markdown in question', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Run `npm start`?',
|
||||
header: 'Test',
|
||||
options: [{ label: 'Yes', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={40}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// Backticks should be removed
|
||||
expect(frame).toContain('npm start');
|
||||
expect(frame).not.toContain('`npm start`');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
|
||||
@@ -27,10 +27,60 @@ import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
/** Padding for dialog content to prevent text from touching edges. */
|
||||
const DIALOG_PADDING = 4;
|
||||
|
||||
/**
|
||||
* Checks if text is a single line without markdown identifiers.
|
||||
*/
|
||||
function isPlainSingleLine(text: string): boolean {
|
||||
// Must be a single line (no newlines)
|
||||
if (text.includes('\n') || text.includes('\r')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for common markdown identifiers
|
||||
const markdownPatterns = [
|
||||
/^#{1,6}\s/, // Headers
|
||||
/^[`~]{3,}/, // Code fences
|
||||
/^[-*+]\s/, // Unordered lists
|
||||
/^\d+\.\s/, // Ordered lists
|
||||
/^[-*_]{3,}$/, // Horizontal rules
|
||||
/\|/, // Tables
|
||||
/\*\*|__/, // Bold
|
||||
/(?<!\*)\*(?!\*)/, // Italic (single asterisk not part of bold)
|
||||
/(?<!_)_(?!_)/, // Italic (single underscore not part of bold)
|
||||
/`[^`]+`/, // Inline code
|
||||
/\[.*?\]\(.*?\)/, // Links
|
||||
/!\[/, // Images
|
||||
];
|
||||
|
||||
for (const pattern of markdownPatterns) {
|
||||
if (pattern.test(text)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-bolds plain single-line text by wrapping in **.
|
||||
* Returns the text unchanged if it already contains markdown.
|
||||
*/
|
||||
function autoBoldIfPlain(text: string): string {
|
||||
if (isPlainSingleLine(text)) {
|
||||
return `**${text}**`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
@@ -303,9 +353,11 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
@@ -734,7 +786,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
: undefined;
|
||||
const questionHeight =
|
||||
listHeight && !isAlternateBuffer
|
||||
? Math.min(15, Math.max(1, listHeight - 4))
|
||||
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
|
||||
: undefined;
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeight
|
||||
@@ -750,15 +802,18 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={autoBoldIfPlain(question.question)}
|
||||
terminalWidth={availableWidth - DIALOG_PADDING}
|
||||
isPending={false}
|
||||
/>
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
@@ -833,7 +888,10 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
{optionItem.description && (
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{' '}
|
||||
{optionItem.description}
|
||||
<RenderInline
|
||||
text={optionItem.description}
|
||||
defaultColor={theme.text.secondary}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
@@ -24,7 +25,6 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { mergeSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
@@ -168,21 +168,6 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
return {
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
ui: {
|
||||
...defaultMergedSettings.ui,
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
@@ -207,7 +192,7 @@ describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
const settings = createMockSettings({ ui: { hideFooter: false } });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
@@ -216,7 +201,7 @@ describe('Composer', () => {
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
const settings = createMockSettings({ ui: { hideFooter: true } });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
@@ -245,8 +230,10 @@ describe('Composer', () => {
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
ui: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
|
||||
@@ -32,11 +32,12 @@ vi.mock('node:process', async () => {
|
||||
describe('FolderTrustDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
});
|
||||
|
||||
it('should render the dialog with title and description', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
|
||||
@@ -44,11 +45,12 @@ describe('FolderTrustDialog', () => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Trusting a folder allows Gemini to execute commands it suggests.',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
|
||||
);
|
||||
|
||||
@@ -67,24 +69,27 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
});
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display restart message when isRestarting is true', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Gemini CLI is restarting');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call relaunchApp when isRestarting is true', async () => {
|
||||
vi.useFakeTimers();
|
||||
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
|
||||
renderWithProviders(
|
||||
const { unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(relaunchApp).toHaveBeenCalled();
|
||||
unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -96,9 +101,7 @@ describe('FolderTrustDialog', () => {
|
||||
);
|
||||
|
||||
// Unmount immediately (before 250ms)
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
unmount();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(relaunchApp).not.toHaveBeenCalled();
|
||||
@@ -106,7 +109,7 @@ describe('FolderTrustDialog', () => {
|
||||
});
|
||||
|
||||
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
|
||||
);
|
||||
|
||||
@@ -117,31 +120,35 @@ describe('FolderTrustDialog', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('directory display', () => {
|
||||
it('should correctly display the folder name for a nested directory', () => {
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust folder (project)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly display the parent folder name for a nested directory', () => {
|
||||
mockedCwd.mockReturnValue('/home/user/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust parent folder (user)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly display an empty parent folder name for a directory directly under root', () => {
|
||||
mockedCwd.mockReturnValue('/project');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Trust parent folder ()');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act, useState } from 'react';
|
||||
import type { InputPromptProps } from './InputPrompt.js';
|
||||
|
||||
@@ -26,6 +26,7 @@ import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
@@ -58,56 +59,6 @@ enum TerminalKeys {
|
||||
BACKSPACE = '\u0008',
|
||||
}
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
systemSettings = {},
|
||||
workspaceSettings = {},
|
||||
) =>
|
||||
new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...systemSettings,
|
||||
},
|
||||
path: '/system/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
path: '/system/system-defaults.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...userSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...userSettings,
|
||||
},
|
||||
path: '/user/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...workspaceSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...workspaceSettings,
|
||||
},
|
||||
path: '/workspace/settings.json',
|
||||
},
|
||||
true,
|
||||
[],
|
||||
);
|
||||
|
||||
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../../config/settingsSchema.js')>();
|
||||
@@ -639,11 +590,23 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
it('should show different values for different scopes', () => {
|
||||
const settings = createMockSettings(
|
||||
{ vimMode: true }, // User settings
|
||||
{ vimMode: false }, // System settings
|
||||
{ autoUpdate: false }, // Workspace settings
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
user: {
|
||||
settings: { vimMode: true },
|
||||
originalSettings: { vimMode: true },
|
||||
path: '',
|
||||
},
|
||||
system: {
|
||||
settings: { vimMode: false },
|
||||
originalSettings: { vimMode: false },
|
||||
path: '',
|
||||
},
|
||||
workspace: {
|
||||
settings: { autoUpdate: false },
|
||||
originalSettings: { autoUpdate: false },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = renderDialog(settings, onSelect);
|
||||
@@ -733,11 +696,23 @@ describe('SettingsDialog', () => {
|
||||
|
||||
describe('Specific Settings Behavior', () => {
|
||||
it('should show correct display values for settings with different states', () => {
|
||||
const settings = createMockSettings(
|
||||
{ vimMode: true, hideTips: false }, // User settings
|
||||
{ hideWindowTitle: true }, // System settings
|
||||
{ ideMode: false }, // Workspace settings
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
user: {
|
||||
settings: { vimMode: true, hideTips: false },
|
||||
originalSettings: { vimMode: true, hideTips: false },
|
||||
path: '',
|
||||
},
|
||||
system: {
|
||||
settings: { hideWindowTitle: true },
|
||||
originalSettings: { hideWindowTitle: true },
|
||||
path: '',
|
||||
},
|
||||
workspace: {
|
||||
settings: { ideMode: false },
|
||||
originalSettings: { ideMode: false },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = renderDialog(settings, onSelect);
|
||||
@@ -794,11 +769,13 @@ describe('SettingsDialog', () => {
|
||||
|
||||
describe('Settings Display Values', () => {
|
||||
it('should show correct values for inherited settings', () => {
|
||||
const settings = createMockSettings(
|
||||
{},
|
||||
{ vimMode: true, hideWindowTitle: false }, // System settings
|
||||
{},
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
system: {
|
||||
settings: { vimMode: true, hideWindowTitle: false },
|
||||
originalSettings: { vimMode: true, hideWindowTitle: false },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = renderDialog(settings, onSelect);
|
||||
@@ -809,11 +786,18 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
it('should show override indicator for overridden settings', () => {
|
||||
const settings = createMockSettings(
|
||||
{ vimMode: false }, // User overrides
|
||||
{ vimMode: true }, // System default
|
||||
{},
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
user: {
|
||||
settings: { vimMode: false },
|
||||
originalSettings: { vimMode: false },
|
||||
path: '',
|
||||
},
|
||||
system: {
|
||||
settings: { vimMode: true },
|
||||
originalSettings: { vimMode: true },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = renderDialog(settings, onSelect);
|
||||
@@ -983,11 +967,13 @@ describe('SettingsDialog', () => {
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle malformed settings gracefully', () => {
|
||||
// Create settings with potentially problematic values
|
||||
const settings = createMockSettings(
|
||||
{ vimMode: null as unknown as boolean }, // Invalid value
|
||||
{},
|
||||
{},
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
user: {
|
||||
settings: { vimMode: null as unknown as boolean },
|
||||
originalSettings: { vimMode: null as unknown as boolean },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame } = renderDialog(settings, onSelect);
|
||||
@@ -1198,11 +1184,13 @@ describe('SettingsDialog', () => {
|
||||
stdin.write('\r'); // Commit
|
||||
});
|
||||
|
||||
settings = createMockSettings(
|
||||
{ 'a.string.setting': 'new value' },
|
||||
{},
|
||||
{},
|
||||
);
|
||||
settings = createMockSettings({
|
||||
user: {
|
||||
settings: { 'a.string.setting': 'new value' },
|
||||
originalSettings: { 'a.string.setting': 'new value' },
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
@@ -1550,11 +1538,23 @@ describe('SettingsDialog', () => {
|
||||
])(
|
||||
'should render $name correctly',
|
||||
({ userSettings, systemSettings, workspaceSettings, stdinActions }) => {
|
||||
const settings = createMockSettings(
|
||||
userSettings,
|
||||
systemSettings,
|
||||
workspaceSettings,
|
||||
);
|
||||
const settings = createMockSettings({
|
||||
user: {
|
||||
settings: userSettings,
|
||||
originalSettings: userSettings,
|
||||
path: '',
|
||||
},
|
||||
system: {
|
||||
settings: systemSettings,
|
||||
originalSettings: systemSettings,
|
||||
path: '',
|
||||
},
|
||||
workspace: {
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceSettings,
|
||||
path: '',
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { lastFrame, stdin } = renderDialog(settings, onSelect);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
// Mock child components to simplify testing
|
||||
@@ -65,14 +66,6 @@ const createMockConfig = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hooksConfig: { notifications: true },
|
||||
ui: { hideContextSummary: false },
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderStatusDisplay = (
|
||||
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
||||
|
||||
@@ -8,52 +8,10 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
|
||||
import { act } from 'react';
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
workspaceSettings = {},
|
||||
systemSettings = {},
|
||||
): LoadedSettings =>
|
||||
new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, ...systemSettings },
|
||||
originalSettings: { ui: { customThemes: {} }, ...systemSettings },
|
||||
path: '/system/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
path: '/system/system-defaults.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
...userSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
...userSettings,
|
||||
},
|
||||
path: '/user/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
...workspaceSettings,
|
||||
},
|
||||
originalSettings: {
|
||||
ui: { customThemes: {} },
|
||||
...workspaceSettings,
|
||||
},
|
||||
path: '/workspace/settings.json',
|
||||
},
|
||||
true,
|
||||
[],
|
||||
);
|
||||
|
||||
describe('ThemeDialog Snapshots', () => {
|
||||
const baseProps = {
|
||||
onSelect: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { UserIdentity } from './UserIdentity.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
type ContentGeneratorConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock UserAccountManager to control cached account
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
UserAccountManager: vi.fn().mockImplementation(() => ({
|
||||
getCachedGoogleAccount: () => 'test@example.com',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<UserIdentity />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render login message and auth indicator', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render login message without colon if email is missing', () => {
|
||||
// Modify the mock for this specific test
|
||||
vi.mocked(UserAccountManager).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
getCachedGoogleAccount: () => undefined,
|
||||
}) as unknown as UserAccountManager,
|
||||
);
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google');
|
||||
expect(output).not.toContain('Logged in with Google:');
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render plan name on a separate line if provided', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Premium Plan');
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Logged in with Google: test@example.com');
|
||||
expect(output).toContain('/auth');
|
||||
expect(output).toContain('Plan: Premium Plan');
|
||||
|
||||
// Check for two lines (or more if wrapped, but here it should be separate)
|
||||
const lines = output?.split('\n').filter((line) => line.trim().length > 0);
|
||||
expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render if authType is missing', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue(
|
||||
{} as unknown as ContentGeneratorConfig,
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render non-Google auth message', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_GEMINI,
|
||||
model: 'gemini-pro',
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<UserIdentity config={mockConfig} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
|
||||
expect(output).toContain('/auth');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
type Config,
|
||||
UserAccountManager,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
interface UserIdentityProps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
const { email, tierName } = useMemo(() => {
|
||||
if (!authType) {
|
||||
return { email: undefined, tierName: undefined };
|
||||
}
|
||||
const userAccountManager = new UserAccountManager();
|
||||
return {
|
||||
email: userAccountManager.getCachedGoogleAccount(),
|
||||
tierName: config.getUserTierName(),
|
||||
};
|
||||
}, [config, authType]);
|
||||
|
||||
if (!authType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
|
||||
<Text>
|
||||
<Text bold>Logged in with Google{email ? ':' : ''}</Text>
|
||||
{email ? ` ${email}` : ''}
|
||||
</Text>
|
||||
) : (
|
||||
`Authenticated with ${authType}`
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> /auth</Text>
|
||||
</Box>
|
||||
{tierName && (
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold>Plan:</Text> {tierName}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -27,21 +27,21 @@ Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -55,20 +55,20 @@ Enter to submit · Esc to cancel"
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > handles long plan content appropriately 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Implement a comprehensive authentication system with multiple providers.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
|
||||
5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
|
||||
6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
|
||||
7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
|
||||
8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts
|
||||
5. Add SAML provider support in src/auth/providers/SAMLProvider.ts
|
||||
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
|
||||
7. Create token refresh mechanism in src/auth/TokenManager.ts
|
||||
8. Add multi-factor authentication in src/auth/MFAService.ts
|
||||
... last 22 lines hidden ...
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
@@ -81,21 +81,21 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders correctly with plan content 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -107,21 +107,21 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -133,21 +133,21 @@ Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -161,42 +161,42 @@ Enter to submit · Esc to cancel"
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > handles long plan content appropriately 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Implement a comprehensive authentication system with multiple providers.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
|
||||
5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
|
||||
6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
|
||||
7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
|
||||
8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
|
||||
9. Implement session timeout handling in \`src/auth/SessionManager.ts\`
|
||||
10. Add audit logging for auth events in \`src/auth/AuditLogger.ts\`
|
||||
11. Create user profile management in \`src/auth/UserProfile.ts\`
|
||||
12. Add role-based access control in \`src/auth/RBACService.ts\`
|
||||
13. Implement password policy enforcement in \`src/auth/PasswordPolicy.ts\`
|
||||
14. Add brute force protection in \`src/auth/BruteForceGuard.ts\`
|
||||
15. Create secure cookie handling in \`src/auth/CookieManager.ts\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add OAuth2 provider support in src/auth/providers/OAuth2Provider.ts
|
||||
5. Add SAML provider support in src/auth/providers/SAMLProvider.ts
|
||||
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
|
||||
7. Create token refresh mechanism in src/auth/TokenManager.ts
|
||||
8. Add multi-factor authentication in src/auth/MFAService.ts
|
||||
9. Implement session timeout handling in src/auth/SessionManager.ts
|
||||
10. Add audit logging for auth events in src/auth/AuditLogger.ts
|
||||
11. Create user profile management in src/auth/UserProfile.ts
|
||||
12. Add role-based access control in src/auth/RBACService.ts
|
||||
13. Implement password policy enforcement in src/auth/PasswordPolicy.ts
|
||||
14. Add brute force protection in src/auth/BruteForceGuard.ts
|
||||
15. Create secure cookie handling in src/auth/CookieManager.ts
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- \`src/routes/api.ts\` - Add auth endpoints
|
||||
- \`src/middleware/cors.ts\` - Update CORS for auth headers
|
||||
- \`src/utils/crypto.ts\` - Add encryption utilities
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
- src/routes/api.ts - Add auth endpoints
|
||||
- src/middleware/cors.ts - Update CORS for auth headers
|
||||
- src/utils/crypto.ts - Add encryption utilities
|
||||
|
||||
## Testing Strategy
|
||||
Testing Strategy
|
||||
|
||||
- Unit tests for each auth provider
|
||||
- Integration tests for full auth flows
|
||||
- Security penetration testing
|
||||
- Load testing for session management
|
||||
- Unit tests for each auth provider
|
||||
- Integration tests for full auth flows
|
||||
- Security penetration testing
|
||||
- Load testing for session management
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
@@ -208,21 +208,21 @@ Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders correctly with plan content 1`] = `
|
||||
"## Overview
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
## Files to Modify
|
||||
Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
|
||||
@@ -10,10 +10,8 @@ import type {
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
|
||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import { getFormattedSettingValue } from '../../../commands/extensions/utils.js';
|
||||
|
||||
interface ExtensionsList {
|
||||
extensions: readonly GeminiCLIExtension[];
|
||||
@@ -70,7 +71,7 @@ export const ExtensionsList: React.FC<ExtensionsList> = ({ extensions }) => {
|
||||
<Text>settings:</Text>
|
||||
{ext.resolvedSettings.map((setting) => (
|
||||
<Text key={setting.name}>
|
||||
- {setting.name}: {setting.value}
|
||||
- {setting.name}: {getFormattedSettingValue(setting)}
|
||||
{setting.scope && (
|
||||
<Text color="gray">
|
||||
{' '}
|
||||
|
||||
@@ -237,6 +237,7 @@ export const useSlashCommandProcessor = (
|
||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest:
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
setConfirmationRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||
},
|
||||
@@ -258,6 +259,7 @@ export const useSlashCommandProcessor = (
|
||||
actions,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
setConfirmationRequest,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
reloadCommands,
|
||||
|
||||
@@ -21,17 +21,6 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('toolMapping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useFolderTrust = (
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const startupMessageSent = useRef(false);
|
||||
|
||||
const folderTrust = settings.merged.security.folderTrust.enabled;
|
||||
const folderTrust = settings.merged.security.folderTrust.enabled ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
|
||||
|
||||
@@ -88,7 +88,8 @@ export const usePermissionsModifyTrust = (
|
||||
);
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
|
||||
const isFolderTrustEnabled = !!settings.merged.security.folderTrust.enabled;
|
||||
const isFolderTrustEnabled =
|
||||
settings.merged.security.folderTrust.enabled ?? true;
|
||||
|
||||
const updateTrustLevel = useCallback(
|
||||
(trustLevel: TrustLevel) => {
|
||||
|
||||
@@ -74,7 +74,6 @@ export const useThemeCommand = (
|
||||
const handleThemeSelect = useCallback(
|
||||
(themeName: string, scope: LoadableSettingScope) => {
|
||||
try {
|
||||
// Merge user and workspace custom themes (workspace takes precedence)
|
||||
const mergedCustomThemes = {
|
||||
...(loadedSettings.user.settings.ui?.customThemes || {}),
|
||||
...(loadedSettings.workspace.settings.ui?.customThemes || {}),
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
};
|
||||
|
||||
@@ -30,6 +30,18 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
|
||||
* code units so that surrogate‑pair emoji count as one "column".)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Checks if a string contains only ASCII characters (0-127).
|
||||
*/
|
||||
export function isAscii(str: string): boolean {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str.charCodeAt(i) > 127) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cache for code points
|
||||
const MAX_STRING_LENGTH_TO_CACHE = 1000;
|
||||
const codePointsCache = new LRUCache<string, string[]>(
|
||||
@@ -37,15 +49,8 @@ const codePointsCache = new LRUCache<string, string[]>(
|
||||
);
|
||||
|
||||
export function toCodePoints(str: string): string[] {
|
||||
// ASCII fast path - check if all chars are ASCII (0-127)
|
||||
let isAscii = true;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str.charCodeAt(i) > 127) {
|
||||
isAscii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAscii) {
|
||||
// ASCII fast path
|
||||
if (isAscii(str)) {
|
||||
return str.split('');
|
||||
}
|
||||
|
||||
@@ -68,6 +73,9 @@ export function toCodePoints(str: string): string[] {
|
||||
}
|
||||
|
||||
export function cpLen(str: string): number {
|
||||
if (isAscii(str)) {
|
||||
return str.length;
|
||||
}
|
||||
return toCodePoints(str).length;
|
||||
}
|
||||
|
||||
@@ -79,6 +87,9 @@ export function cpIndexToOffset(str: string, cpIndex: number): number {
|
||||
}
|
||||
|
||||
export function cpSlice(str: string, start: number, end?: number): string {
|
||||
if (isAscii(str)) {
|
||||
return str.slice(start, end);
|
||||
}
|
||||
// Slice by code‑point indices and re‑join.
|
||||
const arr = toCodePoints(str).slice(start, end);
|
||||
return arr.join('');
|
||||
|
||||
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
|
||||
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
|
||||
import {
|
||||
writeToStderr,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export async function relaunchOnExitCode(runner: () => Promise<number>) {
|
||||
@@ -34,7 +34,7 @@ export async function relaunchOnExitCode(runner: () => Promise<number>) {
|
||||
export async function relaunchAppInChildProcess(
|
||||
additionalNodeArgs: string[],
|
||||
additionalScriptArgs: string[],
|
||||
remoteAdminSettings?: FetchAdminControlsResponse,
|
||||
remoteAdminSettings?: AdminControlsSettings,
|
||||
) {
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
return;
|
||||
@@ -71,7 +71,7 @@ export async function relaunchAppInChildProcess(
|
||||
|
||||
child.on('message', (msg: { type?: string; settings?: unknown }) => {
|
||||
if (msg.type === 'admin-settings-update' && msg.settings) {
|
||||
latestAdminSettings = msg.settings as FetchAdminControlsResponse;
|
||||
latestAdminSettings = msg.settings as AdminControlsSettings;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -374,6 +374,53 @@ describe('sandbox', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
|
||||
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
|
||||
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'--env',
|
||||
'GOOGLE_GEMINI_BASE_URL=http://gemini.proxy',
|
||||
'--env',
|
||||
'GOOGLE_VERTEX_BASE_URL=http://vertex.proxy',
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user creation on Linux if needed', async () => {
|
||||
const config: SandboxConfig = {
|
||||
command: 'docker',
|
||||
|
||||
@@ -460,6 +460,20 @@ export async function start_sandbox(
|
||||
args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`);
|
||||
}
|
||||
|
||||
// copy GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL
|
||||
if (process.env['GOOGLE_GEMINI_BASE_URL']) {
|
||||
args.push(
|
||||
'--env',
|
||||
`GOOGLE_GEMINI_BASE_URL=${process.env['GOOGLE_GEMINI_BASE_URL']}`,
|
||||
);
|
||||
}
|
||||
if (process.env['GOOGLE_VERTEX_BASE_URL']) {
|
||||
args.push(
|
||||
'--env',
|
||||
`GOOGLE_VERTEX_BASE_URL=${process.env['GOOGLE_VERTEX_BASE_URL']}`,
|
||||
);
|
||||
}
|
||||
|
||||
// copy GOOGLE_GENAI_USE_VERTEXAI
|
||||
if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) {
|
||||
args.push(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { installSkill } from './skillUtils.js';
|
||||
import { installSkill, linkSkill } from './skillUtils.js';
|
||||
|
||||
describe('skillUtils', () => {
|
||||
let tempDir: string;
|
||||
@@ -24,6 +24,94 @@ describe('skillUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('linkSkill', () => {
|
||||
it('should successfully link from a local directory', async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
});
|
||||
|
||||
it('should overwrite existing skill at destination', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('should abort linking if consent is rejected', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const requestConsent = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
linkSkill(mockSkillSourceDir, 'workspace', () => {}, requestConsent),
|
||||
).rejects.toThrow('Skill linking cancelled by user.');
|
||||
|
||||
expect(requestConsent).toHaveBeenCalled();
|
||||
|
||||
// Verify it was NOT linked
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const exists = await fs.lstat(linkedPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error if multiple skills with same name are discovered', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillDir1 = path.join(mockSkillSourceDir, 'skill1');
|
||||
const skillDir2 = path.join(mockSkillSourceDir, 'skill2');
|
||||
await fs.mkdir(skillDir1, { recursive: true });
|
||||
await fs.mkdir(skillDir2, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillDir1, 'SKILL.md'),
|
||||
'---\nname: duplicate-skill\ndescription: desc1\n---\nbody1',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillDir2, 'SKILL.md'),
|
||||
'---\nname: duplicate-skill\ndescription: desc2\n---\nbody2',
|
||||
);
|
||||
|
||||
await expect(
|
||||
linkSkill(mockSkillSourceDir, 'workspace', () => {}),
|
||||
).rejects.toThrow('Duplicate skill name "duplicate-skill" found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully install from a .skill file', async () => {
|
||||
const skillPath = path.join(projectRoot, 'weather-skill.skill');
|
||||
|
||||
|
||||
@@ -186,6 +186,75 @@ export async function installSkill(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central logic for linking a skill from a local path via symlink.
|
||||
*/
|
||||
export async function linkSkill(
|
||||
source: string,
|
||||
scope: 'user' | 'workspace',
|
||||
onLog: (msg: string) => void,
|
||||
requestConsent: (
|
||||
skills: SkillDefinition[],
|
||||
targetDir: string,
|
||||
) => Promise<boolean> = () => Promise.resolve(true),
|
||||
): Promise<Array<{ name: string; location: string }>> {
|
||||
const sourcePath = path.resolve(source);
|
||||
|
||||
onLog(`Searching for skills in ${sourcePath}...`);
|
||||
const skills = await loadSkillsFromDir(sourcePath);
|
||||
|
||||
if (skills.length === 0) {
|
||||
throw new Error(
|
||||
`No valid skills found in "${sourcePath}". Ensure a SKILL.md file exists with valid frontmatter.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for internal name collisions
|
||||
const seenNames = new Map<string, string>();
|
||||
for (const skill of skills) {
|
||||
if (seenNames.has(skill.name)) {
|
||||
throw new Error(
|
||||
`Duplicate skill name "${skill.name}" found at multiple locations:\n - ${seenNames.get(skill.name)}\n - ${skill.location}`,
|
||||
);
|
||||
}
|
||||
seenNames.set(skill.name, skill.location);
|
||||
}
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const targetDir =
|
||||
scope === 'workspace'
|
||||
? storage.getProjectSkillsDir()
|
||||
: Storage.getUserSkillsDir();
|
||||
|
||||
if (!(await requestConsent(skills, targetDir))) {
|
||||
throw new Error('Skill linking cancelled by user.');
|
||||
}
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const linkedSkills: Array<{ name: string; location: string }> = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillName = skill.name;
|
||||
const skillSourceDir = path.dirname(skill.location);
|
||||
const destPath = path.join(targetDir, skillName);
|
||||
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
if (exists) {
|
||||
onLog(
|
||||
`Skill "${skillName}" already exists at destination. Overwriting...`,
|
||||
);
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.symlink(skillSourceDir, destPath, 'dir');
|
||||
linkedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
return linkedSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central logic for uninstalling a skill by name.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './agentLoader.js';
|
||||
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
import { DEFAULT_MAX_TIME_MINUTES, DEFAULT_MAX_TURNS } from './types.js';
|
||||
|
||||
describe('loader', () => {
|
||||
let tempDir: string;
|
||||
@@ -237,7 +238,8 @@ Body`);
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTimeMinutes: 5,
|
||||
maxTimeMinutes: DEFAULT_MAX_TIME_MINUTES,
|
||||
maxTurns: DEFAULT_MAX_TURNS,
|
||||
},
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
|
||||
@@ -10,7 +10,11 @@ import { type Dirent } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import type { AgentDefinition } from './types.js';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -290,8 +294,8 @@ export function markdownToAgentDefinition(
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTurns: markdown.max_turns,
|
||||
maxTimeMinutes: markdown.timeout_mins || 5,
|
||||
maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS,
|
||||
maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
|
||||
},
|
||||
toolConfig: markdown.tools
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||
import { BaseA2AAuthProvider } from './base-provider.js';
|
||||
import type { A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Concrete implementation of BaseA2AAuthProvider for testing.
|
||||
*/
|
||||
class TestAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type: A2AAuthProviderType = 'apiKey';
|
||||
private testHeaders: HttpHeaders;
|
||||
|
||||
constructor(headers: HttpHeaders = { Authorization: 'test-token' }) {
|
||||
super();
|
||||
this.testHeaders = headers;
|
||||
}
|
||||
|
||||
async headers(): Promise<HttpHeaders> {
|
||||
return this.testHeaders;
|
||||
}
|
||||
|
||||
setHeaders(headers: HttpHeaders): void {
|
||||
this.testHeaders = headers;
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseA2AAuthProvider', () => {
|
||||
describe('shouldRetryWithHeaders', () => {
|
||||
it('should return headers for 401 response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response = new Response(null, { status: 401 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return headers for 403 response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response = new Response(null, { status: 403 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return undefined for 200 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 200 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for 500 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 500 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for 404 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 404 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call headers() to get fresh headers on retry', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'old-token' });
|
||||
const response = new Response(null, { status: 401 });
|
||||
|
||||
// Change headers before retry
|
||||
provider.setHeaders({ Authorization: 'new-token' });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'new-token' });
|
||||
});
|
||||
|
||||
it('should retry up to 2 times on 401/403', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
|
||||
// First retry should succeed
|
||||
const result1 = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result1).toEqual({ Authorization: 'Bearer token' });
|
||||
|
||||
// Second retry should succeed
|
||||
const result2 = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result2).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return undefined after max retries exceeded', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
|
||||
// Exhaust retries
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 1
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 2
|
||||
|
||||
// Third attempt should return undefined
|
||||
const result = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset retry count on successful response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
const response200 = new Response(null, { status: 200 });
|
||||
|
||||
// Use up retries
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 1
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 2
|
||||
|
||||
// Success resets counter
|
||||
await provider.shouldRetryWithHeaders({}, response200);
|
||||
|
||||
// Should be able to retry again
|
||||
const result = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should be a no-op by default', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
|
||||
// Should not throw
|
||||
await expect(provider.initialize()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||
import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
abstract headers(): Promise<HttpHeaders>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* Default: retry on 401/403 with fresh headers.
|
||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
res: Response,
|
||||
): Promise<HttpHeaders | undefined> {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
|
||||
return undefined; // Max retries exceeded
|
||||
}
|
||||
this.authRetryCount++;
|
||||
return this.headers();
|
||||
}
|
||||
// Reset on success
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { A2AAuthProviderFactory } from './factory.js';
|
||||
import type { AgentCard, SecurityScheme } from '@a2a-js/sdk';
|
||||
import type { A2AAuthConfig } from './types.js';
|
||||
|
||||
describe('A2AAuthProviderFactory', () => {
|
||||
describe('validateAuthConfig', () => {
|
||||
describe('when no security schemes required', () => {
|
||||
it('should return valid when securitySchemes is undefined', () => {
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should return valid when securitySchemes is empty', () => {
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(undefined, {});
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should return valid when auth config provided but not required', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'test-key',
|
||||
};
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when auth is required but not configured', () => {
|
||||
it('should return invalid with diff', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
undefined,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff).toBeDefined();
|
||||
expect(result.diff?.requiredSchemes).toContain('apiKeyAuth');
|
||||
expect(result.diff?.configuredType).toBeUndefined();
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
'Authentication is required but not configured',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKey scheme matching', () => {
|
||||
it('should match apiKey config with apiKey scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'my-key',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match http config with apiKey scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'apiKeyAuth' requires apiKey authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http scheme matching', () => {
|
||||
it('should match http Bearer config with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should match http Basic config with http Basic scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match http Basic config with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'bearerAuth' requires HTTP Bearer authentication, but Basic was configured",
|
||||
);
|
||||
});
|
||||
|
||||
it('should match google-credentials with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth2 scheme matching', () => {
|
||||
it('should match oauth2 config with oauth2 scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'oauth2',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match apiKey config with oauth2 scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'my-key',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'oauth2Auth' requires OAuth 2.0 authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openIdConnect scheme matching', () => {
|
||||
it('should match openIdConnect config with openIdConnect scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'openIdConnect',
|
||||
issuer_url: 'https://auth.example.com',
|
||||
client_id: 'client-id',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match google-credentials for openIdConnect scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'oidcAuth' requires OpenID Connect authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutualTLS scheme', () => {
|
||||
it('should always fail for mutualTLS (not supported)', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'test',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
mtlsAuth: {
|
||||
type: 'mutualTLS',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'mtlsAuth' requires mTLS authentication (not yet supported)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple security schemes', () => {
|
||||
it('should match if any scheme matches', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRequiredAuth', () => {
|
||||
it('should describe apiKey scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('API Key (apiKeyAuth): Send X-API-Key in header');
|
||||
});
|
||||
|
||||
it('should describe http Bearer scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('HTTP Bearer (bearerAuth)');
|
||||
});
|
||||
|
||||
it('should describe http Basic scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('HTTP Basic (basicAuth)');
|
||||
});
|
||||
|
||||
it('should describe oauth2 scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('OAuth 2.0 (oauth2Auth)');
|
||||
});
|
||||
|
||||
it('should describe openIdConnect scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('OpenID Connect (oidcAuth)');
|
||||
});
|
||||
|
||||
it('should describe mutualTLS scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
mtlsAuth: {
|
||||
type: 'mutualTLS',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('Mutual TLS (mtlsAuth)');
|
||||
});
|
||||
|
||||
it('should join multiple schemes with OR', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe(
|
||||
'API Key (apiKeyAuth): Send X-API-Key in header OR HTTP Bearer (bearerAuth)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should return undefined when no auth config and no security schemes', async () => {
|
||||
const result = await A2AAuthProviderFactory.create({
|
||||
agentName: 'test-agent',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no auth config but AgentCard has security schemes', async () => {
|
||||
const result = await A2AAuthProviderFactory.create({
|
||||
agentName: 'test-agent',
|
||||
agentCard: {
|
||||
securitySchemes: {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
},
|
||||
} as unknown as AgentCard,
|
||||
});
|
||||
|
||||
// Returns undefined - caller should prompt user to configure auth
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AgentCard, SecurityScheme } from '@a2a-js/sdk';
|
||||
import type {
|
||||
A2AAuthConfig,
|
||||
A2AAuthProvider,
|
||||
AuthValidationResult,
|
||||
} from './types.js';
|
||||
|
||||
export interface CreateAuthProviderOptions {
|
||||
/** Required for OAuth/OIDC token storage. */
|
||||
agentName?: string;
|
||||
authConfig?: A2AAuthConfig;
|
||||
agentCard?: AgentCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating A2A authentication providers.
|
||||
* @see https://a2a-protocol.org/latest/specification/#451-securityscheme
|
||||
*/
|
||||
export class A2AAuthProviderFactory {
|
||||
static async create(
|
||||
options: CreateAuthProviderOptions,
|
||||
): Promise<A2AAuthProvider | undefined> {
|
||||
const { agentName: _agentName, authConfig, agentCard } = options;
|
||||
|
||||
if (!authConfig) {
|
||||
if (
|
||||
agentCard?.securitySchemes &&
|
||||
Object.keys(agentCard.securitySchemes).length > 0
|
||||
) {
|
||||
return undefined; // Caller should prompt user to configure auth
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (authConfig.type) {
|
||||
case 'google-credentials':
|
||||
// TODO: Implement
|
||||
throw new Error('google-credentials auth provider not yet implemented');
|
||||
|
||||
case 'apiKey':
|
||||
// TODO: Implement
|
||||
throw new Error('apiKey auth provider not yet implemented');
|
||||
|
||||
case 'http':
|
||||
// TODO: Implement
|
||||
throw new Error('http auth provider not yet implemented');
|
||||
|
||||
case 'oauth2':
|
||||
// TODO: Implement
|
||||
throw new Error('oauth2 auth provider not yet implemented');
|
||||
|
||||
case 'openIdConnect':
|
||||
// TODO: Implement
|
||||
throw new Error('openIdConnect auth provider not yet implemented');
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = authConfig;
|
||||
throw new Error(
|
||||
`Unknown auth type: ${(_exhaustive as A2AAuthConfig).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create provider directly from config, bypassing AgentCard validation. */
|
||||
static async createFromConfig(
|
||||
authConfig: A2AAuthConfig,
|
||||
agentName?: string,
|
||||
): Promise<A2AAuthProvider> {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig,
|
||||
agentName,
|
||||
});
|
||||
|
||||
// create() returns undefined only when authConfig is missing.
|
||||
// Since authConfig is required here, provider will always be defined
|
||||
// (or create() throws for unimplemented types).
|
||||
return provider!;
|
||||
}
|
||||
|
||||
/** Validate auth config against AgentCard's security requirements. */
|
||||
static validateAuthConfig(
|
||||
authConfig: A2AAuthConfig | undefined,
|
||||
securitySchemes: Record<string, SecurityScheme> | undefined,
|
||||
): AuthValidationResult {
|
||||
if (!securitySchemes || Object.keys(securitySchemes).length === 0) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const requiredSchemes = Object.keys(securitySchemes);
|
||||
|
||||
if (!authConfig) {
|
||||
return {
|
||||
valid: false,
|
||||
diff: {
|
||||
requiredSchemes,
|
||||
configuredType: undefined,
|
||||
missingConfig: ['Authentication is required but not configured'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchResult = A2AAuthProviderFactory.findMatchingScheme(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
if (matchResult.matched) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
diff: {
|
||||
requiredSchemes,
|
||||
configuredType: authConfig.type,
|
||||
missingConfig: matchResult.missingConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Security schemes have OR semantics per A2A spec - matching any single scheme is sufficient
|
||||
private static findMatchingScheme(
|
||||
authConfig: A2AAuthConfig,
|
||||
securitySchemes: Record<string, SecurityScheme>,
|
||||
): { matched: boolean; missingConfig: string[] } {
|
||||
const missingConfig: string[] = [];
|
||||
|
||||
for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
|
||||
switch (scheme.type) {
|
||||
case 'apiKey':
|
||||
if (authConfig.type === 'apiKey') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires apiKey authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'http':
|
||||
if (authConfig.type === 'http') {
|
||||
if (
|
||||
authConfig.scheme.toLowerCase() === scheme.scheme.toLowerCase()
|
||||
) {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication, but ${authConfig.scheme} was configured`,
|
||||
);
|
||||
} else if (
|
||||
authConfig.type === 'google-credentials' &&
|
||||
scheme.scheme.toLowerCase() === 'bearer'
|
||||
) {
|
||||
return { matched: true, missingConfig: [] };
|
||||
} else {
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'oauth2':
|
||||
if (authConfig.type === 'oauth2') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires OAuth 2.0 authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'openIdConnect':
|
||||
if (authConfig.type === 'openIdConnect') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires OpenID Connect authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'mutualTLS':
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires mTLS authentication (not yet supported)`,
|
||||
);
|
||||
break;
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = scheme;
|
||||
missingConfig.push(
|
||||
`Unknown security scheme type: ${(_exhaustive as SecurityScheme).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: false, missingConfig };
|
||||
}
|
||||
|
||||
/** Get human-readable description of required auth for error messages. */
|
||||
static describeRequiredAuth(
|
||||
securitySchemes: Record<string, SecurityScheme>,
|
||||
): string {
|
||||
const descriptions: string[] = [];
|
||||
|
||||
for (const [name, scheme] of Object.entries(securitySchemes)) {
|
||||
switch (scheme.type) {
|
||||
case 'apiKey':
|
||||
descriptions.push(
|
||||
`API Key (${name}): Send ${scheme.name} in ${scheme.in}`,
|
||||
);
|
||||
break;
|
||||
case 'http':
|
||||
descriptions.push(`HTTP ${scheme.scheme} (${name})`);
|
||||
break;
|
||||
case 'oauth2':
|
||||
descriptions.push(`OAuth 2.0 (${name})`);
|
||||
break;
|
||||
case 'openIdConnect':
|
||||
descriptions.push(`OpenID Connect (${name})`);
|
||||
break;
|
||||
case 'mutualTLS':
|
||||
descriptions.push(`Mutual TLS (${name})`);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = scheme;
|
||||
// This ensures TypeScript errors if a new SecurityScheme type is added
|
||||
descriptions.push(
|
||||
`Unknown (${name}): ${(_exhaustive as SecurityScheme).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descriptions.join(' OR ');
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,12 @@ import type {
|
||||
OutputObject,
|
||||
SubagentActivityEvent,
|
||||
} from './types.js';
|
||||
import { AgentTerminateMode, DEFAULT_QUERY_STRING } from './types.js';
|
||||
import {
|
||||
AgentTerminateMode,
|
||||
DEFAULT_QUERY_STRING,
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import { templateString } from './utils.js';
|
||||
import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js';
|
||||
import type { RoutingContext } from '../routing/routingStrategy.js';
|
||||
@@ -406,7 +411,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
|
||||
let finalResult: string | null = null;
|
||||
|
||||
const { maxTimeMinutes } = this.definition.runConfig;
|
||||
const maxTimeMinutes =
|
||||
this.definition.runConfig.maxTimeMinutes ?? DEFAULT_MAX_TIME_MINUTES;
|
||||
const maxTurns = this.definition.runConfig.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
|
||||
const timeoutController = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => timeoutController.abort(new Error('Agent timed out.')),
|
||||
@@ -441,7 +449,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
while (true) {
|
||||
// Check for termination conditions like max turns.
|
||||
const reason = this.checkTermination(startTime, turnCounter);
|
||||
const reason = this.checkTermination(turnCounter, maxTurns);
|
||||
if (reason) {
|
||||
terminateReason = reason;
|
||||
break;
|
||||
@@ -499,13 +507,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
} else {
|
||||
// Recovery Failed. Set the final error message based on the *original* reason.
|
||||
if (terminateReason === AgentTerminateMode.TIMEOUT) {
|
||||
finalResult = `Agent timed out after ${this.definition.runConfig.maxTimeMinutes} minutes.`;
|
||||
finalResult = `Agent timed out after ${maxTimeMinutes} minutes.`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
});
|
||||
} else if (terminateReason === AgentTerminateMode.MAX_TURNS) {
|
||||
finalResult = `Agent reached max turns limit (${this.definition.runConfig.maxTurns}).`;
|
||||
finalResult = `Agent reached max turns limit (${maxTurns}).`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'max_turns',
|
||||
@@ -569,7 +577,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
}
|
||||
|
||||
// Recovery failed or wasn't possible
|
||||
finalResult = `Agent timed out after ${this.definition.runConfig.maxTimeMinutes} minutes.`;
|
||||
finalResult = `Agent timed out after ${maxTimeMinutes} minutes.`;
|
||||
this.emitActivity('ERROR', {
|
||||
error: finalResult,
|
||||
context: 'timeout',
|
||||
@@ -1160,12 +1168,10 @@ Important Rules:
|
||||
* @returns The reason for termination, or `null` if execution can continue.
|
||||
*/
|
||||
private checkTermination(
|
||||
startTime: number,
|
||||
turnCounter: number,
|
||||
maxTurns: number,
|
||||
): AgentTerminateMode | null {
|
||||
const { runConfig } = this.definition;
|
||||
|
||||
if (runConfig.maxTurns && turnCounter >= runConfig.maxTurns) {
|
||||
if (turnCounter >= maxTurns) {
|
||||
return AgentTerminateMode.MAX_TURNS;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SubagentTool } from './subagent-tool.js';
|
||||
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
|
||||
import type {
|
||||
LocalAgentDefinition,
|
||||
RemoteAgentDefinition,
|
||||
AgentInputs,
|
||||
} from './types.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
} from '../tools/tools.js';
|
||||
|
||||
vi.mock('./subagent-tool-wrapper.js');
|
||||
|
||||
const MockSubagentToolWrapper = vi.mocked(SubagentToolWrapper);
|
||||
|
||||
const testDefinition: LocalAgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'LocalAgent',
|
||||
description: 'A local agent.',
|
||||
inputConfig: { inputSchema: { type: 'object', properties: {} } },
|
||||
modelConfig: { model: 'test', generateContentConfig: {} },
|
||||
runConfig: { maxTimeMinutes: 1 },
|
||||
promptConfig: { systemPrompt: 'test' },
|
||||
};
|
||||
|
||||
const testRemoteDefinition: RemoteAgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'RemoteAgent',
|
||||
description: 'A remote agent.',
|
||||
inputConfig: {
|
||||
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
|
||||
},
|
||||
agentCardUrl: 'http://example.com/agent',
|
||||
};
|
||||
|
||||
describe('SubAgentInvocation', () => {
|
||||
let mockConfig: Config;
|
||||
let mockMessageBus: MessageBus;
|
||||
let mockInnerInvocation: ToolInvocation<AgentInputs, ToolResult>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
mockMessageBus = createMockMessageBus();
|
||||
mockInnerInvocation = {
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
params: {},
|
||||
getDescription: vi.fn(),
|
||||
toolLocations: vi.fn(),
|
||||
};
|
||||
|
||||
MockSubagentToolWrapper.prototype.build = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockInnerInvocation);
|
||||
});
|
||||
|
||||
it('should delegate shouldConfirmExecute to the inner sub-invocation (local)', async () => {
|
||||
const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus);
|
||||
const params = {};
|
||||
// @ts-expect-error - accessing protected method for testing
|
||||
const invocation = tool.createInvocation(params, mockMessageBus);
|
||||
|
||||
vi.mocked(mockInnerInvocation.shouldConfirmExecute).mockResolvedValue(
|
||||
false,
|
||||
);
|
||||
|
||||
const abortSignal = new AbortController().signal;
|
||||
const result = await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockInnerInvocation.shouldConfirmExecute).toHaveBeenCalledWith(
|
||||
abortSignal,
|
||||
);
|
||||
expect(MockSubagentToolWrapper).toHaveBeenCalledWith(
|
||||
testDefinition,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => {
|
||||
const tool = new SubagentTool(
|
||||
testRemoteDefinition,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
const params = { query: 'test' };
|
||||
// @ts-expect-error - accessing protected method for testing
|
||||
const invocation = tool.createInvocation(params, mockMessageBus);
|
||||
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm',
|
||||
prompt: 'Prompt',
|
||||
onConfirm: vi.fn(),
|
||||
} as const;
|
||||
vi.mocked(mockInnerInvocation.shouldConfirmExecute).mockResolvedValue(
|
||||
confirmationDetails as unknown as ToolCallConfirmationDetails,
|
||||
);
|
||||
|
||||
const abortSignal = new AbortController().signal;
|
||||
const result = await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
expect(result).toBe(confirmationDetails);
|
||||
expect(mockInnerInvocation.shouldConfirmExecute).toHaveBeenCalledWith(
|
||||
abortSignal,
|
||||
);
|
||||
expect(MockSubagentToolWrapper).toHaveBeenCalledWith(
|
||||
testRemoteDefinition,
|
||||
mockConfig,
|
||||
mockMessageBus,
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate execute to the inner sub-invocation', async () => {
|
||||
const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus);
|
||||
const params = {};
|
||||
// @ts-expect-error - accessing protected method for testing
|
||||
const invocation = tool.createInvocation(params, mockMessageBus);
|
||||
|
||||
const mockResult: ToolResult = {
|
||||
llmContent: 'success',
|
||||
returnDisplay: 'success',
|
||||
};
|
||||
vi.mocked(mockInnerInvocation.execute).mockResolvedValue(mockResult);
|
||||
|
||||
const abortSignal = new AbortController().signal;
|
||||
const updateOutput = vi.fn();
|
||||
const result = await invocation.execute(abortSignal, updateOutput);
|
||||
|
||||
expect(result).toBe(mockResult);
|
||||
expect(mockInnerInvocation.execute).toHaveBeenCalledWith(
|
||||
abortSignal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -88,11 +88,6 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
|
||||
override async shouldConfirmExecute(
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.definition.kind !== 'remote') {
|
||||
// Local agents should execute without confirmation. Inner tool calls will bubble up their own confirmations to the user.
|
||||
return false;
|
||||
}
|
||||
|
||||
const invocation = this.buildSubInvocation(this.definition, this.params);
|
||||
return invocation.shouldConfirmExecute(abortSignal);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user