mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-11 01:16:28 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a7ecba9c | |||
| 7f9b99bb64 | |||
| dd8dfd91f9 | |||
| f7af4e5180 | |||
| ff00dacd9f | |||
| 7f00c5fe59 | |||
| b5fc06ee33 | |||
| ae0a3aa7b9 | |||
| b14416447e | |||
| 8cd5c0f71f | |||
| df997354c8 | |||
| 19ad71b903 | |||
| 3fbf93e26f | |||
| d845bc5d45 | |||
| 02c6c77324 | |||
| f8541cf7a2 | |||
| 6e0bd68e45 | |||
| d3ef6aca40 |
@@ -197,6 +197,29 @@ runs:
|
||||
run: |
|
||||
node ${{ github.workspace }}/scripts/prepare-npm-release.js
|
||||
|
||||
- name: '📦 Pack CLI for verification'
|
||||
if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
|
||||
# We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
|
||||
git checkout packages/cli/package.json
|
||||
env:
|
||||
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: 'Get CLI Token'
|
||||
uses: './.github/actions/npm-auth-token'
|
||||
id: 'cli-token'
|
||||
@@ -213,12 +236,19 @@ runs:
|
||||
NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
|
||||
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
|
||||
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
|
||||
INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then
|
||||
PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz"
|
||||
else
|
||||
PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}"
|
||||
fi
|
||||
|
||||
npm publish \
|
||||
--ignore-scripts \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
|
||||
${PUBLISH_TARGET} \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
@@ -252,18 +282,6 @@ runs:
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: '${{ inputs.cli-package-name }}@${{ inputs.release-version }}'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
with:
|
||||
|
||||
@@ -19,6 +19,6 @@ runs:
|
||||
run: |-
|
||||
echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
|
||||
echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com/"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
|
||||
env:
|
||||
INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
|
||||
|
||||
@@ -74,7 +74,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
|
||||
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
|
||||
exit 1
|
||||
@@ -86,7 +86,7 @@ runs:
|
||||
- name: 'Install dependencies for integration tests'
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: 'npm ci'
|
||||
run: 'npm ci --ignore-scripts'
|
||||
|
||||
- name: '🔬 Run integration tests against NPM release'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
@@ -98,4 +98,6 @@ runs:
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
CI: 'false'
|
||||
shell: 'bash'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
run: |
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
|
||||
npm run test:integration:sandbox:none
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
|
||||
@@ -131,6 +131,19 @@ jobs:
|
||||
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
|
||||
return labelNames;
|
||||
|
||||
- name: 'Prepare Issue Data'
|
||||
id: 'prepare_issue_data'
|
||||
env:
|
||||
ISSUE_TITLE: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
ISSUE_BODY: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Title: ${ISSUE_TITLE}" > issue_context.md
|
||||
echo "Body:" >> issue_context.md
|
||||
echo "${ISSUE_BODY}" >> issue_context.md
|
||||
|
||||
- name: 'Run Gemini Issue Analysis'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
id: 'gemini_issue_analysis'
|
||||
@@ -140,6 +153,7 @@ jobs:
|
||||
${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }}
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
@@ -157,7 +171,10 @@ jobs:
|
||||
"target": "gcp"
|
||||
},
|
||||
"tools": {
|
||||
"core": []
|
||||
"core": [
|
||||
"run_shell_command(echo)",
|
||||
"read_file"
|
||||
]
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
@@ -165,15 +182,8 @@ jobs:
|
||||
|
||||
You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label based on the definitions provided.
|
||||
|
||||
## Issue Context
|
||||
Title: ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
Body:
|
||||
--- START OF ISSUE BODY ---
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
--- END OF ISSUE BODY ---
|
||||
|
||||
## Steps
|
||||
1. Analyze the issue context above.
|
||||
1. Use the read_file tool to read the file "issue_context.md" which contains the issue title and body.
|
||||
2. Review the available labels: ${{ env.AVAILABLE_LABELS }}.
|
||||
3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions.
|
||||
4. Fallback Logic:
|
||||
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
|
||||
@@ -176,6 +176,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
@@ -300,6 +301,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
skip-branch-cleanup: true
|
||||
force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}"
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}"
|
||||
cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}"
|
||||
core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}"
|
||||
|
||||
@@ -106,7 +106,9 @@ jobs:
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
|
||||
PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)
|
||||
STABLE_SHA=$(git rev-parse "${PREVIOUS_PREVIEW_TAG}^{commit}")
|
||||
echo "STABLE_SHA=${STABLE_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
|
||||
@@ -82,7 +82,8 @@ jobs:
|
||||
ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT"
|
||||
ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")
|
||||
echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Change tag'
|
||||
if: "${{ github.event.inputs.rollback_destination != '' }}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
@google:registry=https://wombat-dressing-room.appspot.com/
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
@@ -143,16 +143,6 @@ Integrate Gemini CLI directly into your GitHub workflows with
|
||||
- **Custom Workflows**: Build automated, scheduled and on-demand workflows
|
||||
tailored to your team's needs
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Security best practice for public repositories:** Never set
|
||||
> `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD workflows
|
||||
> that process untrusted public inputs (like issue titles/bodies or PR comments).
|
||||
> Doing so can expose dynamically generated runner secrets (such as GCP OIDC
|
||||
> service account credentials) to prompt injection attacks. See the
|
||||
> [Trusted Folders documentation](https://www.geminicli.com/docs/cli/trusted-folders)
|
||||
> for more information.
|
||||
|
||||
## 🔐 Authentication Options
|
||||
|
||||
Choose the authentication method that best fits your needs:
|
||||
|
||||
@@ -507,6 +507,7 @@ on GitHub.
|
||||
headlessly in notebook cells or interactively in the built-in terminal
|
||||
([pic](https://imgur.com/a/G0Tn7vi))
|
||||
- 🎉**Gemini CLI Extensions:**
|
||||
|
||||
- **Conductor:** Planning++, Gemini works with you to build out a detailed
|
||||
plan, pull in extra details as needed, ultimately to give the LLM guardrails
|
||||
with artifacts. Measure twice, implement once!
|
||||
@@ -635,6 +636,7 @@ on GitHub.
|
||||
- **Announcement:**
|
||||
[https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/)
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **Arize:** Seamlessly instrument AI applications with Arize AX and grant
|
||||
direct access to Arize support:
|
||||
|
||||
@@ -674,6 +676,7 @@ on GitHub.
|
||||

|
||||
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **🤗 Hugging Face extension:** Access the Hugging Face hub.
|
||||
([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.48.0-preview.0
|
||||
# Preview release: v0.50.0-preview.1
|
||||
|
||||
Released: June 17, 2026
|
||||
Released: June 25, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -27,6 +27,14 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix/verify release npm ci ignore scripts by @rmedranollamas in
|
||||
[#28116](https://github.com/google-gemini/gemini-cli/pull/28116)
|
||||
- fix(ci): prevent workspace binary shadowing in release verification by @galz10
|
||||
in [#28132](https://github.com/google-gemini/gemini-cli/pull/28132)
|
||||
- Feat/tool registry discovery by @ved015 in
|
||||
[#28113](https://github.com/google-gemini/gemini-cli/pull/28113)
|
||||
- fix(ci): prevent bad NPM releases and promote job crashes by @galz10 in
|
||||
[#28147](https://github.com/google-gemini/gemini-cli/pull/28147)
|
||||
- chore(release): bump version to 0.48.0-nightly.20260609.g3a13b8eeb by
|
||||
@gemini-cli-robot in
|
||||
[#27779](https://github.com/google-gemini/gemini-cli/pull/27779)
|
||||
@@ -67,4 +75,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.48.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.50.0-preview.1
|
||||
|
||||
@@ -16,10 +16,12 @@ sends them to the model with every prompt. The CLI loads files in the following
|
||||
order:
|
||||
|
||||
1. **Global context file:**
|
||||
|
||||
- **Location:** `~/.gemini/GEMINI.md` (in your user home directory).
|
||||
- **Scope:** Provides default instructions for all your projects.
|
||||
|
||||
2. **Environment and workspace context files:**
|
||||
|
||||
- **Location:** The CLI searches for `GEMINI.md` files in your configured
|
||||
workspace directories and their parent directories.
|
||||
- **Scope:** Provides context relevant to the projects you are currently
|
||||
|
||||
@@ -64,6 +64,7 @@ Gemini CLI takes action.
|
||||
reach an informal agreement on the approach before proceeding.
|
||||
3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates
|
||||
a detailed implementation plan as a Markdown file in your plans directory.
|
||||
|
||||
- **View:** You can open and read this file to understand the proposed
|
||||
changes.
|
||||
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
|
||||
|
||||
@@ -202,6 +202,7 @@ becoming too large and expensive.
|
||||
exchanges) allowed in a single session. Set to `-1` for unlimited (default).
|
||||
|
||||
**Behavior when limit is reached:**
|
||||
|
||||
- **Interactive mode:** The CLI shows an informational message and stops
|
||||
sending requests to the model. You must manually start a new session.
|
||||
- **Non-interactive mode:** The CLI exits with an error.
|
||||
|
||||
@@ -27,11 +27,13 @@ via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.mdx#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
- The CLI reads `./.gemini/system.md` (relative to your current project
|
||||
directory).
|
||||
|
||||
- Use a custom file path:
|
||||
|
||||
- `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md`
|
||||
- Relative paths are supported and resolved from the current working
|
||||
directory.
|
||||
|
||||
@@ -64,6 +64,7 @@ and Cloud Logging.
|
||||
You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
|
||||
1. Set your Google Cloud project ID:
|
||||
|
||||
- To send telemetry to a separate project:
|
||||
|
||||
**macOS/Linux**
|
||||
@@ -93,8 +94,10 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```
|
||||
|
||||
2. Authenticate with Google Cloud using one of these methods:
|
||||
|
||||
- **Method A: Application Default Credentials (ADC)**: Use this method for
|
||||
service accounts or standard `gcloud` authentication.
|
||||
|
||||
- For user accounts:
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
@@ -112,6 +115,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```powershell
|
||||
$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json"
|
||||
```
|
||||
|
||||
* **Method B: CLI Auth** (Direct export only): Simplest method for local
|
||||
users. Gemini CLI uses the same OAuth credentials you used for login. To
|
||||
enable this, set `useCliAuth: true` in your `.gemini/settings.json`:
|
||||
@@ -133,6 +137,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
> telemetry will be disabled.
|
||||
|
||||
3. Ensure your account or service account has these IAM roles:
|
||||
|
||||
- Cloud Trace Agent
|
||||
- Monitoring Metric Writer
|
||||
- Logs Writer
|
||||
|
||||
@@ -117,16 +117,6 @@ the following methods:
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Never set `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD
|
||||
> workflows that process untrusted public inputs** (such as GitHub issues, pull
|
||||
> requests, or comments). Doing so allows a malicious contributor to commit a
|
||||
> crafted `.gemini/settings.json` file in their pull request, register
|
||||
> arbitrary tools (including shell execution), and exfiltrate dynamically
|
||||
> generated runner secrets (such as GCP service account credentials or AWS keys)
|
||||
> via prompt injection.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
@@ -56,6 +56,7 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
@@ -187,6 +188,7 @@ The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
- **Response (`CallToolResult`):** The tool **MUST** immediately return a
|
||||
`CallToolResult` to acknowledge the request and report whether the diff view
|
||||
was successfully opened.
|
||||
|
||||
- On Success: If the diff view was opened successfully, the response **MUST**
|
||||
contain empty content (that is, `content: []`).
|
||||
- On Failure: If an error prevented the diff view from opening, the response
|
||||
|
||||
@@ -27,6 +27,7 @@ AI-generated code changes directly within your editor.
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
|
||||
- The **10 most recently accessed files** in your workspace.
|
||||
- Your active cursor position.
|
||||
- Any text you have selected (up to a 16KB limit; longer selections will be
|
||||
@@ -228,6 +229,7 @@ If you are using Gemini CLI within a sandbox, be aware of the following:
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
|
||||
- **Cause:** Gemini CLI could not find the necessary environment variables
|
||||
(`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect
|
||||
to the IDE. This usually means the IDE companion extension is not running or
|
||||
@@ -270,6 +272,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
|
||||
- **Cause:** The CLI's current working directory is outside the workspace you
|
||||
have open in your IDE.
|
||||
- **Solution:** `cd` into the same directory that is open in your IDE and
|
||||
@@ -284,6 +287,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is
|
||||
not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
|
||||
|
||||
@@ -59,6 +59,7 @@ You can view traces in the Jaeger UI for local development.
|
||||
|
||||
This command configures your workspace for local telemetry and provides a
|
||||
link to the Jaeger UI (usually `http://localhost:16686`).
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector.log`
|
||||
|
||||
2. **Run Gemini CLI:**
|
||||
@@ -108,6 +109,7 @@ Trace for custom processing or routing.
|
||||
|
||||
The script outputs links to view traces, metrics, and logs in the Google
|
||||
Cloud Console.
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
|
||||
|
||||
3. **Run Gemini CLI:**
|
||||
|
||||
@@ -506,6 +506,7 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
@@ -519,6 +520,7 @@ At commands are used to include the content of files or directories as part of
|
||||
your prompt to Gemini. These commands include git-aware filtering.
|
||||
|
||||
- **`@<path_to_file_or_directory>`**
|
||||
|
||||
- **Description:** Inject the content of the specified file or files into your
|
||||
current prompt. This is useful for asking questions about specific code,
|
||||
text, or collections of files.
|
||||
@@ -565,6 +567,7 @@ The `!` prefix lets you interact with your system's shell directly from within
|
||||
Gemini CLI.
|
||||
|
||||
- **`!<shell_command>`**
|
||||
|
||||
- **Description:** Execute the given `<shell_command>` using `bash` on
|
||||
Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
|
||||
override `ComSpec`). Any output or errors from the command are displayed in
|
||||
@@ -574,6 +577,7 @@ Gemini CLI.
|
||||
- `!git status` (executes `git status` and returns to Gemini CLI)
|
||||
|
||||
- **`!` (Toggle shell mode)**
|
||||
|
||||
- **Description:** Typing `!` on its own toggles shell mode.
|
||||
- **Entering shell mode:**
|
||||
- When active, shell mode uses a different coloring and a "Shell Mode
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ Before promoting a `preview` release to `stable`, a release manager must
|
||||
manually run through this checklist.
|
||||
|
||||
- **Setup:**
|
||||
|
||||
- [ ] Uninstall any existing global version:
|
||||
`npm uninstall -g @google/gemini-cli`
|
||||
- [ ] Clear npx cache (optional but recommended): `npm cache clean --force`
|
||||
@@ -77,24 +78,29 @@ manually run through this checklist.
|
||||
- [ ] Verify version: `gemini --version`
|
||||
|
||||
- **Authentication:**
|
||||
|
||||
- [ ] In interactive mode run `/auth` and verify all sign in flows work:
|
||||
- [ ] Sign in with Google
|
||||
- [ ] API Key
|
||||
- [ ] Vertex AI
|
||||
|
||||
- **Basic prompting:**
|
||||
|
||||
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
|
||||
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
|
||||
context.
|
||||
|
||||
- **Piped input:**
|
||||
|
||||
- [ ] Run `echo "Summarize this" | gemini` and verify it processes stdin.
|
||||
|
||||
- **Context management:**
|
||||
|
||||
- [ ] In interactive mode, use `@file` to add a local file to context. Ask a
|
||||
question about it.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
- [ ] In interactive mode run `/settings` and make modifications
|
||||
- [ ] Validate that setting is changed
|
||||
|
||||
|
||||
@@ -475,6 +475,7 @@ This stage happens _after_ the NPM publish and creates the single-file
|
||||
executable that enables `npx` usage directly from the GitHub repository.
|
||||
|
||||
1. **The JavaScript bundle is created:**
|
||||
|
||||
- **What happens:** The built JavaScript from both `packages/core/dist` and
|
||||
`packages/cli/dist`, along with all third-party JavaScript dependencies,
|
||||
are bundled by `esbuild` into a single, executable JavaScript file (for
|
||||
@@ -486,6 +487,7 @@ executable that enables `npx` usage directly from the GitHub repository.
|
||||
the `core` package) are included directly.
|
||||
|
||||
2. **The `bundle` directory is assembled:**
|
||||
|
||||
- **What happens:** A temporary `bundle` folder is created at the project
|
||||
root. The single `gemini.js` executable is placed inside it, along with
|
||||
other essential files.
|
||||
|
||||
@@ -127,6 +127,7 @@ Standard/Plus and AI Expanded, are not supported._
|
||||
license seats. For predictable costs, you can sign in with Google.
|
||||
|
||||
This includes the following request limits:
|
||||
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 maximum model requests / user / day
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
|
||||
@@ -12,6 +12,7 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`**
|
||||
|
||||
- **Cause:** This error might occur if Gemini CLI detects the
|
||||
`GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is
|
||||
defined. Setting these variables forces an organization subscription check.
|
||||
@@ -19,6 +20,7 @@ topics on:
|
||||
linked to an organizational subscription.
|
||||
|
||||
- **Solution:**
|
||||
|
||||
- **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and
|
||||
`GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these
|
||||
variables from your shell configuration files (for example, `.bashrc`,
|
||||
@@ -30,12 +32,14 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`**
|
||||
|
||||
- **Cause:** Gemini CLI does not currently support your location. For a full
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
|
||||
- **Error: `Failed to sign in. Message: Request contains an invalid argument`**
|
||||
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
@@ -66,6 +70,7 @@ topics on:
|
||||
## Common error messages and solutions
|
||||
|
||||
- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.**
|
||||
|
||||
- **Cause:** Another process is already using the port that the MCP server is
|
||||
trying to bind to.
|
||||
- **Solution:** Either stop the other process that is using the port or
|
||||
@@ -73,6 +78,7 @@ topics on:
|
||||
|
||||
- **Error: Command not found (when attempting to run Gemini CLI with
|
||||
`gemini`).**
|
||||
|
||||
- **Cause:** Gemini CLI is not correctly installed or it is not in your
|
||||
system's `PATH`.
|
||||
- **Solution:** The update depends on how you installed Gemini CLI:
|
||||
@@ -85,6 +91,7 @@ topics on:
|
||||
then rebuild using the command `npm run build`.
|
||||
|
||||
- **Error: `MODULE_NOT_FOUND` or import errors.**
|
||||
|
||||
- **Cause:** Dependencies are not installed correctly, or the project hasn't
|
||||
been built.
|
||||
- **Solution:**
|
||||
@@ -93,6 +100,7 @@ topics on:
|
||||
3. Verify that the build completed successfully with `npm run start`.
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
|
||||
that are restricted by your sandbox configuration, such as writing outside
|
||||
the project directory or system temp directory.
|
||||
@@ -101,6 +109,7 @@ topics on:
|
||||
configuration.
|
||||
|
||||
- **Gemini CLI is not running in interactive mode in "CI" environments**
|
||||
|
||||
- **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if
|
||||
an environment variable starting with `CI_` (for example, `CI_TOKEN`) is
|
||||
set. This is because the `is-in-ci` package, used by the underlying UI
|
||||
@@ -116,6 +125,7 @@ topics on:
|
||||
`env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable
|
||||
debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded
|
||||
@@ -155,12 +165,14 @@ is especially useful for scripting and automation.
|
||||
## Debugging tips
|
||||
|
||||
- **CLI debugging:**
|
||||
|
||||
- Use the `--debug` flag for more detailed output. In interactive mode, press
|
||||
F12 to view the debug console.
|
||||
- Check the CLI logs, often found in a user-specific configuration or cache
|
||||
directory.
|
||||
|
||||
- **Core debugging:**
|
||||
|
||||
- Check the server console output for error messages or stack traces.
|
||||
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
|
||||
environment variable to `true` or `1`.
|
||||
@@ -168,6 +180,7 @@ is especially useful for scripting and automation.
|
||||
step through server-side code.
|
||||
|
||||
- **Tool issues:**
|
||||
|
||||
- If a specific tool is failing, try to isolate the issue by running the
|
||||
simplest possible version of the command or operation the tool performs.
|
||||
- For `run_shell_command`, check that the command works directly in your shell
|
||||
|
||||
@@ -11,6 +11,7 @@ confirmation.
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
@@ -30,6 +31,7 @@ confirmation.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
@@ -768,6 +768,7 @@ defaults:
|
||||
|
||||
- **Tool lists:** Tool lists are merged securely to ensure the most restrictive
|
||||
policy wins:
|
||||
|
||||
- **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either
|
||||
source blocks a tool, it remains disabled.
|
||||
- **Inclusions (`includeTools`):** Arrays are intersected. If both sources
|
||||
|
||||
+11
-3
@@ -56,6 +56,7 @@ export default tseslint.config(
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'tools/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
@@ -80,8 +81,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules for packages/*/src (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
// Rules for packages/*/src and tools/caretaker-agent (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}', 'tools/caretaker-agent/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -284,7 +285,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -410,6 +411,13 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Allow console logging for backend services (Cloud Logging)
|
||||
{
|
||||
files: ['tools/**/*.ts', 'tools/**/*.test.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
|
||||
@@ -216,4 +216,109 @@ describe('evalTest reliability logic', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should append tool call chain to assertion failure error messages', async () => {
|
||||
const mockRig = {
|
||||
setup: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
readToolLogs: vi.fn().mockReturnValue([]),
|
||||
_lastRunStderr: '',
|
||||
} as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
mockRig.run.mockResolvedValue('Success');
|
||||
mockRig.readToolLogs.mockReturnValue([
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'grep_search',
|
||||
args: '{"query":"TODO"}',
|
||||
success: true,
|
||||
duration_ms: 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'read_file',
|
||||
args: '{"path":"/src/foo.ts"}',
|
||||
success: false,
|
||||
duration_ms: 15,
|
||||
error: 'File not found',
|
||||
error_type: 'ENOENT',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const assertionError = new Error('Expected tool to be called');
|
||||
|
||||
try {
|
||||
await internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-tool-chain',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
throw assertionError;
|
||||
},
|
||||
});
|
||||
expect.unreachable('Expected internalEvalTest to throw');
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const msg = (error as Error).message;
|
||||
expect(msg).toContain('Expected tool to be called');
|
||||
expect(msg).toContain('Tool Call Chain (2 calls)');
|
||||
expect(msg).toContain('grep_search');
|
||||
expect(msg).toContain('read_file');
|
||||
expect(msg).toContain('[ENOENT] File not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not crash when error.message is read-only (frozen error)', async () => {
|
||||
const mockRig = {
|
||||
setup: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
readToolLogs: vi.fn(),
|
||||
_lastRunStderr: '',
|
||||
} as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
mockRig.run.mockResolvedValue('Success');
|
||||
mockRig.readToolLogs.mockReturnValue([
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'read_file',
|
||||
args: '{"path":"/foo.ts"}',
|
||||
success: true,
|
||||
duration_ms: 10,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Simulate a frozen error whose message property cannot be mutated
|
||||
const frozenError = Object.freeze(new Error('Frozen assertion error'));
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-frozen-error',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
throw frozenError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Frozen assertion error');
|
||||
|
||||
// Should have warned that the message could not be mutated
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not append tool call chain'),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { TestRig } from '@google/gemini-cli-test-utils';
|
||||
import { formatToolLogChain } from '../scripts/utils/tool-log-formatter.js';
|
||||
import {
|
||||
createUnauthorizedToolError,
|
||||
parseAgentMarkdown,
|
||||
@@ -186,6 +187,23 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
|
||||
await evalCase.assert(rig, result);
|
||||
isSuccess = true;
|
||||
} catch (error: unknown) {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
if (toolLogs && toolLogs.length > 0) {
|
||||
const summary = formatToolLogChain(toolLogs);
|
||||
if (error instanceof Error) {
|
||||
try {
|
||||
error.message = `${error.message}\n\nTool Call Chain (${toolLogs.length} calls):\n${summary}`;
|
||||
} catch {
|
||||
// Error object may be frozen or have a read-only message property.
|
||||
// The original error is still re-thrown, so no failure is hidden.
|
||||
console.warn(
|
||||
`[eval] Could not append tool call chain to error message (${toolLogs.length} calls)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
await fs.promises.unlink(activityLogFile).catch((err) => {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"@google/gemini-cli": ["../packages/cli/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"include": ["**/*.ts", "../scripts/utils/tool-log-formatter.ts"],
|
||||
"exclude": ["logs"],
|
||||
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
|
||||
}
|
||||
|
||||
Generated
+49
-59
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -449,8 +449,7 @@
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1517,7 +1516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -1568,6 +1566,7 @@
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -1654,6 +1653,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
@@ -2084,6 +2084,7 @@
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -2125,6 +2126,7 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2141,7 +2143,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.39.5",
|
||||
@@ -2240,7 +2243,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2421,7 +2423,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2471,7 +2472,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
|
||||
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2822,7 +2822,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
|
||||
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2857,7 +2856,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz",
|
||||
"integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1"
|
||||
@@ -2913,7 +2911,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
|
||||
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
@@ -4140,7 +4137,6 @@
|
||||
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4256,6 +4252,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -5117,7 +5114,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7818,7 +7814,6 @@
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8426,7 +8421,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -8471,6 +8465,7 @@
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
@@ -8610,7 +8605,8 @@
|
||||
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "3.0.2",
|
||||
@@ -8619,6 +8615,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^3.0.2"
|
||||
}
|
||||
@@ -8646,6 +8643,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
@@ -10002,7 +10000,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -10902,6 +10899,7 @@
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@@ -10994,7 +10992,8 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
@@ -11996,7 +11995,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bundled-es-modules/cookie": "^2.0.1",
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
@@ -13660,7 +13658,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13671,7 +13668,6 @@
|
||||
"integrity": "sha512-ldFwzufLletzCikNJVYaxlxMLu7swJ3T2VrGfzXlMsVhZhPDKXA38DEROidaYZVgMAmQnIjymrmqto5pyfrwPA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -14092,7 +14088,8 @@
|
||||
"integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
@@ -14519,7 +14516,8 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
@@ -15555,6 +15553,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -15834,7 +15833,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15886,6 +15884,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.3"
|
||||
},
|
||||
@@ -15899,7 +15898,8 @@
|
||||
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
@@ -15940,6 +15940,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
@@ -16093,8 +16094,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16102,7 +16102,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16268,7 +16267,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16336,7 +16334,6 @@
|
||||
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.30.1",
|
||||
"@typescript-eslint/types": "8.30.1",
|
||||
@@ -16633,6 +16630,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kettanaito"
|
||||
}
|
||||
@@ -16727,7 +16725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17298,7 +17295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17311,7 +17307,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17813,7 +17808,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
@@ -17960,7 +17954,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17976,7 +17969,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "7.19.0",
|
||||
@@ -18178,7 +18171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -18254,7 +18246,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18316,7 +18307,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -18481,7 +18471,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
@@ -18830,7 +18820,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18917,7 +18906,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -19103,7 +19091,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -19590,6 +19578,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -19602,8 +19591,7 @@
|
||||
"version": "0.0.1367902",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz",
|
||||
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"packages/core/node_modules/dotenv": {
|
||||
"version": "17.2.4",
|
||||
@@ -19766,7 +19754,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz",
|
||||
"integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -19934,7 +19921,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -20117,6 +20103,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^11.2.1",
|
||||
"@inquirer/type": "^4.0.7"
|
||||
@@ -20140,6 +20127,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^2.0.7",
|
||||
"@inquirer/figures": "^2.0.7",
|
||||
@@ -20168,6 +20156,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
}
|
||||
@@ -20179,6 +20168,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||
},
|
||||
@@ -20198,6 +20188,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/logger": "^0.3.0",
|
||||
@@ -20244,6 +20235,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"set-cookie-parser": "^3.0.1"
|
||||
@@ -20257,6 +20249,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^6.0.11",
|
||||
"@mswjs/interceptors": "^0.41.3",
|
||||
@@ -20301,7 +20294,8 @@
|
||||
"integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"packages/core/node_modules/vitest/node_modules/mute-stream": {
|
||||
"version": "3.0.0",
|
||||
@@ -20310,6 +20304,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
@@ -20321,6 +20316,7 @@
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
@@ -20371,7 +20367,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "8.16.0"
|
||||
@@ -20407,7 +20403,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -20559,7 +20555,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -20583,7 +20578,6 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -20748,7 +20742,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -20766,7 +20760,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
@@ -21335,7 +21329,6 @@
|
||||
"integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.31.1",
|
||||
"@typescript-eslint/types": "8.31.1",
|
||||
@@ -21557,7 +21550,6 @@
|
||||
"integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -21642,7 +21634,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -21786,7 +21777,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"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.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.51.0-nightly.20260625.g3fbf93e26"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -33,6 +33,7 @@
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
|
||||
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.51.0-nightly.20260625.g3fbf93e26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
|
||||
@@ -439,7 +439,8 @@ describe('extensionsCommand', () => {
|
||||
}
|
||||
|
||||
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
|
||||
@@ -455,7 +456,8 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
|
||||
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
|
||||
@@ -63,8 +63,10 @@ describe('handleAtCommand', () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
@@ -1467,8 +1469,8 @@ describe('handleAtCommand', () => {
|
||||
});
|
||||
|
||||
it('should resolve files in multiple workspace directories', async () => {
|
||||
const secondRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'second-root-'),
|
||||
const secondRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'second-root-')),
|
||||
);
|
||||
try {
|
||||
const fileContent = 'Second root content';
|
||||
@@ -1649,8 +1651,10 @@ describe('checkPermissions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
mockConfig = {
|
||||
|
||||
@@ -37,8 +37,8 @@ describe('handleAtCommand with Agents', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'agent-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
@@ -525,6 +525,102 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with Vertex AI by displaying region-specific availability message and documentation link', async () => {
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
});
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
act(() => {
|
||||
promise = handler('gemini-3.5-flash', 'gemini-1.5-flash', error);
|
||||
});
|
||||
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-3.5-flash');
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`Model "gemini-3.5-flash" is not available in region "us-central1".\n` +
|
||||
`To see which models are available in this region, please visit:\n` +
|
||||
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations\n` +
|
||||
`/model to switch models.`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with Vertex AI and invalid model by displaying generic not found error message', async () => {
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
});
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
act(() => {
|
||||
promise = handler('invalid-model-name', 'gemini-1.5-flash', error);
|
||||
});
|
||||
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('invalid-model-name');
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`Model "invalid-model-name" was not found or is invalid.\n` +
|
||||
`/model to switch models.`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with invalid model correctly', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
|
||||
@@ -135,7 +135,20 @@ export function useQuotaAndFallback({
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
isModelNotFoundError = true;
|
||||
if (VALID_GEMINI_MODELS.has(failedModel)) {
|
||||
if (
|
||||
contentGeneratorConfig?.authType === AuthType.USE_VERTEX_AI &&
|
||||
VALID_GEMINI_MODELS.has(failedModel)
|
||||
) {
|
||||
const location =
|
||||
process.env['GOOGLE_CLOUD_LOCATION'] || 'your configured region';
|
||||
const messageLines = [
|
||||
`Model "${failedModel}" is not available in region "${location}".`,
|
||||
`To see which models are available in this region, please visit:`,
|
||||
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations`,
|
||||
`/model to switch models.`,
|
||||
];
|
||||
message = messageLines.join('\n');
|
||||
} else if (VALID_GEMINI_MODELS.has(failedModel)) {
|
||||
const messageLines = [
|
||||
`It seems like you don't have access to ${getDisplayString(failedModel)}.`,
|
||||
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('createContentGenerator', () => {
|
||||
resetVersionCache();
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -483,6 +484,82 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use US REP endpoint for Vertex AI when location is us and no baseUrl is provided', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: expect.objectContaining({
|
||||
clientOptions: expect.objectContaining({
|
||||
apiEndpoint: 'https://aiplatform.us.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://aiplatform.us.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use EU REP endpoint for Vertex AI when location is eu and no baseUrl is provided', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'eu');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: expect.objectContaining({
|
||||
clientOptions: expect.objectContaining({
|
||||
apiEndpoint: 'https://aiplatform.eu.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://aiplatform.eu.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject HttpsProxyAgent into googleAuthOptions when proxy URL uses https://', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -121,6 +121,15 @@ const VERTEX_AI_REQUEST_TYPE_HEADER = 'X-Vertex-AI-LLM-Request-Type';
|
||||
const VERTEX_AI_SHARED_REQUEST_TYPE_HEADER =
|
||||
'X-Vertex-AI-LLM-Shared-Request-Type';
|
||||
|
||||
/**
|
||||
* Vertex AI Representative Endpoints (REP) for US and EU multi-regions.
|
||||
* These are used as a workaround for the client dynamically
|
||||
* constructing default legacy hostnames (e.g., 'us-aiplatform.googleapis.com')
|
||||
* instead of routing to the official REP endpoints.
|
||||
*/
|
||||
const VERTEX_AI_US_REP_ENDPOINT = 'https://aiplatform.us.rep.googleapis.com';
|
||||
const VERTEX_AI_EU_REP_ENDPOINT = 'https://aiplatform.eu.rep.googleapis.com';
|
||||
|
||||
function validateBaseUrl(baseUrl: string): void {
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
@@ -341,6 +350,13 @@ export async function createContentGenerator(
|
||||
if (envBaseUrl) {
|
||||
validateBaseUrl(envBaseUrl);
|
||||
baseUrl = envBaseUrl;
|
||||
} else if (config.authType === AuthType.USE_VERTEX_AI) {
|
||||
const location = process.env['GOOGLE_CLOUD_LOCATION'];
|
||||
if (location === 'us') {
|
||||
baseUrl = VERTEX_AI_US_REP_ENDPOINT;
|
||||
} else if (location === 'eu') {
|
||||
baseUrl = VERTEX_AI_EU_REP_ENDPOINT;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validateBaseUrl(baseUrl);
|
||||
|
||||
@@ -240,4 +240,72 @@ describe('AllowedPathChecker', () => {
|
||||
const result = await checker.check(input);
|
||||
expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
|
||||
});
|
||||
|
||||
describe('Security Regression: Case-Insensitive Blocklist & .vscode HITL', () => {
|
||||
it('should deny sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', async () => {
|
||||
const sensitivePaths = [
|
||||
path.join(mockCwd, '.git', 'config'),
|
||||
path.join(mockCwd, '.GIT', 'config'),
|
||||
path.join(mockCwd, '.Git', 'config'),
|
||||
path.join(mockCwd, '.env'),
|
||||
path.join(mockCwd, '.Env'),
|
||||
path.join(mockCwd, '.ENV'),
|
||||
path.join(mockCwd, 'node_modules', 'package', 'index.js'),
|
||||
path.join(mockCwd, 'NODE_MODULES', 'package', 'index.js'),
|
||||
// Windows trailing character bypasses
|
||||
path.join(mockCwd, '.git ', 'config'),
|
||||
path.join(mockCwd, '.git.', 'config'),
|
||||
path.join(mockCwd, '.env ', 'config'),
|
||||
path.join(mockCwd, '.env.', 'config'),
|
||||
path.join(mockCwd, 'node_modules ', 'package', 'index.js'),
|
||||
// NTFS Alternate Data Stream bypasses
|
||||
path.join(mockCwd, '.git::$DATA', 'config'),
|
||||
path.join(mockCwd, '.env::$DATA'),
|
||||
path.join(mockCwd, 'node_modules::$DATA', 'package', 'index.js'),
|
||||
];
|
||||
|
||||
for (const p of sensitivePaths) {
|
||||
const input = createInput({ path: p });
|
||||
const result = await checker.check(input);
|
||||
expect(result.decision).toBe(SafetyCheckDecision.DENY);
|
||||
expect(result.reason).toContain('Access to sensitive path');
|
||||
}
|
||||
});
|
||||
|
||||
it('should require ASK_USER for .vscode configuration files inside workspace, but deny them if outside, including NTFS ADS bypasses', async () => {
|
||||
const vscodePaths = [
|
||||
path.join(mockCwd, '.vscode', 'settings.json'),
|
||||
path.join(mockCwd, '.vscode', 'settings.JSON'),
|
||||
path.join(mockCwd, '.VSCODE', 'settings.json'),
|
||||
path.join(mockCwd, '.vscode', 'launch.json'),
|
||||
// Windows trailing character bypasses
|
||||
path.join(mockCwd, '.vscode ', 'settings.json'),
|
||||
path.join(mockCwd, '.vscode.', 'settings.json'),
|
||||
// NTFS Alternate Data Stream bypasses
|
||||
path.join(mockCwd, '.vscode::$DATA', 'settings.json'),
|
||||
];
|
||||
|
||||
for (const p of vscodePaths) {
|
||||
const input = createInput({ path: p });
|
||||
const result = await checker.check(input);
|
||||
expect(result.decision).toBe(SafetyCheckDecision.ASK_USER);
|
||||
expect(result.reason).toContain(
|
||||
'Modifying .vscode configuration files requires explicit user confirmation',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that paths outside the workspace containing .vscode are strictly denied
|
||||
const outsideVscodePaths = [
|
||||
path.join(testRootDir, 'outside', '.vscode', 'settings.json'),
|
||||
path.join(testRootDir, 'outside', '.VSCODE', 'settings.json'),
|
||||
];
|
||||
|
||||
for (const p of outsideVscodePaths) {
|
||||
const input = createInput({ path: p });
|
||||
const result = await checker.check(input);
|
||||
expect(result.decision).toBe(SafetyCheckDecision.DENY);
|
||||
expect(result.reason).toContain('outside of the allowed workspace');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
SafetyCheckDecision,
|
||||
type SafetyCheckInput,
|
||||
type SafetyCheckResult,
|
||||
} from './protocol.js';
|
||||
import type { AllowedPathConfig } from '../policy/types.js';
|
||||
import { resolveToRealPath } from '../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Interface for all in-process safety checkers.
|
||||
@@ -45,6 +45,11 @@ export class AllowedPathChecker implements InProcessChecker {
|
||||
excludedArgs,
|
||||
);
|
||||
|
||||
// Resolve allowed directories once outside the loop to avoid redundant filesystem calls
|
||||
const resolvedAllowedDirs = allowedDirs
|
||||
.map((dir) => this.safelyResolvePath(dir, context.environment.cwd))
|
||||
.filter((resolvedDir): resolvedDir is string => resolvedDir !== null);
|
||||
|
||||
// Check each path
|
||||
for (const { path: p, argName } of pathsToCheck) {
|
||||
const resolvedPath = this.safelyResolvePath(p, context.environment.cwd);
|
||||
@@ -57,15 +62,52 @@ export class AllowedPathChecker implements InProcessChecker {
|
||||
};
|
||||
}
|
||||
|
||||
const isAllowed = allowedDirs.some((dir) => {
|
||||
// Also resolve allowed directories to handle symlinks
|
||||
const resolvedDir = this.safelyResolvePath(
|
||||
dir,
|
||||
context.environment.cwd,
|
||||
);
|
||||
if (!resolvedDir) return false;
|
||||
return this.isPathAllowed(resolvedPath, resolvedDir);
|
||||
});
|
||||
// Check for blocked segments case-insensitively
|
||||
let hasBlockedSegment = false;
|
||||
let isVscodePath = false;
|
||||
|
||||
for (const resolvedDir of resolvedAllowedDirs) {
|
||||
if (!this.isPathAllowed(resolvedPath, resolvedDir)) continue;
|
||||
const relative = path.relative(resolvedDir, resolvedPath);
|
||||
const segments = relative.split(path.sep);
|
||||
for (const segment of segments) {
|
||||
const clean = trimTrailingSpacesAndDots(
|
||||
segment.split(':')[0],
|
||||
).toLowerCase();
|
||||
if (
|
||||
clean === '.git' ||
|
||||
clean === '.env' ||
|
||||
clean === 'node_modules'
|
||||
) {
|
||||
hasBlockedSegment = true;
|
||||
}
|
||||
if (clean === '.vscode') {
|
||||
isVscodePath = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockedSegment) {
|
||||
return {
|
||||
decision: SafetyCheckDecision.DENY,
|
||||
reason: `Access to sensitive path "${p}" in argument "${argName}" is blocked.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isVscodePath) {
|
||||
return {
|
||||
decision: SafetyCheckDecision.ASK_USER,
|
||||
reason: `Modifying .vscode configuration files requires explicit user confirmation.`,
|
||||
};
|
||||
}
|
||||
|
||||
let isAllowed = false;
|
||||
for (const resolvedDir of resolvedAllowedDirs) {
|
||||
if (this.isPathAllowed(resolvedPath, resolvedDir)) {
|
||||
isAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
@@ -84,14 +126,15 @@ export class AllowedPathChecker implements InProcessChecker {
|
||||
|
||||
// Walk up the directory tree until we find a path that exists
|
||||
let current = resolved;
|
||||
// Stop at root (dirname(root) === root on many systems, or it becomes empty/'.' depending on implementation)
|
||||
while (current && current !== path.dirname(current)) {
|
||||
if (fs.existsSync(current)) {
|
||||
const canonical = fs.realpathSync(current);
|
||||
try {
|
||||
const canonical = resolveToRealPath(current);
|
||||
// Re-construct the full path from this canonical base
|
||||
const relative = path.relative(current, resolved);
|
||||
// path.join handles empty relative paths correctly (returns canonical)
|
||||
return path.join(canonical, relative);
|
||||
} catch {
|
||||
// Path does not exist, continue walking up
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
@@ -156,3 +199,15 @@ export class AllowedPathChecker implements InProcessChecker {
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims trailing spaces and dots from a string without using regular expressions
|
||||
* to completely eliminate any potential ReDoS (Regular Expression Denial of Service) risk.
|
||||
*/
|
||||
function trimTrailingSpacesAndDots(str: string): string {
|
||||
let end = str.length - 1;
|
||||
while (end >= 0 && (str[end] === ' ' || str[end] === '.')) {
|
||||
end--;
|
||||
}
|
||||
return str.slice(0, end + 1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { vi } from 'vitest';
|
||||
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
|
||||
@@ -17,7 +18,17 @@ export function createMockWorkspaceContext(
|
||||
rootDir: string,
|
||||
additionalDirs: string[] = [],
|
||||
): WorkspaceContext {
|
||||
const allDirs = [rootDir, ...additionalDirs];
|
||||
const resolveToRealPathSafe = (p: string) => {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedRootDir = resolveToRealPathSafe(rootDir);
|
||||
const resolvedAdditionalDirs = additionalDirs.map(resolveToRealPathSafe);
|
||||
const allDirs = [resolvedRootDir, ...resolvedAdditionalDirs];
|
||||
|
||||
const mockWorkspaceContext = {
|
||||
addDirectory: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ReadFileTool } from './read-file.js';
|
||||
import { WriteFileTool, getCorrectedFileContent } from './write-file.js';
|
||||
import { EditTool } from './edit.js';
|
||||
import { correctPath } from '../utils/pathCorrector.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
|
||||
vi.mock('../telemetry/loggers.js', () => ({
|
||||
logFileOperation: vi.fn(),
|
||||
logEditStrategy: vi.fn(),
|
||||
logEditCorrectionEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./jit-context.js', () => ({
|
||||
discoverJitContext: vi.fn().mockResolvedValue(''),
|
||||
appendJitContext: vi.fn().mockImplementation((content) => content),
|
||||
appendJitContextToParts: vi.fn().mockImplementation((content) => content),
|
||||
}));
|
||||
|
||||
describe('Consolidated At-Reference Path Resolution Tests (b-495551283)', () => {
|
||||
let tempRootDir: string;
|
||||
let mockConfigInstance: Config;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temporary root directory for each test run
|
||||
const realTmp = await fsp.realpath(os.tmpdir());
|
||||
tempRootDir = await fsp.mkdtemp(
|
||||
path.join(realTmp, 'at-ref-resolution-root-'),
|
||||
);
|
||||
|
||||
mockConfigInstance = {
|
||||
getFileService: () => new FileDiscoveryService(tempRootDir),
|
||||
getFileSystemService: () => new StandardFileSystemService(),
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getFileFilteringOptions: () => ({
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
|
||||
},
|
||||
isInteractive: () => false,
|
||||
isPlanMode: () => false,
|
||||
getActiveModel: () => undefined,
|
||||
getBaseLlmClient: () => undefined,
|
||||
getDisableLLMCorrection: () => true,
|
||||
isPathAllowed(this: Config, absolutePath: string): boolean {
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
|
||||
return true;
|
||||
}
|
||||
const projectTempDir = this.storage.getProjectTempDir();
|
||||
return isSubpath(path.resolve(projectTempDir), absolutePath);
|
||||
},
|
||||
validatePathAccess(this: Config, absolutePath: string): string | null {
|
||||
if (this.isPathAllowed(absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
const workspaceDirs = this.getWorkspaceContext().getDirectories();
|
||||
const projectTempDir = this.storage.getProjectTempDir();
|
||||
return `Path not in workspace: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
// Create the policies directory and new-policies.txt file
|
||||
await fsp.mkdir(path.join(tempRootDir, 'policies'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tempRootDir, 'policies', 'new-policies.txt'),
|
||||
'[[rule]]\ntoolName = "run_shell_command"\ndecision = "allow"\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up the temporary root directory
|
||||
if (fs.existsSync(tempRootDir)) {
|
||||
await fsp.rm(tempRootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ReadFileTool successfully reads a file when the path is prefixed with @', async () => {
|
||||
const readFileTool = new ReadFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = readFileTool.build({
|
||||
file_path: '@policies/new-policies.txt',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed because it defensively strips the leading '@'
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('toolName = "run_shell_command"');
|
||||
});
|
||||
|
||||
it('ReadFileTool successfully reads a file when the path is prefixed with @/', async () => {
|
||||
const readFileTool = new ReadFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = readFileTool.build({
|
||||
file_path: '@/policies/new-policies.txt',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed because it defensively strips the leading '@/'
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('toolName = "run_shell_command"');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully writes to/updates a file when the path is prefixed with @', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@policies/new-policies.txt',
|
||||
content: '[[rule]]\nupdated_content = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and update the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@policies',
|
||||
'new-policies.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'new-policies.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@policies" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It should have updated the correct file under "policies"
|
||||
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(updatedContent).toContain('updated_content = true');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@policies/brand-new-file.txt',
|
||||
content: '[[rule]]\nbrand_new_file = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@policies',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@policies" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It should have created the correct file under "policies"
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('brand_new_file = true');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment exists', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@policies/sub/brand-new-file.txt',
|
||||
content: '[[rule]]\nnested_brand_new_file = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@policies',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@policies" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It should have created the correct file under "policies/sub"
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_file = true');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@new-policies/sub/brand-new-file.txt',
|
||||
content: '[[rule]]\nnested_brand_new_file = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@new-policies',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'new-policies',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@new-policies" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It SHOULD have created the file under "new-policies/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_file = true');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@/new-policies-alias/sub/brand-new-file.txt',
|
||||
content: '[[rule]]\nnested_brand_new_file_alias = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const literalAtFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@',
|
||||
'new-policies-alias',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'new-policies-alias',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@" directory
|
||||
expect(fs.existsSync(literalAtFilePath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
|
||||
|
||||
// It should have created the file under "new-policies-alias/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_file_alias = true');
|
||||
});
|
||||
|
||||
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
|
||||
const writeFileTool = new WriteFileTool(
|
||||
mockConfigInstance,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
const invocation = writeFileTool.build({
|
||||
file_path: '@\\new-policies-alias-win\\sub\\brand-new-file.txt',
|
||||
content: '[[rule]]\nnested_brand_new_file_alias_win = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const literalAtFilePath = isWindows
|
||||
? path.join(
|
||||
tempRootDir,
|
||||
'@',
|
||||
'new-policies-alias-win',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
)
|
||||
: path.join(
|
||||
tempRootDir,
|
||||
'@\\new-policies-alias-win\\sub\\brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = isWindows
|
||||
? path.join(
|
||||
tempRootDir,
|
||||
'new-policies-alias-win',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
)
|
||||
: path.join(
|
||||
tempRootDir,
|
||||
'new-policies-alias-win\\sub\\brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@" directory
|
||||
expect(fs.existsSync(literalAtFilePath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
|
||||
|
||||
// It should have created the file under "new-policies-alias-win/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_file_alias_win = true');
|
||||
});
|
||||
|
||||
it('getCorrectedFileContent blocks path traversal outside the workspace', async () => {
|
||||
const result = await getCorrectedFileContent(
|
||||
mockConfigInstance,
|
||||
'../../etc/passwd',
|
||||
'malicious content',
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// The utility should fail with a path validation error
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('Path not in workspace');
|
||||
});
|
||||
|
||||
it('EditTool.getModifyContext blocks path traversal outside the workspace', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const modifyContext = editTool.getModifyContext(abortSignal);
|
||||
|
||||
// The getCurrentContent method should throw a path validation error
|
||||
await expect(
|
||||
modifyContext.getCurrentContent({
|
||||
file_path: '../../etc/passwd',
|
||||
instruction: 'read file',
|
||||
old_string: '',
|
||||
new_string: '',
|
||||
}),
|
||||
).rejects.toThrow('Path not in workspace');
|
||||
|
||||
// The getProposedContent method should throw a path validation error
|
||||
await expect(
|
||||
modifyContext.getProposedContent({
|
||||
file_path: '../../etc/passwd',
|
||||
instruction: 'read file',
|
||||
old_string: '',
|
||||
new_string: '',
|
||||
}),
|
||||
).rejects.toThrow('Path not in workspace');
|
||||
});
|
||||
|
||||
it('getCorrectedFileContent handles symlink loops gracefully', async () => {
|
||||
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
|
||||
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
|
||||
await fsp.symlink(symlinkPath2, symlinkPath1);
|
||||
await fsp.symlink(symlinkPath1, symlinkPath2);
|
||||
|
||||
const result = await getCorrectedFileContent(
|
||||
mockConfigInstance,
|
||||
'symlink1',
|
||||
'content',
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// The utility should fail gracefully with a resolution error
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('Failed to resolve path');
|
||||
});
|
||||
|
||||
it('EditTool.getModifyContext handles symlink loops gracefully by throwing a descriptive error', async () => {
|
||||
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
|
||||
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
|
||||
await fsp.symlink(symlinkPath2, symlinkPath1);
|
||||
await fsp.symlink(symlinkPath1, symlinkPath2);
|
||||
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const modifyContext = editTool.getModifyContext(abortSignal);
|
||||
|
||||
// The getCurrentContent method should throw a path resolution error
|
||||
await expect(
|
||||
modifyContext.getCurrentContent({
|
||||
file_path: 'symlink1',
|
||||
instruction: 'read file',
|
||||
old_string: '',
|
||||
new_string: '',
|
||||
}),
|
||||
).rejects.toThrow('Failed to resolve path');
|
||||
|
||||
// The getProposedContent method should throw a path resolution error
|
||||
await expect(
|
||||
modifyContext.getProposedContent({
|
||||
file_path: 'symlink1',
|
||||
instruction: 'read file',
|
||||
old_string: '',
|
||||
new_string: '',
|
||||
}),
|
||||
).rejects.toThrow('Failed to resolve path');
|
||||
});
|
||||
|
||||
it('getCorrectedFileContent successfully resolves paths in Plan Mode', async () => {
|
||||
const plansDir = path.join(tempRootDir, '.plans');
|
||||
await fsp.mkdir(plansDir, { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(plansDir, 'plan-file.txt'),
|
||||
'plan content',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const planConfigInstance = Object.assign({}, mockConfigInstance, {
|
||||
isPlanMode: () => true,
|
||||
getProjectRoot: () => tempRootDir,
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
|
||||
getPlansDir: () => plansDir,
|
||||
},
|
||||
}) as unknown as Config;
|
||||
|
||||
const result = await getCorrectedFileContent(
|
||||
planConfigInstance,
|
||||
'plan-file.txt',
|
||||
'new plan content',
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.originalContent).toBe('plan content');
|
||||
});
|
||||
|
||||
it('EditTool successfully edits an existing file when the path is prefixed with @', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const invocation = editTool.build({
|
||||
file_path: '@policies/new-policies.txt',
|
||||
instruction: 'update decision rule',
|
||||
old_string: 'decision = "allow"',
|
||||
new_string: 'decision = "deny"',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and update the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'new-policies.txt',
|
||||
);
|
||||
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(updatedContent).toContain('decision = "deny"');
|
||||
});
|
||||
|
||||
it('EditTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const invocation = editTool.build({
|
||||
file_path: '@policies/brand-new-edit-file.txt',
|
||||
instruction: 'create new file',
|
||||
old_string: '',
|
||||
new_string: '[[rule]]\nbrand_new_edit_file = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@policies',
|
||||
'brand-new-edit-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'brand-new-edit-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@policies" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It should have created the correct file under "policies"
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('brand_new_edit_file = true');
|
||||
});
|
||||
|
||||
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const invocation = editTool.build({
|
||||
file_path: '@new-policies-edit/sub/brand-new-file.txt',
|
||||
instruction: 'create new file in nested subdirectory',
|
||||
old_string: '',
|
||||
new_string: '[[rule]]\nnested_brand_new_edit_file = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const incorrectFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@new-policies-edit',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'new-policies-edit',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@new-policies-edit" directory
|
||||
expect(fs.existsSync(incorrectFilePath)).toBe(false);
|
||||
|
||||
// It SHOULD have created the file under "new-policies-edit/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_edit_file = true');
|
||||
});
|
||||
|
||||
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const invocation = editTool.build({
|
||||
file_path: '@/new-policies-edit-alias/sub/brand-new-file.txt',
|
||||
instruction: 'create new file in nested subdirectory',
|
||||
old_string: '',
|
||||
new_string: '[[rule]]\nnested_brand_new_edit_file_alias = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const literalAtFilePath = path.join(
|
||||
tempRootDir,
|
||||
'@',
|
||||
'new-policies-edit-alias',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = path.join(
|
||||
tempRootDir,
|
||||
'new-policies-edit-alias',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@" directory
|
||||
expect(fs.existsSync(literalAtFilePath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
|
||||
|
||||
// It should have created the file under "new-policies-edit-alias/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain('nested_brand_new_edit_file_alias = true');
|
||||
});
|
||||
|
||||
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
|
||||
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
|
||||
const invocation = editTool.build({
|
||||
file_path: '@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
|
||||
instruction: 'create new file in nested subdirectory',
|
||||
old_string: '',
|
||||
new_string: '[[rule]]\nnested_brand_new_edit_file_alias_win = true\n',
|
||||
});
|
||||
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
|
||||
// The tool should succeed and create the correct file
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const literalAtFilePath = isWindows
|
||||
? path.join(
|
||||
tempRootDir,
|
||||
'@',
|
||||
'new-policies-edit-alias-win',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
)
|
||||
: path.join(
|
||||
tempRootDir,
|
||||
'@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
|
||||
);
|
||||
const correctFilePath = isWindows
|
||||
? path.join(
|
||||
tempRootDir,
|
||||
'new-policies-edit-alias-win',
|
||||
'sub',
|
||||
'brand-new-file.txt',
|
||||
)
|
||||
: path.join(
|
||||
tempRootDir,
|
||||
'new-policies-edit-alias-win\\sub\\brand-new-file.txt',
|
||||
);
|
||||
|
||||
// It should NOT have created a literal "@" directory
|
||||
expect(fs.existsSync(literalAtFilePath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
|
||||
|
||||
// It should have created the file under "new-policies-edit-alias-win/sub"
|
||||
expect(fs.existsSync(correctFilePath)).toBe(true);
|
||||
|
||||
// Verify the content of the created file
|
||||
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
|
||||
expect(createdContent).toContain(
|
||||
'nested_brand_new_edit_file_alias_win = true',
|
||||
);
|
||||
});
|
||||
|
||||
it('correctPath successfully resolves a path prefixed with @ to its clean counterpart', () => {
|
||||
const result = correctPath(
|
||||
'@policies/new-policies.txt',
|
||||
mockConfigInstance,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const expectedPath = path.join(
|
||||
tempRootDir,
|
||||
'policies',
|
||||
'new-policies.txt',
|
||||
);
|
||||
expect(result.correctedPath).toBe(expectedPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,10 @@ describe('EditTool', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'edit-tool-test-'),
|
||||
);
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
rootDir = path.join(tempDir, 'root');
|
||||
fs.mkdirSync(rootDir);
|
||||
|
||||
@@ -701,6 +704,30 @@ function doIt() {
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should sanitize null bytes in absolute path during validation', () => {
|
||||
const badPath = path.resolve(rootDir, 'test\0.txt');
|
||||
const params: EditToolParams = {
|
||||
file_path: badPath,
|
||||
instruction: 'An instruction',
|
||||
old_string: 'old',
|
||||
new_string: 'new',
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should sanitize null bytes in absolute path during invocation setup', () => {
|
||||
const badPath = path.resolve(rootDir, 'test\0.txt');
|
||||
const invocation = tool.build({
|
||||
file_path: badPath,
|
||||
instruction: 'test',
|
||||
old_string: 'old',
|
||||
new_string: 'new',
|
||||
});
|
||||
expect((invocation as any).resolvedPath).toBe(
|
||||
path.resolve(rootDir, 'test.txt'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
|
||||
+114
-17
@@ -27,7 +27,12 @@ import {
|
||||
import { buildFilePathArgsPattern } from '../policy/utils.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { correctPath } from '../utils/pathCorrector.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -478,11 +483,13 @@ class EditToolInvocation
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
this.resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'Failed to resolve plan path during EditTool invocation setup',
|
||||
@@ -490,20 +497,39 @@ class EditToolInvocation
|
||||
);
|
||||
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
|
||||
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
|
||||
this.resolvedPath = this.params.file_path;
|
||||
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
|
||||
}
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
this.resolvedPath = result.correctedPath;
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(result.correctedPath);
|
||||
} catch {
|
||||
this.resolvedPath = result.correctedPath;
|
||||
}
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.resolvedPath = this.params.file_path;
|
||||
const cleanPath = this.params.file_path.replace(/\0/g, '');
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(cleanPath);
|
||||
} catch {
|
||||
this.resolvedPath = cleanPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,28 +1120,45 @@ export class EditTool
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
params.file_path,
|
||||
const cleanFilePath = params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else if (!path.isAbsolute(params.file_path)) {
|
||||
const result = correctPath(params.file_path, this.config);
|
||||
if (result.success) {
|
||||
resolvedPath = result.correctedPath;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(result.correctedPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolvedPath = params.file_path;
|
||||
const cleanPath = params.file_path.replace(/\0/g, '');
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(cleanPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
const newPlaceholders = detectOmissionPlaceholders(params.new_string);
|
||||
if (newPlaceholders.length > 0) {
|
||||
const oldPlaceholders = new Set(
|
||||
@@ -1150,13 +1193,66 @@ export class EditTool
|
||||
}
|
||||
|
||||
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {
|
||||
const resolvePath = (params: EditToolParams): string => {
|
||||
let pathBeforeRealResolve: string;
|
||||
|
||||
try {
|
||||
if (this.config.isPlanMode()) {
|
||||
const cleanFilePath = params.file_path.replace(/\0/g, '');
|
||||
pathBeforeRealResolve = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} else if (!path.isAbsolute(params.file_path)) {
|
||||
const result = correctPath(params.file_path, this.config);
|
||||
if (result.success) {
|
||||
pathBeforeRealResolve = result.correctedPath;
|
||||
} else {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
pathBeforeRealResolve = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pathBeforeRealResolve = params.file_path.replace(/\0/g, '');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Failed to resolve path: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveToRealPath(pathBeforeRealResolve);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'Failed to resolve path: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(resolved);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
return {
|
||||
getFilePath: (params: EditToolParams) => params.file_path,
|
||||
getCurrentContent: async (params: EditToolParams): Promise<string> => {
|
||||
try {
|
||||
const resolvedPath = resolvePath(params);
|
||||
return await this.config
|
||||
.getFileSystemService()
|
||||
.readTextFile(params.file_path);
|
||||
.readTextFile(resolvedPath);
|
||||
} catch (err) {
|
||||
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
@@ -1164,9 +1260,10 @@ export class EditTool
|
||||
},
|
||||
getProposedContent: async (params: EditToolParams): Promise<string> => {
|
||||
try {
|
||||
const resolvedPath = resolvePath(params);
|
||||
const currentContent = await this.config
|
||||
.getFileSystemService()
|
||||
.readTextFile(params.file_path);
|
||||
.readTextFile(resolvedPath);
|
||||
return applyReplacement(
|
||||
currentContent,
|
||||
params.old_string,
|
||||
|
||||
@@ -37,7 +37,10 @@ describe('GlobTool', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique root directory for each test run
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));
|
||||
const rawTempRootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'glob-tool-root-'),
|
||||
);
|
||||
tempRootDir = await fs.realpath(rawTempRootDir);
|
||||
await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo
|
||||
|
||||
const rootDir = tempRootDir;
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import { shortenPath, makeRelative } from '../utils/paths.js';
|
||||
import {
|
||||
shortenPath,
|
||||
makeRelative,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
@@ -138,10 +142,22 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
// If a specific path is provided, resolve it and check if it's within workspace
|
||||
let searchDirectories: readonly string[];
|
||||
if (this.params.dir_path) {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.dir_path,
|
||||
);
|
||||
let searchDirAbsolute: string;
|
||||
try {
|
||||
searchDirAbsolute = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), this.params.dir_path),
|
||||
);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
llmContent: errMsg,
|
||||
returnDisplay: 'Path resolution failed.',
|
||||
error: {
|
||||
message: errMsg,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbsolute,
|
||||
'read',
|
||||
@@ -189,9 +205,22 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
|
||||
const relativePaths = allEntries.map((p) =>
|
||||
path.relative(this.config.getTargetDir(), p.fullpath()),
|
||||
);
|
||||
let realTargetDir = this.config.getTargetDir();
|
||||
try {
|
||||
realTargetDir = resolveToRealPath(realTargetDir);
|
||||
} catch {
|
||||
// Ignore and use raw targetDir
|
||||
}
|
||||
|
||||
const relativePaths = allEntries.map((p) => {
|
||||
let realFullPath = p.fullpath();
|
||||
try {
|
||||
realFullPath = resolveToRealPath(realFullPath);
|
||||
} catch {
|
||||
// Ignore and use raw fullpath
|
||||
}
|
||||
return path.relative(realTargetDir, realFullPath);
|
||||
});
|
||||
|
||||
const { filteredPaths, ignoredCount } =
|
||||
fileDiscovery.filterFilesWithReport(relativePaths, {
|
||||
@@ -304,10 +333,14 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GlobToolParams,
|
||||
): string | null {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path || '.',
|
||||
);
|
||||
let searchDirAbsolute: string;
|
||||
try {
|
||||
searchDirAbsolute = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), params.dir_path || '.'),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbsolute,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { GrepTool, type GrepToolParams } from './grep.js';
|
||||
import type { ToolResult, GrepResult, ExecuteOptions } from './tools.js';
|
||||
import path from 'node:path';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -156,7 +156,7 @@ describe('GrepTool', () => {
|
||||
});
|
||||
|
||||
it('should return error if path is a file, not a directory', async () => {
|
||||
const filePath = path.join(tempRootDir, 'fileA.txt');
|
||||
const filePath = resolveToRealPath(path.join(tempRootDir, 'fileA.txt'));
|
||||
const params: GrepToolParams = { pattern: 'hello', dir_path: filePath };
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -146,7 +150,21 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
let searchDirAbs: string | null = null;
|
||||
if (pathParam) {
|
||||
searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
|
||||
try {
|
||||
searchDirAbs = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), pathParam),
|
||||
);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
llmContent: errMsg,
|
||||
returnDisplay: 'Error: Path resolution failed.',
|
||||
error: {
|
||||
message: errMsg,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbs,
|
||||
'read',
|
||||
@@ -722,10 +740,14 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
|
||||
// Only validate dir_path if one is provided
|
||||
if (params.dir_path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path,
|
||||
);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), params.dir_path),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
const validationError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -74,10 +79,20 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -242,11 +257,20 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
return "The 'file_path' parameter must be non-empty.";
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch (err) {
|
||||
return `Failed to resolve path: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
|
||||
@@ -398,7 +398,7 @@ describe('ReadManyFilesTool', () => {
|
||||
});
|
||||
|
||||
it('should NOT use default excludes if useDefaultExcludes is false', async () => {
|
||||
createFile('node_modules/some-lib/index.js', 'lib code');
|
||||
createFile('dist/some-lib/index.js', 'lib code');
|
||||
createFile('src/app.js', 'app code');
|
||||
const params = { include: ['**/*.js'], useDefaultExcludes: false };
|
||||
const invocation = tool.build(params);
|
||||
@@ -406,10 +406,7 @@ describe('ReadManyFilesTool', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath1 = path.join(
|
||||
tempRootDir,
|
||||
'node_modules/some-lib/index.js',
|
||||
);
|
||||
const expectedPath1 = path.join(tempRootDir, 'dist/some-lib/index.js');
|
||||
const expectedPath2 = path.join(tempRootDir, 'src/app.js');
|
||||
expect(
|
||||
content.some((c) =>
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock('../utils/paths.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/paths.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveToRealPath: vi.fn((p) => p),
|
||||
resolveToRealPath: vi.fn((p) => actual.resolveToRealPath(p)),
|
||||
normalizePath: vi.fn((p) =>
|
||||
typeof p === 'string' ? p.replace(/\\/g, '/') : p,
|
||||
),
|
||||
@@ -1351,7 +1351,9 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should add .geminiignore when enabled and patterns exist', async () => {
|
||||
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
|
||||
const geminiIgnorePath = resolveToRealPath(
|
||||
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
|
||||
);
|
||||
await fs.writeFile(geminiIgnorePath, 'ignored.log');
|
||||
|
||||
const configWithGeminiIgnore = createMockConfig(tempRootDir);
|
||||
@@ -1395,7 +1397,9 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should skip .geminiignore when disabled', async () => {
|
||||
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
|
||||
const geminiIgnorePath = resolveToRealPath(
|
||||
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
|
||||
);
|
||||
await fs.writeFile(geminiIgnorePath, 'ignored.log');
|
||||
const configWithoutGeminiIgnore = createMockConfig(tempRootDir);
|
||||
vi.spyOn(
|
||||
|
||||
@@ -185,7 +185,22 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
// This forces CWD search instead of 'all workspaces' search by default.
|
||||
const pathParam = this.params.dir_path || '.';
|
||||
|
||||
const searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
|
||||
let searchDirAbs: string;
|
||||
try {
|
||||
searchDirAbs = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), pathParam),
|
||||
);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
llmContent: errMsg,
|
||||
returnDisplay: 'Error: Path resolution failed.',
|
||||
error: {
|
||||
message: errMsg,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbs,
|
||||
'read',
|
||||
@@ -624,8 +639,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
true, // isOutputMarkdown
|
||||
false, // canUpdateOutput
|
||||
);
|
||||
let targetDir = config.getTargetDir();
|
||||
try {
|
||||
targetDir = resolveToRealPath(targetDir);
|
||||
} catch {
|
||||
// Ignore and use raw targetDir
|
||||
}
|
||||
this.fileDiscoveryService = new FileDiscoveryService(
|
||||
config.getTargetDir(),
|
||||
targetDir,
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
}
|
||||
@@ -670,10 +691,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.dir_path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path,
|
||||
);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), params.dir_path),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
const validationError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Tracker Tools Integration', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
|
||||
config = new Config({
|
||||
sessionId: 'test-session',
|
||||
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
|
||||
targetDir: tempDir,
|
||||
cwd: tempDir,
|
||||
model: 'gemini-3-flash',
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { Config } from '../config/config.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import type { ToolRegistry } from './tool-registry.js';
|
||||
import path from 'node:path';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
getMockMessageBusInstance,
|
||||
} from '../test-utils/mock-message-bus.js';
|
||||
|
||||
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
|
||||
const plansDir = path.resolve(os.tmpdir(), 'gemini-cli-test-plans');
|
||||
let rootDir: string;
|
||||
let plansDir: string;
|
||||
|
||||
// --- MOCKS ---
|
||||
vi.mock('../core/client.js');
|
||||
@@ -134,16 +134,20 @@ describe('WriteFileTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Create a unique temporary directory for files created outside the root
|
||||
tempDir = fs.mkdtempSync(
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'write-file-test-external-'),
|
||||
);
|
||||
// Ensure the rootDir and plansDir for the tool exists
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(plansDir)) {
|
||||
fs.mkdirSync(plansDir, { recursive: true });
|
||||
}
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
|
||||
const rawRootDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-root-'),
|
||||
);
|
||||
rootDir = fs.realpathSync(rawRootDir);
|
||||
|
||||
const rawPlansDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-plans-'),
|
||||
);
|
||||
plansDir = fs.realpathSync(rawPlansDir);
|
||||
|
||||
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
|
||||
const mockStorage = {
|
||||
@@ -272,8 +276,9 @@ describe('WriteFileTool', () => {
|
||||
file_path: dirAsFilePath,
|
||||
content: 'hello',
|
||||
};
|
||||
const realDirAsFilePath = resolveToRealPath(dirAsFilePath);
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
`Path is a directory, not a file: ${dirAsFilePath}`,
|
||||
`Path is a directory, not a file: ${realDirAsFilePath}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -441,7 +446,8 @@ describe('WriteFileTool', () => {
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(fsService.readTextFile).toHaveBeenCalledWith(filePath);
|
||||
const realFilePath = resolveToRealPath(filePath);
|
||||
expect(fsService.readTextFile).toHaveBeenCalledWith(realFilePath);
|
||||
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
|
||||
expect(result.correctedContent).toBe(proposedContent);
|
||||
expect(result.originalContent).toBe('');
|
||||
@@ -1014,8 +1020,9 @@ describe('WriteFileTool', () => {
|
||||
|
||||
expect(result.error?.type).toBe(errorType);
|
||||
const errorSuffix = errorCode ? ` (${errorCode})` : '';
|
||||
const realFilePath = resolveToRealPath(filePath);
|
||||
const expectedMessage = errorCode
|
||||
? `${expectedMessagePrefix}: ${filePath}${errorSuffix}`
|
||||
? `${expectedMessagePrefix}: ${realFilePath}${errorSuffix}`
|
||||
: `${expectedMessagePrefix}: ${errorMessage}`;
|
||||
expect(result.llmContent).toContain(expectedMessage);
|
||||
expect(result.returnDisplay).toContain(expectedMessage);
|
||||
|
||||
@@ -28,7 +28,12 @@ import {
|
||||
} from './tools.js';
|
||||
import { buildFilePathArgsPattern } from '../policy/utils.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { ensureCorrectFileContent } from '../utils/editCorrector.js';
|
||||
import { detectLineEnding } from '../utils/textUtils.js';
|
||||
@@ -109,10 +114,67 @@ export async function getCorrectedFileContent(
|
||||
let fileExists = false;
|
||||
let correctedContent = proposedContent;
|
||||
|
||||
let resolvedPath: string;
|
||||
if (config.isPlanMode()) {
|
||||
try {
|
||||
const cleanFilePath = filePath.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
config.storage.getPlansDir(),
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (err) {
|
||||
return {
|
||||
originalContent: '',
|
||||
correctedContent: proposedContent,
|
||||
fileExists: false,
|
||||
error: {
|
||||
message:
|
||||
'Failed to resolve plan path: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
code: 'EINVAL',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
filePath,
|
||||
config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
originalContent: '',
|
||||
correctedContent: proposedContent,
|
||||
fileExists: false,
|
||||
error: {
|
||||
message:
|
||||
'Failed to resolve path: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
code: 'EINVAL',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const validationError = config.validatePathAccess(resolvedPath);
|
||||
if (validationError) {
|
||||
return {
|
||||
originalContent: '',
|
||||
correctedContent: proposedContent,
|
||||
fileExists: false,
|
||||
error: { message: validationError, code: 'EACCES' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
originalContent = await config
|
||||
.getFileSystemService()
|
||||
.readTextFile(filePath);
|
||||
.readTextFile(resolvedPath);
|
||||
fileExists = true; // File exists and was read
|
||||
} catch (err) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
@@ -170,24 +232,36 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
this.resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'Failed to resolve plan path during WriteFileTool invocation setup',
|
||||
e,
|
||||
);
|
||||
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
|
||||
this.resolvedPath = this.params.file_path;
|
||||
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
|
||||
}
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,16 +599,28 @@ export class WriteFileTool
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
filePath,
|
||||
const cleanFilePath = filePath.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(resolvedPath);
|
||||
|
||||
@@ -261,6 +261,7 @@ describe('fetch utils', () => {
|
||||
it('should fall back to no_proxy if NO_PROXY is not set', () => {
|
||||
const proxyUrl = 'http://proxy.example.com';
|
||||
const noProxyValue = 'localhost,127.0.0.1';
|
||||
vi.stubEnv('NO_PROXY', undefined);
|
||||
vi.stubEnv('no_proxy', noProxyValue);
|
||||
|
||||
setGlobalProxy(proxyUrl);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { processImports, validateImportPath } from './memoryImportProcessor.js';
|
||||
@@ -867,5 +869,46 @@ describe('memoryImportProcessor', () => {
|
||||
);
|
||||
expect(validateImportPath(dotPath, basePath, [allowedPath])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject paths that escape allowed directories via symbolic links', () => {
|
||||
const tmpDir = fsSync.realpathSync(os.tmpdir());
|
||||
const testRoot = fsSync.mkdtempSync(path.join(tmpDir, 'gemini-test-'));
|
||||
const allowedDir = path.join(testRoot, 'allowed');
|
||||
const outsideDir = path.join(testRoot, 'outside');
|
||||
const symlinkDir = path.join(allowedDir, 'sym_outside');
|
||||
|
||||
try {
|
||||
// Create real directories and files on disk
|
||||
fsSync.mkdirSync(allowedDir, { recursive: true });
|
||||
fsSync.mkdirSync(outsideDir, { recursive: true });
|
||||
fsSync.writeFileSync(path.join(outsideDir, 'sensitive.md'), 'secret');
|
||||
|
||||
// Create a symbolic link pointing outside the allowed directory
|
||||
try {
|
||||
fsSync.symlinkSync(outsideDir, symlinkDir, 'dir');
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'EPERM'
|
||||
) {
|
||||
// Skip the test if the user lacks symlink creation privileges on Windows
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const importPath = 'sym_outside/sensitive.md';
|
||||
|
||||
expect(validateImportPath(importPath, allowedDir, [allowedDir])).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
// Cleanup
|
||||
fsSync.rmSync(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { isSubpath } from './paths.js';
|
||||
import { isSubpath, resolveToRealPath } from './paths.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
// Simple console logger for import processing
|
||||
@@ -397,9 +397,28 @@ export function validateImportPath(
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(basePath, importPath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
// Canonicalize the path on the actual physical disk to resolve symlinks
|
||||
resolvedPath = resolveToRealPath(path.resolve(basePath, importPath));
|
||||
} catch {
|
||||
// If path resolution fails (e.g., infinite recursion or invalid path), fail-closed and reject it
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedDirectories.some((allowedDir) =>
|
||||
isSubpath(allowedDir, resolvedPath),
|
||||
const realAllowedDirs = allowedDirectories
|
||||
.map((dir) => {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return resolveToRealPath(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((dir): dir is string => dir !== null);
|
||||
|
||||
return realAllowedDirs.some((realAllowedDir) =>
|
||||
isSubpath(realAllowedDir, resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ describe('pathCorrector', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-corrector-test-'));
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'path-corrector-test-'),
|
||||
);
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
rootDir = path.join(tempDir, 'root');
|
||||
otherWorkspaceDir = path.join(tempDir, 'other');
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { bfsFileSearchSync } from './bfsFileSearch.js';
|
||||
import { resolveDefensiveToolPath } from './paths.js';
|
||||
|
||||
type SuccessfulPathCorrection = {
|
||||
success: true;
|
||||
@@ -34,8 +35,13 @@ export function correctPath(
|
||||
filePath: string,
|
||||
config: Config,
|
||||
): PathCorrectionResult {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
filePath,
|
||||
config.getTargetDir(),
|
||||
);
|
||||
|
||||
// Check for direct path relative to the primary target directory.
|
||||
const directPath = path.join(config.getTargetDir(), filePath);
|
||||
const directPath = path.join(config.getTargetDir(), sanitizedPath);
|
||||
if (fs.existsSync(directPath)) {
|
||||
return { success: true, correctedPath: directPath };
|
||||
}
|
||||
@@ -43,8 +49,8 @@ export function correctPath(
|
||||
// If not found directly, search across all workspace directories for ambiguous matches.
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const searchPaths = workspaceContext.getDirectories();
|
||||
const basename = path.basename(filePath);
|
||||
const normalizedTarget = filePath.replace(/\\/g, '/');
|
||||
const basename = path.basename(sanitizedPath);
|
||||
const normalizedTarget = sanitizedPath.replace(/\\/g, '/');
|
||||
|
||||
// Normalize path for matching and check if it ends with the provided relative path
|
||||
const foundFiles = searchPaths
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toAbsolutePath,
|
||||
toPathKey,
|
||||
isTrustedSystemPath,
|
||||
resolveDefensiveToolPath,
|
||||
} from './paths.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -918,4 +919,20 @@ describe('normalizePath', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDefensiveToolPath', () => {
|
||||
it('should sanitize paths by stripping null bytes', () => {
|
||||
const targetDir = '/workspace';
|
||||
const filePathWithNull = 'src/index.ts\0.exe';
|
||||
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
|
||||
expect(result).toBe('src/index.ts.exe');
|
||||
});
|
||||
|
||||
it('should sanitize @ prefixed paths by stripping null bytes', () => {
|
||||
const targetDir = '/workspace';
|
||||
const filePathWithNull = '@/components/Button.tsx\0';
|
||||
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
|
||||
expect(result).toBe('components/Button.tsx');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -572,3 +572,54 @@ export function isTrustedSystemPath(filePath: string): boolean {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively resolves and sanitizes a file path generated by the LLM,
|
||||
* stripping user-facing reference prefixes if necessary.
|
||||
*/
|
||||
export function resolveDefensiveToolPath(
|
||||
filePath: string,
|
||||
targetDir: string,
|
||||
): string {
|
||||
const cleanPath = filePath.replace(/\0/g, '');
|
||||
|
||||
try {
|
||||
const literalPath = path.resolve(targetDir, cleanPath);
|
||||
|
||||
// If the file literally exists on disk as-is, return the resolved literal path immediately
|
||||
if (fs.existsSync(literalPath)) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
// If the model supplied a leading @ prefix and the literal path doesn't exist:
|
||||
if (cleanPath.startsWith('@') && cleanPath.length > 1) {
|
||||
if (cleanPath.startsWith('@/') || cleanPath.startsWith('@\\')) {
|
||||
const stripped = cleanPath.substring(1).replace(/^[\\/]+/, '');
|
||||
return stripped.length > 0 ? stripped : cleanPath;
|
||||
}
|
||||
|
||||
const strippedPath = cleanPath.substring(1).replace(/^[\\/]+/, '');
|
||||
|
||||
// Check if a literal directory/file starting with '@' exists for the first segment.
|
||||
// If it does, we should preserve the '@' prefix.
|
||||
const parts = strippedPath.split(/[\\/]/);
|
||||
const firstSegment = parts[0];
|
||||
if (firstSegment) {
|
||||
const literalFirstSegment = path.resolve(targetDir, '@' + firstSegment);
|
||||
if (fs.existsSync(literalFirstSegment)) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
// Otherwise, strip the '@' prefix to resolve to the standard directory name,
|
||||
// preventing the accidental creation of literal '@'-prefixed directories (e.g. '@src', '@policies')
|
||||
// when creating new files or directories.
|
||||
return strippedPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to original path if any filesystem or resolution error occurs
|
||||
}
|
||||
|
||||
// Fallback: return the original path
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
@@ -492,4 +492,50 @@ describe('WorkspaceContext with optional directories', () => {
|
||||
expect(directories).toEqual([cwd, existingDir1]);
|
||||
expect(debugLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Security Regression: Case-Insensitive Sensitive Path Blocklist', () => {
|
||||
it('should reject sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', () => {
|
||||
const workspaceContext = new WorkspaceContext(cwd);
|
||||
|
||||
const sensitivePaths = [
|
||||
path.join(cwd, '.git', 'config'),
|
||||
path.join(cwd, '.GIT', 'config'),
|
||||
path.join(cwd, '.Git', 'config'),
|
||||
path.join(cwd, '.env'),
|
||||
path.join(cwd, '.Env'),
|
||||
path.join(cwd, '.ENV'),
|
||||
path.join(cwd, 'node_modules', 'package', 'index.js'),
|
||||
path.join(cwd, 'NODE_MODULES', 'package', 'index.js'),
|
||||
// Windows trailing character bypasses
|
||||
path.join(cwd, '.git ', 'config'),
|
||||
path.join(cwd, '.git.', 'config'),
|
||||
path.join(cwd, '.env ', 'config'),
|
||||
path.join(cwd, '.env.', 'config'),
|
||||
path.join(cwd, 'node_modules ', 'package', 'index.js'),
|
||||
// NTFS Alternate Data Stream bypasses
|
||||
path.join(cwd, '.git::$DATA', 'config'),
|
||||
path.join(cwd, '.env::$DATA'),
|
||||
path.join(cwd, 'node_modules::$DATA', 'package', 'index.js'),
|
||||
];
|
||||
|
||||
for (const p of sensitivePaths) {
|
||||
expect(workspaceContext.isPathWithinWorkspace(p)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow standard non-sensitive paths', () => {
|
||||
const workspaceContext = new WorkspaceContext(cwd);
|
||||
|
||||
const safePaths = [
|
||||
path.join(cwd, 'src', 'index.ts'),
|
||||
path.join(cwd, '.gitignore'),
|
||||
path.join(cwd, '.env.example'),
|
||||
path.join(cwd, 'package.json'),
|
||||
];
|
||||
|
||||
for (const p of safePaths) {
|
||||
expect(workspaceContext.isPathWithinWorkspace(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,20 @@ export class WorkspaceContext {
|
||||
|
||||
for (const dir of this.directories) {
|
||||
if (this.isPathWithinRoot(fullyResolvedPath, dir)) {
|
||||
// Check for blocked segments case-insensitively
|
||||
const relative = path.relative(dir, fullyResolvedPath);
|
||||
const segments = relative.split(path.sep);
|
||||
const hasBlockedSegment = segments.some((segment) => {
|
||||
const clean = trimTrailingSpacesAndDots(
|
||||
segment.split(':')[0],
|
||||
).toLowerCase();
|
||||
return (
|
||||
clean === '.git' || clean === '.env' || clean === 'node_modules'
|
||||
);
|
||||
});
|
||||
if (hasBlockedSegment) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -248,3 +262,15 @@ export class WorkspaceContext {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims trailing spaces and dots from a string without using regular expressions
|
||||
* to completely eliminate any potential ReDoS (Regular Expression Denial of Service) risk.
|
||||
*/
|
||||
function trimTrailingSpacesAndDots(str: string): string {
|
||||
let end = str.length - 1;
|
||||
while (end >= 0 && (str[end] === ' ' || str[end] === '.')) {
|
||||
end--;
|
||||
}
|
||||
return str.slice(0, end + 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ if (process.env.CI) {
|
||||
.filter((name) => name !== '@google/gemini-cli-core');
|
||||
|
||||
execSync(
|
||||
`npx npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
`npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
{ stdio: 'inherit', cwd: root },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,25 +10,40 @@
|
||||
* @fileoverview CLI entry point for the eval inventory command.
|
||||
*
|
||||
* Scans all eval source files, runs the static analyzer on each,
|
||||
* and prints a human-readable inventory report grouped by policy,
|
||||
* file, and suite.
|
||||
* and prints an inventory report grouped by policy, file, and suite.
|
||||
*
|
||||
* Usage:
|
||||
* npm run eval:inventory
|
||||
* npm run eval:inventory -- --json
|
||||
* npm run eval:inventory -- --root /path/to/repo
|
||||
* npm run eval:inventory -- --root /path/to/repo --json
|
||||
*/
|
||||
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
} from './utils/eval-inventory.js';
|
||||
|
||||
async function main() {
|
||||
const rootFlagIndex = process.argv.indexOf('--root');
|
||||
const repoRoot =
|
||||
rootFlagIndex !== -1 && process.argv[rootFlagIndex + 1]
|
||||
? process.argv[rootFlagIndex + 1]
|
||||
: process.cwd();
|
||||
const rootFlagValue =
|
||||
rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined;
|
||||
if (rootFlagIndex !== -1 && rootFlagValue === undefined) {
|
||||
console.error(
|
||||
'Error: --root requires a directory path argument but none was provided.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (rootFlagValue && rootFlagValue.startsWith('--')) {
|
||||
console.error(
|
||||
`Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const repoRoot = rootFlagValue ?? process.cwd();
|
||||
|
||||
const jsonMode = process.argv.includes('--json');
|
||||
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
@@ -37,7 +52,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatInventoryReport(result));
|
||||
console.log(
|
||||
jsonMode ? formatInventoryJson(result) : formatInventoryReport(result),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -159,12 +159,37 @@ function detectRollbackAndGetBaseline({ args, npmDistTag } = {}) {
|
||||
// Sort by semver to get a list from highest to lowest
|
||||
matchingVersions.sort((a, b) => semver.rcompare(a, b));
|
||||
|
||||
// Find the highest non-deprecated version
|
||||
// Find the highest non-deprecated version with a git tag
|
||||
let highestExistingVersion = '';
|
||||
for (const version of matchingVersions) {
|
||||
if (!isVersionDeprecated({ version, args })) {
|
||||
highestExistingVersion = version;
|
||||
break; // Found the one we want
|
||||
try {
|
||||
// Only consider versions that have a corresponding git tag.
|
||||
// This prevents picking up versions that were published to NPM but failed before the github release/tag.
|
||||
let tagExists = false;
|
||||
try {
|
||||
execSync(`git rev-parse v${version}^{commit} 2>/dev/null`);
|
||||
tagExists = true;
|
||||
} catch {
|
||||
const remoteTag = execSync(
|
||||
`git ls-remote --tags origin refs/tags/v${version} 2>/dev/null`,
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
if (remoteTag) {
|
||||
tagExists = true;
|
||||
}
|
||||
}
|
||||
if (!tagExists) {
|
||||
throw new Error(`Tag v${version} not found`);
|
||||
}
|
||||
highestExistingVersion = version;
|
||||
break; // Found the one we want
|
||||
} catch {
|
||||
console.error(
|
||||
`Ignoring version ${version} because it lacks a git tag (likely a failed release).`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(`Ignoring deprecated version: ${version}`);
|
||||
}
|
||||
|
||||
@@ -279,4 +279,235 @@ describe('eval-analysis', () => {
|
||||
'Could not statically resolve eval case object for evalTest call.',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('tool reference extraction', () => {
|
||||
it('extracts tool from waitForToolCall string literal', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'grep test',
|
||||
prompt: 'find something',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('extracts tool from toolRequest.name comparison', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'shell test',
|
||||
prompt: 'run a command',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['run_shell_command']);
|
||||
});
|
||||
|
||||
it('extracts multiple tools from array includes', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'edit test',
|
||||
prompt: 'edit a file',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const editCalls = logs.filter(
|
||||
(log) => ['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([
|
||||
'replace',
|
||||
'write_file',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts tool from imported constant', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'tracker test',
|
||||
prompt: 'create a task',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
|
||||
});
|
||||
|
||||
it('deduplicates references within a case', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'dedup test',
|
||||
prompt: 'search twice',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => log.toolRequest.name === 'grep_search',
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('sorts references alphabetically', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'sorted test',
|
||||
prompt: 'do things',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('write_file');
|
||||
await rig.waitForToolCall('grep_search');
|
||||
await rig.waitForToolCall('glob');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([
|
||||
'glob',
|
||||
'grep_search',
|
||||
'write_file',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no tool refs found', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'no tools',
|
||||
prompt: 'just answer',
|
||||
assert: async (rig, result) => {
|
||||
expect(result).toContain('hello');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates file-level toolReferences across cases', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 1',
|
||||
prompt: 'first',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 2',
|
||||
prompt: 'second',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('write_file');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.toolReferences).toEqual(['grep_search', 'write_file']);
|
||||
});
|
||||
|
||||
it('deduplicates file-level toolReferences', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 1',
|
||||
prompt: 'first',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'case 2',
|
||||
prompt: 'second',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
|
||||
it('handles aliased constant imports', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME as CREATE_TOOL } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'alias test',
|
||||
prompt: 'create task',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForToolCall(CREATE_TOOL);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['tracker_create_task']);
|
||||
});
|
||||
|
||||
it('handles reversed toolRequest.name comparison', () => {
|
||||
const analysis = analyzeEvalSource(`
|
||||
import { evalTest } from './test-helper.js';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'reversed compare',
|
||||
prompt: 'do something',
|
||||
assert: async (rig) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const calls = logs.filter(
|
||||
(log) => 'replace' === log.toolRequest.name,
|
||||
);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['replace']);
|
||||
});
|
||||
|
||||
it('extracts tools from real grep_search eval pattern', () => {
|
||||
const analysis = analyzeEvalSource(
|
||||
`
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
|
||||
describe('grep_search_functionality', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should find a simple string in a file',
|
||||
files: { 'test.txt': 'hello world' },
|
||||
prompt: 'Find "world" in test.txt',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
},
|
||||
});
|
||||
});
|
||||
`,
|
||||
{ filePath: '/repo/evals/grep_search.eval.ts', repoRoot: '/repo' },
|
||||
);
|
||||
|
||||
expect(analysis.cases[0].toolReferences).toEqual(['grep_search']);
|
||||
expect(analysis.toolReferences).toEqual(['grep_search']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectInventory,
|
||||
formatInventoryJson,
|
||||
formatInventoryReport,
|
||||
type InventoryJsonOutput,
|
||||
type InventoryResult,
|
||||
} from '../utils/eval-inventory.js';
|
||||
import type { EvalCaseRecord } from '../utils/eval-analysis.js';
|
||||
@@ -30,6 +32,19 @@ function makeCaseRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmptyResult(repoRoot = '/repo'): InventoryResult {
|
||||
return {
|
||||
totalFiles: 0,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [],
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
|
||||
|
||||
describe('eval-inventory', () => {
|
||||
describe('collectInventory', () => {
|
||||
it('discovers eval files from the real evals directory', async () => {
|
||||
@@ -40,6 +55,7 @@ describe('eval-inventory', () => {
|
||||
expect(result.totalCases).toBeGreaterThanOrEqual(90);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
|
||||
for (const evalCase of result.cases) {
|
||||
expect(evalCase.name).toBeTruthy();
|
||||
@@ -48,13 +64,20 @@ describe('eval-inventory', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns zero counts for a directory with no eval files', async () => {
|
||||
const result = await collectInventory(import.meta.dirname);
|
||||
it('returns zero file counts for an evals directory with no matching files', async () => {
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../../');
|
||||
const result = await collectInventory(repoRoot);
|
||||
|
||||
expect(result.totalFiles).toBe(0);
|
||||
expect(result.totalCases).toBe(0);
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.cases).toEqual([]);
|
||||
expect(result.totalFiles).toBeGreaterThanOrEqual(0);
|
||||
expect(result.files.length).toBe(result.totalFiles);
|
||||
expect(result.cases.length).toBe(result.totalCases);
|
||||
expect(result.repoRoot).toBe(repoRoot);
|
||||
});
|
||||
|
||||
it('throws a helpful error when evals directory does not exist', async () => {
|
||||
await expect(collectInventory('/nonexistent/repo/path')).rejects.toThrow(
|
||||
/evals directory not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +86,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'case-1' }),
|
||||
@@ -77,10 +101,11 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('2 files · 3 cases · 0 diagnostics');
|
||||
});
|
||||
|
||||
it('groups cases by policy', () => {
|
||||
it('groups cases by policy in canonical order', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
@@ -102,12 +127,39 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('USUALLY_PASSES (1 cases)');
|
||||
expect(report).toContain('• stable test');
|
||||
expect(report).toContain('• flaky test');
|
||||
expect(report.indexOf('ALWAYS_PASSES')).toBeLessThan(
|
||||
report.indexOf('USUALLY_PASSES'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cases with policies not listed in POLICY_ORDER', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'known policy' }),
|
||||
makeCaseRecord({
|
||||
policy: 'FUTURE_POLICY' as never,
|
||||
name: 'future policy',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const report = formatInventoryReport(result);
|
||||
|
||||
expect(report).toContain('ALWAYS_PASSES (1 cases)');
|
||||
expect(report).toContain('FUTURE_POLICY (1 cases)');
|
||||
expect(report).toContain('• future policy');
|
||||
});
|
||||
|
||||
it('groups cases by suite name', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ suiteName: 'default', name: 'suite-test' }),
|
||||
@@ -127,7 +179,16 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
files: [],
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/bad.eval.ts',
|
||||
relativePath: 'evals/bad.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
@@ -144,7 +205,7 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('Diagnostics');
|
||||
expect(report).toContain('1 diagnostics');
|
||||
expect(report).toContain(
|
||||
'⚠ /repo/evals/bad.eval.ts:5:3 — Could not resolve policy',
|
||||
'⚠ evals/bad.eval.ts:5:3 — Could not resolve policy',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,6 +213,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [makeCaseRecord()],
|
||||
diagnostics: [],
|
||||
@@ -167,6 +229,7 @@ describe('eval-inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
@@ -182,4 +245,466 @@ describe('eval-inventory', () => {
|
||||
expect(report).toContain('• custom test [customHelper]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInventoryJson', () => {
|
||||
it('snapshot: minimal inventory', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'basic eval',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
suiteName: 'core',
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 1,
|
||||
"totalCases": 1,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "basic eval",
|
||||
"filePath": "evals/test.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": "core",
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: mixed policies with diagnostics', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 2,
|
||||
totalCases: 3,
|
||||
repoRoot: '/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
relativePath: 'evals/c.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'stable test',
|
||||
policy: 'ALWAYS_PASSES',
|
||||
relativePath: 'evals/a.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'flaky test',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: 'tools',
|
||||
suiteType: 'behavioral',
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
makeCaseRecord({
|
||||
name: 'failing test',
|
||||
policy: 'USUALLY_FAILS',
|
||||
timeout: 30000,
|
||||
hasFiles: true,
|
||||
relativePath: 'evals/b.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'Could not resolve policy',
|
||||
filePath: '/repo/evals/c.eval.ts',
|
||||
location: { line: 10, column: 5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 2,
|
||||
"totalCases": 3,
|
||||
"totalDiagnostics": 1,
|
||||
"byPolicy": {
|
||||
"ALWAYS_PASSES": 1,
|
||||
"USUALLY_PASSES": 1,
|
||||
"USUALLY_FAILS": 1
|
||||
}
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "stable test",
|
||||
"filePath": "evals/a.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "ALWAYS_PASSES",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "flaky test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_PASSES",
|
||||
"suiteName": "tools",
|
||||
"suiteType": "behavioral",
|
||||
"timeout": null,
|
||||
"hasFiles": false,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "failing test",
|
||||
"filePath": "evals/b.eval.ts",
|
||||
"helperName": "evalTest",
|
||||
"baseHelperName": "evalTest",
|
||||
"policy": "USUALLY_FAILS",
|
||||
"suiteName": null,
|
||||
"suiteType": null,
|
||||
"timeout": 30000,
|
||||
"hasFiles": true,
|
||||
"hasPrompt": true,
|
||||
"location": {
|
||||
"line": 1,
|
||||
"column": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"message": "Could not resolve policy",
|
||||
"filePath": "evals/c.eval.ts",
|
||||
"location": {
|
||||
"line": 10,
|
||||
"column": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('snapshot: empty inventory', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).toMatchInlineSnapshot(`
|
||||
"{
|
||||
"version": 1,
|
||||
"generated": "2026-06-03T12:00:00.000Z",
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"totalCases": 0,
|
||||
"totalDiagnostics": 0,
|
||||
"byPolicy": {}
|
||||
},
|
||||
"cases": [],
|
||||
"diagnostics": []
|
||||
}"
|
||||
`);
|
||||
});
|
||||
|
||||
it('produces valid JSON with version field', () => {
|
||||
const result: InventoryResult = {
|
||||
...makeEmptyResult(),
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
cases: [makeCaseRecord()],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it('includes correct summary counts', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_PASSES' }),
|
||||
makeCaseRecord({ policy: 'USUALLY_FAILS' }),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test',
|
||||
filePath: 'test.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary).toEqual({
|
||||
totalFiles: 3,
|
||||
totalCases: 4,
|
||||
totalDiagnostics: 1,
|
||||
byPolicy: {
|
||||
ALWAYS_PASSES: 2,
|
||||
USUALLY_PASSES: 1,
|
||||
USUALLY_FAILS: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps case fields correctly with nulls for missing optionals', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
name: 'detailed case',
|
||||
relativePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
const firstCase = parsed.cases[0];
|
||||
|
||||
expect(firstCase).toEqual({
|
||||
name: 'detailed case',
|
||||
filePath: 'evals/detail.eval.ts',
|
||||
helperName: 'appEvalTest',
|
||||
baseHelperName: 'appEvalTest',
|
||||
policy: 'USUALLY_PASSES',
|
||||
suiteName: null,
|
||||
suiteType: null,
|
||||
timeout: null,
|
||||
hasFiles: true,
|
||||
hasPrompt: true,
|
||||
location: { line: 42, column: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses relative paths not absolute paths', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 1,
|
||||
repoRoot: '/absolute/repo',
|
||||
files: [
|
||||
{
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
makeCaseRecord({
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
relativePath: 'evals/test.eval.ts',
|
||||
}),
|
||||
],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'test diagnostic',
|
||||
filePath: '/absolute/repo/evals/test.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(json).not.toContain('/absolute/repo');
|
||||
expect(json).toContain('evals/test.eval.ts');
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/test.eval.ts');
|
||||
});
|
||||
|
||||
it('relativizes absolute diagnostic path not in file lookup using repoRoot', () => {
|
||||
const repoRoot = '/repo';
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 0,
|
||||
repoRoot,
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/evals/known.eval.ts',
|
||||
relativePath: 'evals/known.eval.ts',
|
||||
helpers: {},
|
||||
cases: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
cases: [],
|
||||
diagnostics: [
|
||||
{
|
||||
severity: 'warning',
|
||||
message: 'cross-file diagnostic',
|
||||
filePath: '/repo/evals/other.eval.ts',
|
||||
location: { line: 1, column: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const json = formatInventoryJson(result, FIXED_NOW);
|
||||
const parsed: InventoryJsonOutput = JSON.parse(json);
|
||||
|
||||
expect(parsed.diagnostics[0].filePath).toBe('evals/other.eval.ts');
|
||||
expect(parsed.diagnostics[0].filePath).not.toMatch(/^\//);
|
||||
});
|
||||
|
||||
it('includes policies not listed in POLICY_ORDER in byPolicy', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ policy: 'unknown' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result, FIXED_NOW),
|
||||
);
|
||||
|
||||
expect(parsed.summary.byPolicy).toEqual({
|
||||
ALWAYS_PASSES: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
|
||||
const sum = Object.values(parsed.summary.byPolicy).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
expect(sum).toBe(parsed.summary.totalCases);
|
||||
});
|
||||
|
||||
it('emits deterministic output', () => {
|
||||
const result: InventoryResult = {
|
||||
totalFiles: 1,
|
||||
totalCases: 2,
|
||||
repoRoot: '/repo',
|
||||
files: [],
|
||||
cases: [
|
||||
makeCaseRecord({ name: 'a', policy: 'ALWAYS_PASSES' }),
|
||||
makeCaseRecord({ name: 'b', policy: 'USUALLY_PASSES' }),
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const first = formatInventoryJson(result, FIXED_NOW);
|
||||
const second = formatInventoryJson(result, FIXED_NOW);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('generated field is valid ISO-8601', () => {
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
|
||||
const date = new Date(parsed.generated);
|
||||
expect(date.getTime()).not.toBeNaN();
|
||||
expect(parsed.generated).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,
|
||||
);
|
||||
});
|
||||
|
||||
describe('environment overrides for timestamp', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('uses SOURCE_DATE_EPOCH if set', () => {
|
||||
vi.stubEnv('SOURCE_DATE_EPOCH', '1700000000');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('2023-11-14T22:13:20.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_STABLE_DATE is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_STABLE_DATE', '1');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('uses epoch 0 if EVAL_INVENTORY_DETERMINISTIC is set', () => {
|
||||
vi.stubEnv('EVAL_INVENTORY_DETERMINISTIC', 'true');
|
||||
const result: InventoryResult = makeEmptyResult();
|
||||
const parsed: InventoryJsonOutput = JSON.parse(
|
||||
formatInventoryJson(result),
|
||||
);
|
||||
expect(parsed.generated).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatToolLogChain,
|
||||
type ToolLogEntry,
|
||||
} from '../utils/tool-log-formatter.js';
|
||||
|
||||
function makeEntry(
|
||||
overrides: Partial<ToolLogEntry['toolRequest']> = {},
|
||||
): ToolLogEntry {
|
||||
return {
|
||||
toolRequest: {
|
||||
name: 'test_tool',
|
||||
args: '{}',
|
||||
success: true,
|
||||
duration_ms: 100,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatToolLogChain', () => {
|
||||
it('returns empty string for empty log array', () => {
|
||||
expect(formatToolLogChain([])).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined input', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(formatToolLogChain(null as any)).toBe('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(formatToolLogChain(undefined as any)).toBe('');
|
||||
});
|
||||
|
||||
it('formats a single successful tool call', () => {
|
||||
const logs = [makeEntry({ name: 'grep_search', duration_ms: 42 })];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('1.');
|
||||
expect(result).toContain('grep_search()');
|
||||
expect(result).toContain('✓');
|
||||
expect(result).toContain('42ms');
|
||||
});
|
||||
|
||||
it('formats a single failed tool call with error details', () => {
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'read_file',
|
||||
args: '{"path":"/src/foo.ts"}',
|
||||
success: false,
|
||||
duration_ms: 80,
|
||||
error: 'File not found',
|
||||
error_type: 'ENOENT',
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('read_file(');
|
||||
expect(result).toContain('path="/src/foo.ts"');
|
||||
expect(result).toContain('✗');
|
||||
expect(result).toContain('80ms');
|
||||
expect(result).toContain('↳ Error: [ENOENT] File not found');
|
||||
});
|
||||
|
||||
it('formats arguments as key=value pairs', () => {
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'grep_search',
|
||||
args: '{"query":"TODO","path":"/src"}',
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('query="TODO"');
|
||||
expect(result).toContain('path="/src"');
|
||||
});
|
||||
|
||||
it('truncates long argument values', () => {
|
||||
const longValue = 'a'.repeat(100);
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'write_file',
|
||||
args: JSON.stringify({ content: longValue }),
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('…');
|
||||
expect(result).not.toContain(longValue);
|
||||
});
|
||||
|
||||
it('handles invalid JSON in args gracefully', () => {
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'shell',
|
||||
args: 'not-json {{{',
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('shell(');
|
||||
expect(result).toContain('not-json');
|
||||
});
|
||||
|
||||
it('handles JSON null args without crashing', () => {
|
||||
// JSON.parse('null') returns null; Object.entries(null) would throw TypeError
|
||||
const logs = [makeEntry({ name: 'some_tool', args: 'null' })];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('some_tool(');
|
||||
expect(result).toContain('null');
|
||||
});
|
||||
|
||||
it('handles JSON primitive string args without producing garbage output', () => {
|
||||
// JSON.parse('"hello"') returns a string; Object.entries("hello") would produce char pairs
|
||||
const logs = [makeEntry({ name: 'some_tool', args: '"hello"' })];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('some_tool(');
|
||||
expect(result).toContain('hello');
|
||||
// Should NOT produce character-index pairs like 0="h"
|
||||
expect(result).not.toMatch(/0="h"/);
|
||||
});
|
||||
|
||||
it('handles JSON array args without producing indexed output', () => {
|
||||
// JSON.parse('[1,2,3]') returns an array; Object.entries([1,2,3]) would produce index pairs
|
||||
const logs = [makeEntry({ name: 'some_tool', args: '[1, 2, 3]' })];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('some_tool(');
|
||||
// Should NOT produce array-index pairs like 0="1"
|
||||
expect(result).not.toMatch(/0="1"/);
|
||||
});
|
||||
|
||||
it('formats multiple tool calls with correct numbering', () => {
|
||||
const logs = [
|
||||
makeEntry({ name: 'grep_search', duration_ms: 10 }),
|
||||
makeEntry({ name: 'read_file', duration_ms: 20 }),
|
||||
makeEntry({
|
||||
name: 'write_file',
|
||||
success: false,
|
||||
duration_ms: 30,
|
||||
error: 'Permission denied',
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toContain('1.');
|
||||
expect(lines[0]).toContain('grep_search');
|
||||
expect(lines[1]).toContain('2.');
|
||||
expect(lines[1]).toContain('read_file');
|
||||
expect(lines[2]).toContain('3.');
|
||||
expect(lines[2]).toContain('write_file');
|
||||
expect(lines[3]).toContain('↳ Error:');
|
||||
expect(lines[3]).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('pads step numbers for double-digit counts', () => {
|
||||
const logs = Array.from({ length: 12 }, (_, i) =>
|
||||
makeEntry({ name: `tool_${i + 1}`, duration_ms: i * 10 }),
|
||||
);
|
||||
const result = formatToolLogChain(logs);
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toMatch(/\s+1\./);
|
||||
expect(lines[11]).toContain('12.');
|
||||
});
|
||||
|
||||
it('shows failed call without error details when neither error nor error_type present', () => {
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'run_shell',
|
||||
success: false,
|
||||
duration_ms: 50,
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('✗');
|
||||
expect(result).not.toContain('↳');
|
||||
});
|
||||
|
||||
it('handles empty args string', () => {
|
||||
const logs = [makeEntry({ name: 'list_dir', args: '' })];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('list_dir()');
|
||||
});
|
||||
|
||||
it('formats non-string argument values correctly', () => {
|
||||
const logs = [
|
||||
makeEntry({
|
||||
name: 'some_tool',
|
||||
args: '{"count":42,"nested":{"a":1},"flag":true}',
|
||||
}),
|
||||
];
|
||||
const result = formatToolLogChain(logs);
|
||||
expect(result).toContain('count="42"');
|
||||
expect(result).toContain('flag="true"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildToolRegistry,
|
||||
resolveToolName,
|
||||
getToolsByCategory,
|
||||
type ToolCategory,
|
||||
} from '../utils/tool-registry.js';
|
||||
|
||||
describe('tool-registry', () => {
|
||||
const registry = buildToolRegistry();
|
||||
|
||||
describe('buildToolRegistry', () => {
|
||||
it('includes all canonical built-in tools', () => {
|
||||
expect(registry.totalTools).toBeGreaterThanOrEqual(26);
|
||||
});
|
||||
|
||||
it('every tool has a valid category', () => {
|
||||
for (const [name, entry] of registry.tools) {
|
||||
expect(entry.category).toBeTruthy();
|
||||
expect(entry.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('byCategory entries match tools map', () => {
|
||||
let categoryTotal = 0;
|
||||
for (const [, entries] of registry.byCategory) {
|
||||
for (const entry of entries) {
|
||||
expect(registry.tools.get(entry.name)).toBe(entry);
|
||||
}
|
||||
categoryTotal += entries.length;
|
||||
}
|
||||
expect(categoryTotal).toBe(registry.totalTools);
|
||||
});
|
||||
|
||||
it('aliasLookup covers every canonical name', () => {
|
||||
for (const name of registry.tools.keys()) {
|
||||
expect(registry.aliasLookup.get(name)).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('aliasLookup covers every legacy alias', () => {
|
||||
for (const [, entry] of registry.tools) {
|
||||
for (const alias of entry.aliases) {
|
||||
expect(registry.aliasLookup.get(alias)).toBe(entry.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic across calls', () => {
|
||||
const second = buildToolRegistry();
|
||||
expect([...second.tools.keys()]).toEqual([...registry.tools.keys()]);
|
||||
expect(second.totalTools).toBe(registry.totalTools);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolName', () => {
|
||||
it('resolves canonical names to themselves', () => {
|
||||
expect(resolveToolName(registry, 'grep_search')).toBe('grep_search');
|
||||
expect(resolveToolName(registry, 'run_shell_command')).toBe(
|
||||
'run_shell_command',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves legacy alias to canonical name', () => {
|
||||
expect(resolveToolName(registry, 'search_file_content')).toBe(
|
||||
'grep_search',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown tool names', () => {
|
||||
expect(resolveToolName(registry, 'nonexistent_tool')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(resolveToolName(registry, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolsByCategory', () => {
|
||||
it('returns file-system tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'file-system');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('glob');
|
||||
expect(names).toContain('grep_search');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('replace');
|
||||
});
|
||||
|
||||
it('returns task-tracker tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'task-tracker');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('tracker_create_task');
|
||||
expect(names).toContain('tracker_update_task');
|
||||
expect(names).toContain('tracker_get_task');
|
||||
expect(names).toContain('tracker_list_tasks');
|
||||
expect(names).toContain('tracker_add_dependency');
|
||||
expect(names).toContain('tracker_visualize');
|
||||
expect(names).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('returns agent tools', () => {
|
||||
const tools = getToolsByCategory(registry, 'agent');
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('invoke_agent');
|
||||
expect(names).toContain('complete_task');
|
||||
expect(names).toContain('update_topic');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown category', () => {
|
||||
expect(
|
||||
getToolsByCategory(registry, 'nonexistent' as ToolCategory),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every defined category has at least one tool', () => {
|
||||
const expectedCategories: ToolCategory[] = [
|
||||
'file-system',
|
||||
'shell',
|
||||
'web',
|
||||
'planning',
|
||||
'user-interaction',
|
||||
'skills',
|
||||
'task-tracker',
|
||||
'agent',
|
||||
'mcp',
|
||||
];
|
||||
for (const cat of expectedCategories) {
|
||||
expect(getToolsByCategory(registry, cat).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
import path from 'node:path';
|
||||
import * as ts from 'typescript';
|
||||
import {
|
||||
ALL_BUILTIN_TOOL_NAMES,
|
||||
isValidToolName,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { buildToolRegistry } from './tool-registry.js';
|
||||
|
||||
export const BASE_EVAL_HELPERS = [
|
||||
'evalTest',
|
||||
@@ -45,6 +50,7 @@ export interface EvalCaseRecord {
|
||||
timeout?: number;
|
||||
hasFiles: boolean;
|
||||
hasPrompt: boolean;
|
||||
toolReferences: readonly string[];
|
||||
location: EvalSourceLocation;
|
||||
}
|
||||
|
||||
@@ -53,6 +59,7 @@ export interface EvalFileAnalysis {
|
||||
relativePath: string;
|
||||
helpers: Record<string, BaseEvalHelper | 'unknown'>;
|
||||
cases: readonly EvalCaseRecord[];
|
||||
toolReferences: readonly string[];
|
||||
diagnostics: readonly EvalAnalysisDiagnostic[];
|
||||
}
|
||||
|
||||
@@ -76,6 +83,7 @@ export function analyzeEvalSource(
|
||||
);
|
||||
|
||||
const helpers = collectHelperMappings(sourceFile);
|
||||
const importedConstants = collectImportedToolNameConstants(sourceFile);
|
||||
const diagnostics: EvalAnalysisDiagnostic[] = [];
|
||||
const cases: EvalCaseRecord[] = [];
|
||||
|
||||
@@ -118,6 +126,30 @@ export function analyzeEvalSource(
|
||||
});
|
||||
}
|
||||
|
||||
const assertProp = getPropertyAssignment(evalCase, 'assert');
|
||||
const assertBody = assertProp
|
||||
? getFunctionBody(assertProp.initializer)
|
||||
: undefined;
|
||||
const toolRefsInfo = assertBody
|
||||
? collectToolReferences(assertBody, importedConstants)
|
||||
: [];
|
||||
|
||||
const toolRefs: string[] = [];
|
||||
const registry = buildToolRegistry();
|
||||
|
||||
for (const { name: resolvedName, node } of toolRefsInfo) {
|
||||
const canonicalName = registry.aliasLookup.get(resolvedName);
|
||||
if (!canonicalName && !isValidToolName(resolvedName)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
message: `Unrecognized tool name extracted: "${resolvedName}"`,
|
||||
filePath,
|
||||
location: getLocation(sourceFile, node),
|
||||
});
|
||||
}
|
||||
toolRefs.push(canonicalName ?? resolvedName);
|
||||
}
|
||||
|
||||
cases.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
@@ -130,17 +162,23 @@ export function analyzeEvalSource(
|
||||
timeout: getStaticNumberProperty(evalCase, 'timeout'),
|
||||
hasFiles: hasProperty(evalCase, 'files'),
|
||||
hasPrompt: hasProperty(evalCase, 'prompt'),
|
||||
toolReferences: Object.freeze([...new Set(toolRefs)].sort()),
|
||||
location: getLocation(sourceFile, callExpression),
|
||||
});
|
||||
});
|
||||
|
||||
cases.sort(compareEvalCases);
|
||||
|
||||
const fileToolRefs = [
|
||||
...new Set(cases.flatMap((c) => [...c.toolReferences])),
|
||||
].sort();
|
||||
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
helpers,
|
||||
cases,
|
||||
toolReferences: Object.freeze(fileToolRefs),
|
||||
diagnostics: diagnostics.sort(compareDiagnostics),
|
||||
};
|
||||
}
|
||||
@@ -439,3 +477,204 @@ function compareDiagnostics(
|
||||
function compareStrings(left: string, right: string) {
|
||||
return left.localeCompare(right, 'en');
|
||||
}
|
||||
|
||||
const TOOL_NAME_TO_CONSTANT: Record<
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
keyof typeof import('@google/gemini-cli-core')
|
||||
> = {
|
||||
glob: 'GLOB_TOOL_NAME',
|
||||
grep_search: 'GREP_TOOL_NAME',
|
||||
list_directory: 'LS_TOOL_NAME',
|
||||
read_file: 'READ_FILE_TOOL_NAME',
|
||||
run_shell_command: 'SHELL_TOOL_NAME',
|
||||
write_file: 'WRITE_FILE_TOOL_NAME',
|
||||
replace: 'EDIT_TOOL_NAME',
|
||||
google_web_search: 'WEB_SEARCH_TOOL_NAME',
|
||||
write_todos: 'WRITE_TODOS_TOOL_NAME',
|
||||
web_fetch: 'WEB_FETCH_TOOL_NAME',
|
||||
read_many_files: 'READ_MANY_FILES_TOOL_NAME',
|
||||
get_internal_docs: 'GET_INTERNAL_DOCS_TOOL_NAME',
|
||||
activate_skill: 'ACTIVATE_SKILL_TOOL_NAME',
|
||||
ask_user: 'ASK_USER_TOOL_NAME',
|
||||
exit_plan_mode: 'EXIT_PLAN_MODE_TOOL_NAME',
|
||||
enter_plan_mode: 'ENTER_PLAN_MODE_TOOL_NAME',
|
||||
update_topic: 'UPDATE_TOPIC_TOOL_NAME',
|
||||
complete_task: 'COMPLETE_TASK_TOOL_NAME',
|
||||
read_mcp_resource: 'READ_MCP_RESOURCE_TOOL_NAME',
|
||||
list_mcp_resources: 'LIST_MCP_RESOURCES_TOOL_NAME',
|
||||
tracker_create_task: 'TRACKER_CREATE_TASK_TOOL_NAME',
|
||||
tracker_update_task: 'TRACKER_UPDATE_TASK_TOOL_NAME',
|
||||
tracker_get_task: 'TRACKER_GET_TASK_TOOL_NAME',
|
||||
tracker_list_tasks: 'TRACKER_LIST_TASKS_TOOL_NAME',
|
||||
tracker_add_dependency: 'TRACKER_ADD_DEPENDENCY_TOOL_NAME',
|
||||
tracker_visualize: 'TRACKER_VISUALIZE_TOOL_NAME',
|
||||
invoke_agent: 'AGENT_TOOL_NAME',
|
||||
};
|
||||
|
||||
const WELL_KNOWN_TOOL_CONSTANTS: Record<
|
||||
string,
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number]
|
||||
> = Object.fromEntries(
|
||||
Object.entries(TOOL_NAME_TO_CONSTANT).map(([toolName, constantName]) => [
|
||||
constantName,
|
||||
toolName as (typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
]),
|
||||
);
|
||||
|
||||
function collectImportedToolNameConstants(
|
||||
sourceFile: ts.SourceFile,
|
||||
): Map<string, string> {
|
||||
const constants = new Map<string, string>();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
!ts.isImportDeclaration(statement) ||
|
||||
!statement.importClause?.namedBindings ||
|
||||
!ts.isNamedImports(statement.importClause.namedBindings) ||
|
||||
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
||||
statement.moduleSpecifier.text !== '@google/gemini-cli-core'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.importClause.namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text;
|
||||
const localName = element.name.text;
|
||||
const resolvedValue = WELL_KNOWN_TOOL_CONSTANTS[importedName];
|
||||
if (resolvedValue !== undefined) {
|
||||
constants.set(localName, resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return constants;
|
||||
}
|
||||
|
||||
function getFunctionBody(
|
||||
node: ts.Expression,
|
||||
): ts.ConciseBody | ts.Block | undefined {
|
||||
if (ts.isArrowFunction(node)) {
|
||||
return node.body;
|
||||
}
|
||||
if (ts.isFunctionExpression(node)) {
|
||||
return node.body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectToolReferences(
|
||||
body: ts.ConciseBody | ts.Block,
|
||||
importedConstants: Map<string, string>,
|
||||
): { name: string; node: ts.Node }[] {
|
||||
const refs: { name: string; node: ts.Node }[] = [];
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
extractFromWaitForToolCall(node, importedConstants, refs);
|
||||
extractFromArrayIncludes(node, importedConstants, refs);
|
||||
} else if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
||||
) {
|
||||
extractFromToolRequestNameComparison(node, importedConstants, refs);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(body);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function extractFromWaitForToolCall(
|
||||
call: ts.CallExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
const expr = call.expression;
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(expr) ||
|
||||
expr.name.text !== 'waitForToolCall'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const firstArg = call.arguments[0];
|
||||
if (!firstArg) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveStringValue(firstArg, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: firstArg });
|
||||
}
|
||||
}
|
||||
|
||||
function isToolRequestName(node: ts.Expression): boolean {
|
||||
return (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
node.name.text === 'name' &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === 'toolRequest'
|
||||
);
|
||||
}
|
||||
|
||||
function extractFromToolRequestNameComparison(
|
||||
binary: ts.BinaryExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
let valueNode: ts.Expression | undefined;
|
||||
if (isToolRequestName(binary.left)) {
|
||||
valueNode = binary.right;
|
||||
} else if (isToolRequestName(binary.right)) {
|
||||
valueNode = binary.left;
|
||||
}
|
||||
|
||||
if (valueNode) {
|
||||
const resolved = resolveStringValue(valueNode, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: valueNode });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFromArrayIncludes(
|
||||
call: ts.CallExpression,
|
||||
importedConstants: Map<string, string>,
|
||||
refs: { name: string; node: ts.Node }[],
|
||||
) {
|
||||
const expr = call.expression;
|
||||
if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'includes') {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstArg = call.arguments[0];
|
||||
if (!firstArg || !isToolRequestName(firstArg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayExpr = expr.expression;
|
||||
if (!ts.isArrayLiteralExpression(arrayExpr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of arrayExpr.elements) {
|
||||
const resolved = resolveStringValue(element, importedConstants);
|
||||
if (resolved) {
|
||||
refs.push({ name: resolved, node: element });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStringValue(
|
||||
node: ts.Expression,
|
||||
importedConstants: Map<string, string>,
|
||||
): string | undefined {
|
||||
const literal = getStringLiteralValue(node);
|
||||
if (literal !== undefined) {
|
||||
return literal;
|
||||
}
|
||||
if (ts.isIdentifier(node)) {
|
||||
return importedConstants.get(node.text);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+192
-13
@@ -16,9 +16,17 @@ import {
|
||||
type EvalPolicy,
|
||||
} from './eval-analysis.js';
|
||||
|
||||
const POLICY_ORDER: EvalPolicy[] = [
|
||||
'ALWAYS_PASSES',
|
||||
'USUALLY_PASSES',
|
||||
'USUALLY_FAILS',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
export interface InventoryResult {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
repoRoot: string;
|
||||
files: EvalFileAnalysis[];
|
||||
cases: readonly EvalCaseRecord[];
|
||||
diagnostics: readonly EvalAnalysisDiagnostic[];
|
||||
@@ -32,6 +40,22 @@ export async function collectInventory(
|
||||
repoRoot: string,
|
||||
): Promise<InventoryResult> {
|
||||
const evalsDir = path.join(repoRoot, 'evals');
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(evalsDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`evals path exists but is not a directory: ${evalsDir}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`evals directory not found under repo root: ${evalsDir}\n` +
|
||||
`Make sure --root points to the repository root.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pattern = '**/*.eval.{ts,tsx}';
|
||||
|
||||
const evalFiles = await glob(pattern, {
|
||||
@@ -57,6 +81,7 @@ export async function collectInventory(
|
||||
return {
|
||||
totalFiles: files.length,
|
||||
totalCases: allCases.length,
|
||||
repoRoot,
|
||||
files,
|
||||
cases: allCases,
|
||||
diagnostics: allDiagnostics,
|
||||
@@ -81,20 +106,30 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
lines.push('By Policy');
|
||||
lines.push('─────────');
|
||||
|
||||
const byPolicy = groupBy(result.cases, (c) => c.policy);
|
||||
const policyOrder: EvalPolicy[] = [
|
||||
'ALWAYS_PASSES',
|
||||
'USUALLY_PASSES',
|
||||
'USUALLY_FAILS',
|
||||
'unknown',
|
||||
];
|
||||
const byPolicyMap = groupBy(result.cases, (c) => c.policy);
|
||||
|
||||
for (const policy of policyOrder) {
|
||||
const cases = byPolicy.get(policy);
|
||||
const renderedPolicies = new Set<string>();
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const cases = byPolicyMap.get(policy);
|
||||
if (!cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
renderedPolicies.add(policy);
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
for (const [filePath, fileCases] of byFile) {
|
||||
lines.push(` ${filePath}`);
|
||||
for (const evalCase of fileCases) {
|
||||
lines.push(` • ${evalCase.name} [${evalCase.helperName}]`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
for (const [policy, cases] of byPolicyMap) {
|
||||
if (renderedPolicies.has(policy) || !cases || cases.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`${policy} (${cases.length} cases)`);
|
||||
|
||||
const byFile = groupBy(cases, (c) => c.relativePath);
|
||||
@@ -141,10 +176,11 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
lines.push('Diagnostics');
|
||||
lines.push('───────────');
|
||||
for (const diagnostic of result.diagnostics) {
|
||||
const displayPath =
|
||||
diagnostic.filePath === '<inline>'
|
||||
? diagnostic.filePath
|
||||
: (filePaths.get(diagnostic.filePath) ?? diagnostic.filePath);
|
||||
const displayPath = resolveRelativePath(
|
||||
diagnostic.filePath,
|
||||
filePaths,
|
||||
result.repoRoot,
|
||||
);
|
||||
lines.push(
|
||||
`⚠ ${displayPath}:${diagnostic.location.line}:${diagnostic.location.column} — ${diagnostic.message}`,
|
||||
);
|
||||
@@ -155,6 +191,128 @@ export function formatInventoryReport(result: InventoryResult): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface InventoryJsonOutput {
|
||||
version: 1;
|
||||
generated: string;
|
||||
summary: {
|
||||
totalFiles: number;
|
||||
totalCases: number;
|
||||
totalDiagnostics: number;
|
||||
byPolicy: Record<string, number>;
|
||||
};
|
||||
cases: InventoryJsonCase[];
|
||||
diagnostics: InventoryJsonDiagnostic[];
|
||||
}
|
||||
|
||||
interface InventoryJsonCase {
|
||||
name: string;
|
||||
filePath: string;
|
||||
helperName: string;
|
||||
baseHelperName: string;
|
||||
policy: string;
|
||||
suiteName: string | null;
|
||||
suiteType: string | null;
|
||||
timeout: number | null;
|
||||
hasFiles: boolean;
|
||||
hasPrompt: boolean;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
interface InventoryJsonDiagnostic {
|
||||
severity: string;
|
||||
message: string;
|
||||
filePath: string;
|
||||
location: { line: number; column: number };
|
||||
}
|
||||
|
||||
export function formatInventoryJson(
|
||||
result: InventoryResult,
|
||||
now?: Date,
|
||||
): string {
|
||||
const filePathLookup = new Map<string, string>();
|
||||
for (const f of result.files) {
|
||||
filePathLookup.set(f.filePath, f.relativePath);
|
||||
}
|
||||
|
||||
const policyCounts = new Map<string, number>();
|
||||
for (const evalCase of result.cases) {
|
||||
policyCounts.set(
|
||||
evalCase.policy,
|
||||
(policyCounts.get(evalCase.policy) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const byPolicy: Record<string, number> = {};
|
||||
for (const policy of POLICY_ORDER) {
|
||||
const count = policyCounts.get(policy);
|
||||
if (count !== undefined) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
for (const [policy, count] of policyCounts) {
|
||||
if (!(policy in byPolicy)) {
|
||||
byPolicy[policy] = count;
|
||||
}
|
||||
}
|
||||
|
||||
let generatedDate = now;
|
||||
if (!generatedDate && process.env.SOURCE_DATE_EPOCH) {
|
||||
const epoch = parseInt(process.env.SOURCE_DATE_EPOCH, 10);
|
||||
if (!isNaN(epoch)) {
|
||||
generatedDate = new Date(epoch * 1000);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!generatedDate &&
|
||||
(process.env.EVAL_INVENTORY_STABLE_DATE ||
|
||||
process.env.EVAL_INVENTORY_DETERMINISTIC)
|
||||
) {
|
||||
generatedDate = new Date(0);
|
||||
}
|
||||
if (!generatedDate) {
|
||||
generatedDate = new Date();
|
||||
}
|
||||
|
||||
const output: InventoryJsonOutput = {
|
||||
version: 1,
|
||||
generated: generatedDate.toISOString(),
|
||||
summary: {
|
||||
totalFiles: result.totalFiles,
|
||||
totalCases: result.totalCases,
|
||||
totalDiagnostics: result.diagnostics.length,
|
||||
byPolicy,
|
||||
},
|
||||
cases: result.cases.map((c) => ({
|
||||
name: c.name,
|
||||
filePath: c.relativePath,
|
||||
helperName: c.helperName,
|
||||
baseHelperName: c.baseHelperName,
|
||||
policy: c.policy,
|
||||
suiteName: c.suiteName ?? null,
|
||||
suiteType: c.suiteType ?? null,
|
||||
timeout: c.timeout ?? null,
|
||||
hasFiles: c.hasFiles,
|
||||
hasPrompt: c.hasPrompt,
|
||||
location: { line: c.location.line, column: c.location.column },
|
||||
})),
|
||||
diagnostics: result.diagnostics.map((d) => {
|
||||
const relativePath = resolveRelativePath(
|
||||
d.filePath,
|
||||
filePathLookup,
|
||||
result.repoRoot,
|
||||
);
|
||||
return {
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
filePath: relativePath,
|
||||
location: { line: d.location.line, column: d.location.column },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
function groupBy<T>(
|
||||
items: readonly T[],
|
||||
keyFn: (item: T) => string,
|
||||
@@ -171,3 +329,24 @@ function groupBy<T>(
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function resolveRelativePath(
|
||||
filePath: string,
|
||||
lookup: Map<string, string>,
|
||||
baseDir: string,
|
||||
): string {
|
||||
if (filePath === '<inline>') {
|
||||
return filePath;
|
||||
}
|
||||
const mapped = lookup.get(filePath);
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
return path.isAbsolute(filePath)
|
||||
? path.relative(baseDir, filePath).replace(/\\/g, '/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
function isNodeError(err: unknown): err is NodeJS.ErrnoException {
|
||||
return err instanceof Error && 'code' in err;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface ToolLogEntry {
|
||||
toolRequest: {
|
||||
name: string;
|
||||
args: string;
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
prompt_id?: string;
|
||||
error?: string;
|
||||
error_type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ARG_VALUE_LENGTH = 60;
|
||||
|
||||
function formatArgs(argsJson: string): string {
|
||||
if (!argsJson || argsJson === '{}') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const val = JSON.parse(argsJson);
|
||||
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
|
||||
return truncate(argsJson, MAX_ARG_VALUE_LENGTH);
|
||||
}
|
||||
parsed = val as Record<string, unknown>;
|
||||
} catch {
|
||||
return truncate(argsJson, MAX_ARG_VALUE_LENGTH);
|
||||
}
|
||||
|
||||
const pairs: string[] = [];
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
const strValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
pairs.push(
|
||||
`${key}=${JSON.stringify(truncate(String(strValue), MAX_ARG_VALUE_LENGTH))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return pairs.join(', ');
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) {
|
||||
return str;
|
||||
}
|
||||
return str.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
export function formatToolLogChain(logs: ToolLogEntry[]): string {
|
||||
if (!logs || logs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const padWidth = String(logs.length).length;
|
||||
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const { toolRequest: t } = logs[i];
|
||||
const idx = String(i + 1).padStart(padWidth, ' ');
|
||||
const argsStr = formatArgs(t.args);
|
||||
const call = argsStr ? `${t.name}(${argsStr})` : `${t.name}()`;
|
||||
|
||||
const status = t.success ? '✓' : '✗';
|
||||
const duration = `${t.duration_ms}ms`;
|
||||
|
||||
lines.push(` ${idx}. ${call} ── ${status} ${duration}`);
|
||||
|
||||
if (!t.success && (t.error || t.error_type)) {
|
||||
const errorType = t.error_type ? `[${t.error_type}] ` : '';
|
||||
const errorMsg = t.error ? truncate(t.error, 120) : 'Unknown error';
|
||||
lines.push(
|
||||
` ${' '.repeat(padWidth)} ↳ Error: ${errorType}${errorMsg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ALL_BUILTIN_TOOL_NAMES,
|
||||
TOOL_LEGACY_ALIASES,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export type ToolCategory =
|
||||
| 'file-system'
|
||||
| 'shell'
|
||||
| 'web'
|
||||
| 'planning'
|
||||
| 'user-interaction'
|
||||
| 'skills'
|
||||
| 'task-tracker'
|
||||
| 'agent'
|
||||
| 'mcp';
|
||||
|
||||
export interface ToolRegistryEntry {
|
||||
name: string;
|
||||
category: ToolCategory;
|
||||
aliases: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolRegistry {
|
||||
tools: ReadonlyMap<string, ToolRegistryEntry>;
|
||||
totalTools: number;
|
||||
byCategory: ReadonlyMap<ToolCategory, readonly ToolRegistryEntry[]>;
|
||||
aliasLookup: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const TOOL_CATEGORIES: Record<
|
||||
(typeof ALL_BUILTIN_TOOL_NAMES)[number],
|
||||
ToolCategory
|
||||
> = {
|
||||
glob: 'file-system',
|
||||
grep_search: 'file-system',
|
||||
list_directory: 'file-system',
|
||||
read_file: 'file-system',
|
||||
read_many_files: 'file-system',
|
||||
write_file: 'file-system',
|
||||
replace: 'file-system',
|
||||
run_shell_command: 'shell',
|
||||
google_web_search: 'web',
|
||||
web_fetch: 'web',
|
||||
enter_plan_mode: 'planning',
|
||||
exit_plan_mode: 'planning',
|
||||
write_todos: 'planning',
|
||||
ask_user: 'user-interaction',
|
||||
activate_skill: 'skills',
|
||||
get_internal_docs: 'skills',
|
||||
tracker_create_task: 'task-tracker',
|
||||
tracker_update_task: 'task-tracker',
|
||||
tracker_get_task: 'task-tracker',
|
||||
tracker_list_tasks: 'task-tracker',
|
||||
tracker_add_dependency: 'task-tracker',
|
||||
tracker_visualize: 'task-tracker',
|
||||
invoke_agent: 'agent',
|
||||
complete_task: 'agent',
|
||||
update_topic: 'agent',
|
||||
read_mcp_resource: 'mcp',
|
||||
list_mcp_resources: 'mcp',
|
||||
};
|
||||
|
||||
let registryCache: ToolRegistry | undefined;
|
||||
|
||||
export function buildToolRegistry(): ToolRegistry {
|
||||
if (registryCache) {
|
||||
return registryCache;
|
||||
}
|
||||
|
||||
const tools = new Map<string, ToolRegistryEntry>();
|
||||
const aliasLookup = new Map<string, string>();
|
||||
const categoryGroups = new Map<ToolCategory, ToolRegistryEntry[]>();
|
||||
|
||||
for (const name of ALL_BUILTIN_TOOL_NAMES) {
|
||||
const category = TOOL_CATEGORIES[name];
|
||||
const aliases: string[] = [];
|
||||
|
||||
for (const [legacyName, canonicalName] of Object.entries(
|
||||
TOOL_LEGACY_ALIASES,
|
||||
)) {
|
||||
if (canonicalName === name) {
|
||||
aliases.push(legacyName);
|
||||
aliasLookup.set(legacyName, name);
|
||||
}
|
||||
}
|
||||
|
||||
aliasLookup.set(name, name);
|
||||
|
||||
const entry: ToolRegistryEntry = {
|
||||
name,
|
||||
category,
|
||||
aliases: Object.freeze(aliases),
|
||||
};
|
||||
|
||||
tools.set(name, entry);
|
||||
|
||||
const group = categoryGroups.get(category);
|
||||
if (group) {
|
||||
group.push(entry);
|
||||
} else {
|
||||
categoryGroups.set(category, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const frozenCategories = new Map<
|
||||
ToolCategory,
|
||||
readonly ToolRegistryEntry[]
|
||||
>();
|
||||
for (const [cat, entries] of categoryGroups) {
|
||||
frozenCategories.set(cat, Object.freeze(entries));
|
||||
}
|
||||
|
||||
registryCache = {
|
||||
tools,
|
||||
totalTools: tools.size,
|
||||
byCategory: frozenCategories,
|
||||
aliasLookup,
|
||||
};
|
||||
return registryCache;
|
||||
}
|
||||
|
||||
export function resolveToolName(
|
||||
registry: ToolRegistry,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return registry.aliasLookup.get(name);
|
||||
}
|
||||
|
||||
export function getToolsByCategory(
|
||||
registry: ToolRegistry,
|
||||
category: ToolCategory,
|
||||
): readonly ToolRegistryEntry[] {
|
||||
return registry.byCategory.get(category) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.git
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "egress-service",
|
||||
"version": "1.0.0",
|
||||
"description": "GitHub Egress Pub/Sub Cloud Run worker service",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/rest": "^20.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "^4.9.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from './app.js';
|
||||
|
||||
/**
|
||||
* Helper function simulating GCP Cloud Pub/Sub HTTP Push message wrapper.
|
||||
* Encodes the payload object into Base64 format inside message.data.
|
||||
*/
|
||||
function createPubSubPushEnvelope(payload: unknown): {
|
||||
message: { data: string };
|
||||
} {
|
||||
const jsonString =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
const base64Data = Buffer.from(jsonString).toString('base64');
|
||||
return { message: { data: base64Data } };
|
||||
}
|
||||
|
||||
describe('Egress Service App Router', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('GET / should return 200 OK with structured health debug info', async () => {
|
||||
const res = await request(app).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'healthy',
|
||||
service: 'caretaker-egress-service',
|
||||
revision: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('POST / should return 400 if Pub/Sub envelope is invalid', async () => {
|
||||
const res = await request(app).post('/').send('not a json object');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST / should return 400 if message.data is missing', async () => {
|
||||
const res = await request(app).post('/').send({ message: {} });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.text).toBe('Missing message.data');
|
||||
});
|
||||
|
||||
it('POST / should return 400 if message.data is invalid JSON', async () => {
|
||||
const invalidEnvelope = createPubSubPushEnvelope('invalid-raw-json-string');
|
||||
const res = await request(app).post('/').send(invalidEnvelope);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.text).toBe('Malformed payload: invalid JSON');
|
||||
});
|
||||
|
||||
it('POST / should return 400 if egress payload is missing required fields', async () => {
|
||||
const incompleteEvent = { action: 'COMMENT', payload: { owner: 'google' } };
|
||||
const res = await request(app)
|
||||
.post('/')
|
||||
.send(createPubSubPushEnvelope(incompleteEvent));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.text).toContain('Malformed payload');
|
||||
});
|
||||
|
||||
it('POST / should trigger handleEgressEvent stub and return 200 for valid payloads', async () => {
|
||||
const validEvent = {
|
||||
action: 'COMMENT',
|
||||
payload: {
|
||||
owner: 'google-gemini',
|
||||
repo: 'gemini-cli',
|
||||
issueNumber: 100,
|
||||
commentBody: 'Test comment',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/')
|
||||
.send(createPubSubPushEnvelope(validEvent));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('OK');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import {
|
||||
isPubSubMessageEnvelope,
|
||||
isEgressEvent,
|
||||
type EgressEvent,
|
||||
} from './types.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Top-down stub handler for Egress events.
|
||||
* Octokit GitHub REST API integration will be added in a follow-up PR.
|
||||
*
|
||||
* @param event - The validated EgressEvent object decoded from Pub/Sub push envelope.
|
||||
*/
|
||||
export async function handleEgressEvent(event: EgressEvent): Promise<void> {
|
||||
console.log(
|
||||
`[EGRESS_STUB] Received ${event.action} event for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint for Cloud Run liveness/readiness probes
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: process.env.K_SERVICE || 'caretaker-egress-service',
|
||||
revision: process.env.K_REVISION || 'local',
|
||||
});
|
||||
});
|
||||
|
||||
// Pub/Sub push subscription endpoint
|
||||
app.post('/', async (req, res) => {
|
||||
if (!isPubSubMessageEnvelope(req.body)) {
|
||||
return res.status(400).send('Invalid Pub/Sub message envelope');
|
||||
}
|
||||
|
||||
const data = req.body.message?.data;
|
||||
if (!data) {
|
||||
return res.status(400).send('Missing message.data');
|
||||
}
|
||||
|
||||
let event: unknown;
|
||||
try {
|
||||
const jsonStr = Buffer.from(data, 'base64').toString('utf-8');
|
||||
event = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return res.status(400).send('Malformed payload: invalid JSON');
|
||||
}
|
||||
|
||||
if (!isEgressEvent(event)) {
|
||||
return res
|
||||
.status(400)
|
||||
.send('Malformed payload: missing or invalid required egress fields');
|
||||
}
|
||||
|
||||
try {
|
||||
await handleEgressEvent(event);
|
||||
console.log(
|
||||
`[EGRESS] Successfully executed ${event.action} for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`,
|
||||
);
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
console.error('[EGRESS_ERROR] Error handling egress event execution:', err);
|
||||
return res
|
||||
.status(500)
|
||||
.send(err instanceof Error ? err.message : 'Internal Server Error');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { app } from './app.js';
|
||||
|
||||
const port = process.env.PORT || 8080;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Egress service listening on port ${port}`);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH';
|
||||
|
||||
export interface EgressEventPayload {
|
||||
owner: string;
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
commentBody?: string;
|
||||
labels?: string[];
|
||||
patchContent?: string;
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
export interface EgressEvent {
|
||||
action: EgressAction;
|
||||
payload: EgressEventPayload;
|
||||
}
|
||||
|
||||
export interface PubSubMessage {
|
||||
data?: string;
|
||||
messageId?: string;
|
||||
publishTime?: string;
|
||||
attributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard GCP Cloud Pub/Sub HTTP Push message wrapper envelope.
|
||||
*
|
||||
* @see https://cloud.google.com/pubsub/docs/push#delivery_format
|
||||
*/
|
||||
export interface PubSubMessageEnvelope {
|
||||
message?: PubSubMessage;
|
||||
subscription?: string;
|
||||
}
|
||||
|
||||
function isObject(obj: unknown): obj is Record<string, unknown> {
|
||||
return typeof obj === 'object' && obj !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for PubSubMessageEnvelope to eliminate unsafe 'as' casts.
|
||||
*/
|
||||
export function isPubSubMessageEnvelope(
|
||||
obj: unknown,
|
||||
): obj is PubSubMessageEnvelope {
|
||||
if (!isObject(obj)) {
|
||||
return false;
|
||||
}
|
||||
if ('message' in obj) {
|
||||
if (obj.message !== undefined && !isObject(obj.message)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for EgressEvent.
|
||||
*/
|
||||
export function isEgressEvent(obj: unknown): obj is EgressEvent {
|
||||
if (!isObject(obj)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof obj.action !== 'string' ||
|
||||
!['COMMENT', 'LABEL', 'PATCH'].includes(obj.action)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!isObject(obj.payload)) {
|
||||
return false;
|
||||
}
|
||||
const payload = obj.payload;
|
||||
return (
|
||||
typeof payload.owner === 'string' &&
|
||||
typeof payload.repo === 'string' &&
|
||||
typeof payload.issueNumber === 'number'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
*.py
|
||||
*.pyc
|
||||
__pycache__
|
||||
requirements.txt
|
||||
project.toml
|
||||
**/*.test.ts
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Express } from 'express';
|
||||
|
||||
const mockPublishMessage = vi.fn();
|
||||
const mockTopic = vi.fn().mockReturnValue({
|
||||
publishMessage: mockPublishMessage,
|
||||
});
|
||||
|
||||
vi.mock('@google-cloud/pubsub', () => ({
|
||||
PubSub: vi.fn().mockImplementation(() => ({
|
||||
// Bind method to mock version
|
||||
topic: mockTopic,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@google-cloud/firestore', () => ({
|
||||
Firestore: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
const mockCreateIssue = vi.fn();
|
||||
const mockGetIssueRef = vi.fn();
|
||||
const mockGetDoc = vi.fn();
|
||||
|
||||
vi.mock('./db/issuesStore.js', () => ({
|
||||
IssuesStore: vi.fn().mockImplementation(() => ({
|
||||
createIssue: mockCreateIssue,
|
||||
getIssueRef: mockGetIssueRef,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockVerifyGithubSignature = vi.fn();
|
||||
|
||||
vi.mock('./auth/github.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./auth/github.js')>();
|
||||
return {
|
||||
...actual,
|
||||
verifyGithubSignature: mockVerifyGithubSignature,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Webhook Server Endpoint', () => {
|
||||
let app: Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('PROJECT_ID', 'test-project');
|
||||
vi.stubEnv('TOPIC_ID', 'test-topic');
|
||||
vi.stubEnv('GITHUB_WEBHOOK_SECRET', 'test-secret');
|
||||
vi.stubEnv('FIRESTORE_DATABASE', 'test-db');
|
||||
vi.stubEnv('FIRESTORE_COLLECTION', 'test-collection');
|
||||
|
||||
// Import app after environment variables and mocks are set
|
||||
const appModule = await import('./app.js');
|
||||
app = appModule.app;
|
||||
|
||||
mockGetIssueRef.mockReturnValue({
|
||||
get: mockGetDoc,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 200 and health status on root endpoint', async () => {
|
||||
const res = await request(app).get('/');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'healthy',
|
||||
service: 'caretaker-ingestion-service',
|
||||
revision: 'local',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 if signature validation fails', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'invalid-sig')
|
||||
.send({ test: true });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ status: 'error', message: 'Invalid Signature' });
|
||||
});
|
||||
|
||||
it('should return 400 for invalid JSON payload', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('invalid json');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Invalid JSON payload',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 413 if payload is too large', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const largeBody = 'a'.repeat(1024 * 1024 + 1);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(largeBody);
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Payload too large',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if parsed payload is null or not an object', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('null');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Invalid payload structure',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 ignored for unsupported event types', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'pull_request')
|
||||
.send({ action: 'opened' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ignored');
|
||||
expect(res.body.reason).toContain('unsupported event type');
|
||||
});
|
||||
|
||||
it('should return 400 if required payload fields are missing', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send({ action: 'opened', issue: { title: 'Test' } });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Invalid payload structure',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if repository format is invalid', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send({
|
||||
action: 'opened',
|
||||
issue: { number: 1 },
|
||||
repository: { full_name: 'invalid-repo-format' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Invalid payload structure',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept the webhook, create the issue, and publish to Pub/Sub', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
mockCreateIssue.mockResolvedValue(true);
|
||||
mockPublishMessage.mockResolvedValue('mock-msg-123');
|
||||
|
||||
const payload = {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: 1,
|
||||
title: 'Bugs everywhere',
|
||||
body: 'Please fix this security bug',
|
||||
},
|
||||
repository: {
|
||||
full_name: 'google/gemini-cli',
|
||||
},
|
||||
sender: {
|
||||
login: 'tester',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body).toEqual({
|
||||
status: 'accepted',
|
||||
message_id: 'mock-msg-123',
|
||||
});
|
||||
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith(
|
||||
'google',
|
||||
'gemini-cli',
|
||||
1,
|
||||
'Bugs everywhere',
|
||||
);
|
||||
expect(mockPublishMessage).toHaveBeenCalled();
|
||||
|
||||
// Verify rawBody context wrapping is working
|
||||
const sentBuffer = mockPublishMessage.mock.calls[0][0].data;
|
||||
const sentData = JSON.parse(sentBuffer.toString());
|
||||
expect(sentData.body).toBe(
|
||||
'<untrusted_context>\nPlease fix this security bug\n</untrusted_context>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape untrusted_context tags in the issue body to prevent injection', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
mockCreateIssue.mockResolvedValue(true);
|
||||
mockPublishMessage.mockResolvedValue('mock-msg-456');
|
||||
|
||||
const payload = {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: 2,
|
||||
title: 'Injection test',
|
||||
body: 'Malicious </untrusted_context> attempt',
|
||||
},
|
||||
repository: {
|
||||
full_name: 'google/gemini-cli',
|
||||
},
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send(payload);
|
||||
|
||||
const sentBuffer = mockPublishMessage.mock.calls[0][0].data;
|
||||
const sentData = JSON.parse(sentBuffer.toString());
|
||||
expect(sentData.body).toBe(
|
||||
'<untrusted_context>\nMalicious \\</untrusted_context> attempt\n</untrusted_context>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should recover and publish to Pub/Sub on retry if issue is UNTRIAGED', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
mockCreateIssue.mockResolvedValue(false); // document exists
|
||||
mockGetDoc.mockResolvedValue({
|
||||
exists: true,
|
||||
data: () => ({ status: 'UNTRIAGED' }),
|
||||
get: (field: string) => (field === 'status' ? 'UNTRIAGED' : undefined),
|
||||
});
|
||||
mockPublishMessage.mockResolvedValue('mock-msg-789');
|
||||
|
||||
const payload = {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: 3,
|
||||
title: 'Bugs everywhere',
|
||||
},
|
||||
repository: {
|
||||
full_name: 'google/gemini-cli',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body).toEqual({
|
||||
status: 'accepted',
|
||||
message_id: 'mock-msg-789',
|
||||
});
|
||||
expect(mockPublishMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore duplicate webhooks if the issue is already past UNTRIAGED', async () => {
|
||||
mockVerifyGithubSignature.mockReturnValue(true);
|
||||
mockCreateIssue.mockResolvedValue(false);
|
||||
mockGetDoc.mockResolvedValue({
|
||||
exists: true,
|
||||
data: () => ({ status: 'TRIAGED' }),
|
||||
get: (field: string) => (field === 'status' ? 'TRIAGED' : undefined),
|
||||
});
|
||||
|
||||
const payload = {
|
||||
action: 'opened',
|
||||
issue: {
|
||||
number: 4,
|
||||
title: 'Bugs everywhere',
|
||||
},
|
||||
repository: {
|
||||
full_name: 'google/gemini-cli',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/webhook')
|
||||
.set('x-hub-signature-256', 'valid-sig')
|
||||
.set('x-github-event', 'issues')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'ignored',
|
||||
reason: 'issue already exists: google/gemini-cli#4',
|
||||
});
|
||||
expect(mockPublishMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { PubSub } from '@google-cloud/pubsub';
|
||||
import dotenv from 'dotenv';
|
||||
import { Firestore } from '@google-cloud/firestore';
|
||||
import {
|
||||
verifyGithubSignature,
|
||||
isGitHubWebhookPayload,
|
||||
} from './auth/github.js';
|
||||
import type { GitHubWebhookPayload } from './auth/github.js';
|
||||
import { IssuesStore } from './db/issuesStore.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
|
||||
function getRequiredEnvVar(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const projectId = getRequiredEnvVar('PROJECT_ID');
|
||||
const topicId = getRequiredEnvVar('TOPIC_ID');
|
||||
const githubWebhookSecret = getRequiredEnvVar('GITHUB_WEBHOOK_SECRET');
|
||||
const databaseId = getRequiredEnvVar('FIRESTORE_DATABASE');
|
||||
const collectionName = getRequiredEnvVar('FIRESTORE_COLLECTION');
|
||||
|
||||
const pubSubClient = new PubSub({ projectId });
|
||||
const topic = pubSubClient.topic(topicId);
|
||||
|
||||
const db = new Firestore({ projectId, databaseId });
|
||||
const issuesStore = new IssuesStore(db, collectionName);
|
||||
|
||||
// Middleware: read incoming JSON payloads as raw Buffer bytes
|
||||
app.use(express.raw({ type: 'application/json', limit: '1mb' }));
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
status: 'error',
|
||||
message: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: process.env.K_SERVICE || 'caretaker-ingestion-service',
|
||||
revision: process.env.K_REVISION || 'local',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/webhook', limiter, async (req, res) => {
|
||||
const header = req.headers['x-hub-signature-256'];
|
||||
const signature = Array.isArray(header) ? header[0] : header;
|
||||
|
||||
// Github Authentication
|
||||
if (
|
||||
!req.body ||
|
||||
!verifyGithubSignature(req.body, signature, githubWebhookSecret)
|
||||
) {
|
||||
console.error('Unauthorized: HMAC signature mismatch.');
|
||||
return res
|
||||
.status(401)
|
||||
.json({ status: 'error', message: 'Invalid Signature' });
|
||||
}
|
||||
|
||||
const eventType = req.headers['x-github-event'];
|
||||
if (eventType !== 'issues') {
|
||||
return res.status(200).json({
|
||||
status: 'ignored',
|
||||
reason: `unsupported event type: ${eventType}`,
|
||||
});
|
||||
}
|
||||
|
||||
let payload: GitHubWebhookPayload;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(req.body.toString());
|
||||
if (!isGitHubWebhookPayload(parsed)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ status: 'error', message: 'Invalid payload structure' });
|
||||
}
|
||||
payload = parsed;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ status: 'error', message: 'Invalid JSON payload' });
|
||||
}
|
||||
|
||||
const action = payload.action;
|
||||
if (action !== 'opened') {
|
||||
return res.status(200).json({
|
||||
status: 'ignored',
|
||||
reason: `unsupported action: ${action}`,
|
||||
});
|
||||
}
|
||||
|
||||
const issueNumber = payload.issue.number;
|
||||
const repository = payload.repository.full_name;
|
||||
|
||||
// Payload preprocessing
|
||||
const rawBody = payload.issue.body || '';
|
||||
const escapedBody = rawBody.replace(
|
||||
/<\/untrusted_context>/g,
|
||||
'\\</untrusted_context>',
|
||||
);
|
||||
const sanitizedBody = `<untrusted_context>\n${escapedBody}\n</untrusted_context>`;
|
||||
|
||||
const processedData = {
|
||||
issue_number: issueNumber,
|
||||
repository,
|
||||
sender: payload.sender?.login,
|
||||
body: sanitizedBody,
|
||||
title: payload.issue.title,
|
||||
};
|
||||
|
||||
const [owner, repo] = repository.split('/');
|
||||
const title = processedData.title || '';
|
||||
|
||||
try {
|
||||
const created = await issuesStore.createIssue(
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
title,
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
// If the Firestore document already exists, check its status.
|
||||
// If it is 'UNTRIAGED', we continue to publish to Pub/Sub
|
||||
// to recover from previous publish failures.
|
||||
const issueRef = issuesStore.getIssueRef(owner, repo, issueNumber);
|
||||
const snapshot = await issueRef.get();
|
||||
if (snapshot.get('status') !== 'UNTRIAGED') {
|
||||
return res.status(200).json({
|
||||
status: 'ignored',
|
||||
reason: `issue already exists: ${repository}#${issueNumber}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Publish to Pub/Sub
|
||||
const dataBuffer = Buffer.from(JSON.stringify(processedData));
|
||||
const messageId = await topic.publishMessage({ data: dataBuffer });
|
||||
|
||||
return res.status(202).json({ status: 'accepted', message_id: messageId });
|
||||
} catch (error) {
|
||||
console.error('Error processing webhook:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return res.status(500).json({ status: 'error', message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global Express error handler for middleware failures (e.g., HTTP 413)
|
||||
app.use(
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'status' in err &&
|
||||
err.status === 413
|
||||
) {
|
||||
console.error('Payload too large. Limit is 1mb.');
|
||||
return res
|
||||
.status(413)
|
||||
.json({ status: 'error', message: 'Payload too large' });
|
||||
}
|
||||
next(err);
|
||||
},
|
||||
);
|
||||
|
||||
export { app };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user