mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-10 00:47:16 -07:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5634951e | |||
| ae5f5edb58 | |||
| 76a97cac0e | |||
| a74b483d14 | |||
| a81db2768f | |||
| d3c51f158c | |||
| 5f9c117de2 | |||
| d29268d360 | |||
| fccc043bd4 | |||
| c5622fec27 | |||
| e07280eb4e | |||
| bef6119500 | |||
| b94c9775b1 | |||
| 3818efbbfb | |||
| e2a5375d10 | |||
| 69b51f8fa2 | |||
| 3c1bb8c35d | |||
| d76d2d0742 | |||
| a96259c9e5 | |||
| 87f785192c | |||
| 1c21640f97 | |||
| 455d721a0c | |||
| 9681621c6b | |||
| f743ab5790 | |||
| c776c665b0 | |||
| acae7124bd | |||
| 69e0c2659e | |||
| aea9e5e8ed | |||
| 3ff5ba20fc | |||
| 1ae8ba6496 | |||
| fa975395bc | |||
| a345621404 | |||
| f5e58ff18b | |||
| 42ee2b74c7 | |||
| f354eebaf4 | |||
| a4c91ce191 | |||
| 9a023bbba0 | |||
| a8b115caa1 | |||
| 172ff92c34 | |||
| 70f4d573f2 | |||
| 132f38c3a4 | |||
| b31b755bbf | |||
| c988cbb1e3 | |||
| b7c61c9e3d | |||
| 27a3da3e88 | |||
| 15a9429b69 | |||
| 892b35fcfb | |||
| f7af4e5180 | |||
| ff00dacd9f | |||
| 7f00c5fe59 | |||
| b5fc06ee33 | |||
| ae0a3aa7b9 | |||
| b14416447e | |||
| 8cd5c0f71f | |||
| df997354c8 | |||
| 19ad71b903 | |||
| 3fbf93e26f | |||
| d845bc5d45 | |||
| 02c6c77324 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 != '' }}"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Testing: Tools (Python)'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
name: 'Python Tests'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Set up Python'
|
||||
uses: 'actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55' # ratchet:actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'tools/caretaker-agent/cloudrun/triage-worker/requirements.txt'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f tools/caretaker-agent/cloudrun/triage-worker/requirements.txt ]; then
|
||||
python -m pip install -r tools/caretaker-agent/cloudrun/triage-worker/requirements.txt
|
||||
fi
|
||||
|
||||
- name: 'Run unittest suite'
|
||||
run: |
|
||||
PYTHONPATH=tools/caretaker-agent/cloudrun/triage-worker python -m unittest discover -s tools/caretaker-agent/cloudrun/triage-worker/tests -t tools/caretaker-agent/cloudrun/triage-worker
|
||||
+3
-3
@@ -421,9 +421,9 @@ To debug the CLI's React-based UI, you can use React DevTools.
|
||||
|
||||
On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open`
|
||||
profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that
|
||||
restricts writes to the project folder but otherwise allows all other operations
|
||||
and outbound network traffic ("open") by default. You can switch to a
|
||||
`strict-open` profile (see
|
||||
denies operations by default, confining writes to the project folder while
|
||||
allowing broad file reads and outbound network traffic ("open") by default. You
|
||||
can switch to a `strict-open` profile (see
|
||||
`packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads
|
||||
and writes to the working directory while allowing outbound network traffic by
|
||||
setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file.
|
||||
|
||||
@@ -18,6 +18,39 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.52.0 - 2026-07-22
|
||||
|
||||
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
|
||||
foundational modules, main worker execution loops, and egress action
|
||||
publishers alongside the octokit GitHub Action handler for egress services
|
||||
([#28163](https://github.com/google-gemini/gemini-cli/pull/28163),
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306) by @chadd28).
|
||||
- **Core Tool Enhancements:** Bypassed LLM correction for JSON and IPYNB files
|
||||
in `write_file` and `replace` tools, and simplified plan mode write policy to
|
||||
support relative paths
|
||||
([#28223](https://github.com/google-gemini/gemini-cli/pull/28223) by
|
||||
@amelidev, [#28398](https://github.com/google-gemini/gemini-cli/pull/28398) by
|
||||
@DavidAPierce).
|
||||
- **Auth & Privacy Improvements:** Displayed clear error messages when user
|
||||
account has no Code Assist tier, and bumped `google-auth-library` to version
|
||||
10.9.0 ([#28304](https://github.com/google-gemini/gemini-cli/pull/28304) by
|
||||
@ompatel-aiml,
|
||||
[#28385](https://github.com/google-gemini/gemini-cli/pull/28385) by
|
||||
@jerrylin3321).
|
||||
|
||||
## Announcements: v0.50.0 - 2026-07-08
|
||||
|
||||
- **Tool Registry Discovery:** Introduced tool registry discovery capabilities
|
||||
to automatically detect and register available tools
|
||||
([#28113](https://github.com/google-gemini/gemini-cli/pull/28113) by @ved015).
|
||||
- **Release Verification & CI Stability:** Enhanced release verification by
|
||||
ignoring scripts during verification, preventing workspace binary shadowing,
|
||||
and safeguarding against bad NPM releases
|
||||
([#28116](https://github.com/google-gemini/gemini-cli/pull/28116) by
|
||||
@rmedranollamas,
|
||||
[#28132](https://github.com/google-gemini/gemini-cli/pull/28132) by
|
||||
@galdawave).
|
||||
|
||||
## Announcements: v0.45.0 - 2026-06-03
|
||||
|
||||
- **Context Simplification:** Completed major architectural work to simplify the
|
||||
|
||||
+51
-47
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.45.0
|
||||
# Latest stable release: v0.52.0
|
||||
|
||||
Released: June 03, 2026
|
||||
Released: July 22, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,55 +11,59 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Context Manager Simplification:** Completed a significant refactoring of the
|
||||
context management system to improve reliability and architectural clarity.
|
||||
- **A2A Usage Metadata:** Enhanced the Agent-to-Agent protocol to expose usage
|
||||
metadata, enabling more transparent resource monitoring.
|
||||
- **Terminal & PTY Robustness:** Resolved several critical issues related to
|
||||
terminal interactions, including Termux relaunch loops and PTY resize errors.
|
||||
- **Routing Optimizations:** Updated default auto-routing and bypassed
|
||||
classifiers for specific tool responses to prevent orphaned function errors.
|
||||
- **Tool Execution Control:** Forced the `update_topic` tool to execute
|
||||
sequentially, ensuring consistent narrative flow in agent interactions.
|
||||
- **Caretaker Services:** Introduced a new caretaker triage worker including
|
||||
core foundational modules, main worker execution loops, egress action
|
||||
publishers, and octokit GitHub Action handlers.
|
||||
- **Robust File Editing:** Core tools like `write_file` and `replace` now bypass
|
||||
LLM corrections for JSON and IPYNB files to ensure accurate and direct file
|
||||
modifications.
|
||||
- **Plan Mode Improvements:** Simplified plan mode write policies to natively
|
||||
support writing to relative paths, enhancing project directory navigation.
|
||||
- **Enhanced Account Visibility:** Improved clear user-facing messages when the
|
||||
user account does not have a Code Assist tier, and enriched shared project
|
||||
quota limit errors with setup instructions.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
|
||||
- Refactor: exclude transient CI configuration files from workspace context by
|
||||
@DavidAPierce in
|
||||
[#28216](https://github.com/google-gemini/gemini-cli/pull/28216)
|
||||
- feat(caretaker-triage): add triage worker core foundational modules by
|
||||
@chadd28 in [#28163](https://github.com/google-gemini/gemini-cli/pull/28163)
|
||||
- feat(caretaker-egress): implement octokit github action handler for egress
|
||||
service by @chadd28 in
|
||||
[#28303](https://github.com/google-gemini/gemini-cli/pull/28303)
|
||||
- chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 by
|
||||
@gemini-cli-robot in
|
||||
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
|
||||
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
|
||||
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
|
||||
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
|
||||
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
|
||||
- feat(context): Complete simplification work. by @joshualitt in
|
||||
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
|
||||
- fix(core): force update_topic tool to execute sequentially by
|
||||
@jvargassanchez-dot in
|
||||
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
|
||||
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
|
||||
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
|
||||
- Changelog for v0.43.0 by @gemini-cli-robot in
|
||||
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
|
||||
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
|
||||
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
|
||||
- fix(cli): filter internal session context from history during resumption by
|
||||
@rmedranollamas in
|
||||
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
|
||||
- Update default auto routing by @DavidAPierce in
|
||||
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
|
||||
- fix(core): bypass routing classifiers to prevent orphaned function response
|
||||
errors by @danielweis in
|
||||
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
|
||||
- fix(core): suppress PTY resize EBADF errors by @scidomino in
|
||||
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
|
||||
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
|
||||
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
|
||||
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
|
||||
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
|
||||
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
|
||||
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
|
||||
[#28323](https://github.com/google-gemini/gemini-cli/pull/28323)
|
||||
- Changelog for v0.51.0-preview.0 by @gemini-cli-robot in
|
||||
[#28320](https://github.com/google-gemini/gemini-cli/pull/28320)
|
||||
- Changelog for v0.50.0 by @gemini-cli-robot in
|
||||
[#28322](https://github.com/google-gemini/gemini-cli/pull/28322)
|
||||
- fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file
|
||||
and replace by @amelidev in
|
||||
[#28223](https://github.com/google-gemini/gemini-cli/pull/28223)
|
||||
- fix(core): use unambiguous previous intent label in fallback summary by
|
||||
@amelidev in [#28343](https://github.com/google-gemini/gemini-cli/pull/28343)
|
||||
- feat(caretaker-triage): implement main worker execution loop and egress action
|
||||
publisher by @chadd28 in
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306)
|
||||
- fix(privacy): show a clear message when the account has no Code Assist tier by
|
||||
@ompatel-aiml in
|
||||
[#28304](https://github.com/google-gemini/gemini-cli/pull/28304)
|
||||
- fix(core): enrich shared project quota limit errors with setup hint by
|
||||
@amelidev in [#28391](https://github.com/google-gemini/gemini-cli/pull/28391)
|
||||
- fix(a2a-server): ensure task cancellation aborts execution loop by
|
||||
@luisfelipe-alt in
|
||||
[#28316](https://github.com/google-gemini/gemini-cli/pull/28316)
|
||||
- fix(core): simplify plan mode write policy to support relative paths by
|
||||
@DavidAPierce in
|
||||
[#28398](https://github.com/google-gemini/gemini-cli/pull/28398)
|
||||
- feat(core): Bump node google-auth-library version to 10.9.0 by @jerrylin3321
|
||||
in [#28385](https://github.com/google-gemini/gemini-cli/pull/28385)
|
||||
- chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b by
|
||||
@gemini-cli-robot in
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
[#28402](https://github.com/google-gemini/gemini-cli/pull/28402)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.51.0...v0.52.0
|
||||
|
||||
+35
-51
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.48.0-preview.0
|
||||
# Preview release: v0.53.0-preview.0
|
||||
|
||||
Released: June 17, 2026
|
||||
Released: July 22, 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).
|
||||
@@ -13,58 +13,42 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **GDC Service Identity Support**: Added support for GDC air-gapped Service
|
||||
Identity after a major auth library update.
|
||||
- **Standardised Tool Outputs**: Standardised tool output formatting to ensure
|
||||
consistency and readability across different CLI commands.
|
||||
- **Static Evaluation Analyzer**: Introduced a new static evaluation source
|
||||
analyzer to improve development and testing.
|
||||
- **Vulnerability Prevention**: Hardened CLI security by preventing path
|
||||
traversal vulnerabilities during the installation of Skills.
|
||||
- **Configuration & Error Hardening**: Migrated the `coreTools` configuration
|
||||
setting to `tools.core` and ensured zero-quota limits fail fast to prevent
|
||||
infinite retry loops.
|
||||
- **Caretaker LLM Triage Orchestrator**: Implemented the LLM triage orchestrator
|
||||
and container build configuration to support caretaker triage workflows.
|
||||
- **Enhanced Workspace Trust & Sandbox Hardening**: Aligned macOS permissive
|
||||
Seatbelt profiles with the deny-default model and enforced workspace trust and
|
||||
task isolation in the Agent-to-Agent (A2A) server to prevent remote code
|
||||
execution (RCE).
|
||||
- **Core Robustness & API Protections**: Mitigated infinite ReAct and prompt
|
||||
injection loops, and prevented 400 Bad Request errors by grouping cancelled
|
||||
tool responses and coalescing consecutive roles.
|
||||
- **Robust Credentials & Fallbacks**: Restored the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable fallback and
|
||||
sequentially verified cached credentials.
|
||||
- **Evaluation Coverage Reporting**: Added a new command to generate
|
||||
comprehensive eval coverage reports.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- 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)
|
||||
- ci(dependabot): enable cooldown period for npm packages by @ruomengz in
|
||||
[#27743](https://github.com/google-gemini/gemini-cli/pull/27743)
|
||||
- refactor(core): standardize tool output formatting by @galz10 in
|
||||
[#27772](https://github.com/google-gemini/gemini-cli/pull/27772)
|
||||
- ci: update workflow logging and policy configurations by @galz10 in
|
||||
[#27853](https://github.com/google-gemini/gemini-cli/pull/27853)
|
||||
- fix(core): Ensure zero-quota limits fail fast to prevent retry loop hang by
|
||||
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
|
||||
to prevent 400 Bad Request by @luisfelipe-alt in
|
||||
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
|
||||
- feat(caretaker-triage): implement LLM triage orchestrator and container build
|
||||
by @chadd28 in
|
||||
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
|
||||
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
|
||||
model by @ompatel-aiml in
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
|
||||
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
|
||||
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
|
||||
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
|
||||
@luisfelipe-alt in
|
||||
[#27698](https://github.com/google-gemini/gemini-cli/pull/27698)
|
||||
- fix(core): handle multi-line escaped quotes in stripShellWrapper by
|
||||
@sanchezcoraspe in
|
||||
[#27467](https://github.com/google-gemini/gemini-cli/pull/27467)
|
||||
- fix(cli): prevent path traversal vulnerabilities during skill install… by
|
||||
@ompatel-aiml in
|
||||
[#27767](https://github.com/google-gemini/gemini-cli/pull/27767)
|
||||
- Fix/pending tools and trust overrides by @jvargassanchez-dot in
|
||||
[#27854](https://github.com/google-gemini/gemini-cli/pull/27854)
|
||||
- ci: use internal environment for scheduled nightly releases (#27865) by
|
||||
@rmedranollamas in
|
||||
[#27939](https://github.com/google-gemini/gemini-cli/pull/27939)
|
||||
- feat(core): Support GDC air-gapped Service Identity after auth library update
|
||||
by @sidhantgoyal-droid in
|
||||
[#27956](https://github.com/google-gemini/gemini-cli/pull/27956)
|
||||
- fix(cli): handle tmux false positive background detection by @amelidev in
|
||||
[#27572](https://github.com/google-gemini/gemini-cli/pull/27572)
|
||||
- Add static eval source analyzer by @ved015 in
|
||||
[#27631](https://github.com/google-gemini/gemini-cli/pull/27631)
|
||||
- fix(config): migrate coreTools setting to tools.core by @galz10 in
|
||||
[#27947](https://github.com/google-gemini/gemini-cli/pull/27947)
|
||||
- fix(core-tools): resolve defensive path resolution for at-reference files by
|
||||
@luisfelipe-alt in
|
||||
[#27943](https://github.com/google-gemini/gemini-cli/pull/27943)
|
||||
- Revert "fix(core-tools): resolve defensive path resolution for at-reference
|
||||
files" by @galz10 in
|
||||
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
|
||||
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
|
||||
- fix(core): sequentially verify cached credentials and restore
|
||||
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
|
||||
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
|
||||
- feat(evals): add eval coverage report command by @ved015 in
|
||||
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
|
||||
|
||||
**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.52.0-preview.0...v0.53.0-preview.0
|
||||
|
||||
+3
-2
@@ -87,8 +87,9 @@ preferred container solution.
|
||||
|
||||
Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
**Default profile**: `permissive-open` - denies operations by default; confines
|
||||
writes to the project directory while allowing broad file reads and network
|
||||
access.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
|
||||
@@ -2736,10 +2736,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Run the CLI once with this set to generate the file.
|
||||
- **`SEATBELT_PROFILE`** (macOS specific):
|
||||
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
|
||||
- `permissive-open`: (Default) Restricts writes to the project folder (and a
|
||||
few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other
|
||||
operations.
|
||||
- `permissive-open`: (Default) Denies operations by default, confining writes
|
||||
to the project folder (and a few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing
|
||||
broad file reads and network access.
|
||||
- `restrictive-open`: Declines operations by default, allows network.
|
||||
- `strict-open`: Restricts both reads and writes to the working directory,
|
||||
allows network.
|
||||
|
||||
+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
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('Interactive file system', () => {
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
const readPrompt = `Read the version from ${fileName} using the read_file tool`;
|
||||
await run.type(readPrompt);
|
||||
await run.type('\r');
|
||||
|
||||
|
||||
Generated
+94
-1455
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.2",
|
||||
"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.54.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -34,6 +34,7 @@
|
||||
"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",
|
||||
"eval:coverage": "tsx ./scripts/eval-coverage-cli.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
@@ -95,8 +96,10 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"read-package-up": "11.0.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/minimatch": "5.1.2",
|
||||
@@ -121,6 +124,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"express": "5.1.0",
|
||||
"glob": "12.0.0",
|
||||
"globals": "16.0.0",
|
||||
"google-artifactregistry-auth": "3.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.1",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,7 +14,24 @@ import type {
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { requestStorage } from '../http/requestStorage.js';
|
||||
|
||||
vi.mock('../utils/path_utils.js', () => ({
|
||||
validateWorkspacePath: vi
|
||||
.fn()
|
||||
.mockImplementation(async (path?: string) => path || process.cwd()),
|
||||
}));
|
||||
|
||||
// Mocks for constructor dependencies
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
GeminiEventType: {
|
||||
PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
|
||||
SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
|
||||
},
|
||||
SimpleExtensionLoader: vi.fn(),
|
||||
checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
|
||||
isHeadlessMode: vi.fn().mockReturnValue(true),
|
||||
resolveToRealPath: vi.fn().mockImplementation((p) => p),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
getSessionId: () => 'test-session',
|
||||
@@ -24,6 +41,10 @@ vi.mock('../config/config.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
setIsTrusted: vi.fn().mockReturnValue(false),
|
||||
setTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
envStorage: {
|
||||
run: (env: Record<string, string>, cb: () => unknown) => cb(),
|
||||
},
|
||||
cwdSymbol: Symbol('cwd'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', () => ({
|
||||
@@ -300,4 +321,125 @@ describe('CoderAgentExecutor', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('cancelTask should abort the active execution loop', async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
const taskId = 'test-task-to-cancel';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
// Don't await this, let it run in the background.
|
||||
let primaryError: Error | null = null;
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
primaryPromise.catch((err) => {
|
||||
primaryError = err as Error;
|
||||
});
|
||||
|
||||
// Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (primaryError) {
|
||||
throw new Error(`Primary execution failed early: ${primaryError}`);
|
||||
}
|
||||
if (attempts++ > 100) {
|
||||
// 100 * 5ms = 500ms timeout
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId);
|
||||
expect(wrapper).toBeDefined();
|
||||
const setTaskStateSpy = vi
|
||||
.spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
|
||||
.mockImplementation((newState) => {
|
||||
// Make the mock realistic: actually update the state when called.
|
||||
wrapper!.task.taskState = newState;
|
||||
});
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the abort method on the controller was called and state was updated.
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
expect(setTaskStateSpy).toHaveBeenCalledWith(
|
||||
'canceled',
|
||||
expect.any(Object),
|
||||
'Task canceled by user request.',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
// The abort call should have unblocked the acceptUserMessage generator.
|
||||
await primaryPromise;
|
||||
|
||||
// Verify task is evicted from cache
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
|
||||
const taskId = 'test-task-active-abort-save';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
|
||||
// Wait for task to be registered
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (attempts++ > 100) {
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId)!;
|
||||
const saveSpy = vi.spyOn(mockTaskStore, 'save');
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the task state was saved to TaskStore during cancelTask
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(wrapper.task.dispose).toHaveBeenCalled();
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
await primaryPromise;
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -752,4 +752,107 @@ describe('Task', () => {
|
||||
expect(changed3).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProposedContent (CRLF Line Ending Normalization)', () => {
|
||||
it('should successfully replace LF-based strings in CRLF-based files', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
getTargetDir: () => os.tmpdir(),
|
||||
validatePathAccess: () => null,
|
||||
});
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt');
|
||||
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
|
||||
fs.writeFileSync(tempFile, crlfContent, 'utf8');
|
||||
|
||||
try {
|
||||
const oldString = 'line2\n';
|
||||
const newString = 'line2-optimized\n';
|
||||
|
||||
const result = await task['getProposedContent'](
|
||||
tempFile,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
|
||||
expect(result).toContain('line2-optimized');
|
||||
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
|
||||
} finally {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
getTargetDir: () => os.tmpdir(),
|
||||
validatePathAccess: () => null,
|
||||
});
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const tempFile = path.resolve(
|
||||
os.tmpdir(),
|
||||
'crlf_test_file_crlf_inputs.txt',
|
||||
);
|
||||
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
|
||||
fs.writeFileSync(tempFile, crlfContent, 'utf8');
|
||||
|
||||
try {
|
||||
const oldString = 'line2\r\n';
|
||||
const newString = 'line2-optimized\r\n';
|
||||
|
||||
const result = await task['getProposedContent'](
|
||||
tempFile,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
|
||||
expect(result).toContain('line2-optimized');
|
||||
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
|
||||
} finally {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -666,13 +666,18 @@ export class Task {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
return this._applyReplacement(
|
||||
const rawContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
const hasCrlf = rawContent.includes('\r\n');
|
||||
const currentContent = rawContent.replace(/\r\n/g, '\n');
|
||||
const normalizedOldString = old_string.replace(/\r\n/g, '\n');
|
||||
const normalizedNewString = new_string.replace(/\r\n/g, '\n');
|
||||
const proposedContent = this._applyReplacement(
|
||||
currentContent,
|
||||
old_string,
|
||||
new_string,
|
||||
old_string === '' && currentContent === '',
|
||||
normalizedOldString,
|
||||
normalizedNewString,
|
||||
normalizedOldString === '' && currentContent === '',
|
||||
);
|
||||
return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
|
||||
} catch (err) {
|
||||
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
@@ -1092,19 +1097,21 @@ export class Task {
|
||||
logger.info(
|
||||
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
|
||||
);
|
||||
const responsesToAdd = completedTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
for (const response of responsesToAdd) {
|
||||
let parts: genAiPart[];
|
||||
if (Array.isArray(response)) {
|
||||
parts = response;
|
||||
} else if (typeof response === 'string') {
|
||||
parts = [{ text: response }];
|
||||
} else {
|
||||
parts = [response];
|
||||
const parts: genAiPart[] = [];
|
||||
for (const toolCall of completedTools) {
|
||||
const response = toolCall.response?.responseParts;
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
parts.push(...response);
|
||||
} else if (typeof response === 'string') {
|
||||
parts.push({ text: response });
|
||||
} else {
|
||||
parts.push(response);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.geminiClient.addHistory({
|
||||
role: 'user',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
tmpdir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
@@ -28,28 +30,246 @@ import {
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
resolveToRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { type AgentSettings, CoderAgentEvent } from '../types.js';
|
||||
|
||||
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
|
||||
export const envStorage = new AsyncLocalStorage<TaskEnv>();
|
||||
|
||||
const deletedKeysSymbol = Symbol('deletedKeys');
|
||||
export const cwdSymbol = Symbol('cwd');
|
||||
|
||||
export interface TaskEnv extends Record<string, string | undefined> {
|
||||
[deletedKeysSymbol]?: Set<string>;
|
||||
[cwdSymbol]?: string;
|
||||
}
|
||||
|
||||
// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
|
||||
const originalEnv = process.env;
|
||||
const envProxy = new Proxy(originalEnv, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return taskEnv[prop];
|
||||
}
|
||||
}
|
||||
return target[prop];
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return target[prop as any];
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return prop in target;
|
||||
}
|
||||
return prop in target;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
target[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
target[prop as any] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
delete taskEnv[prop];
|
||||
(taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
|
||||
return true;
|
||||
}
|
||||
delete target[prop];
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return delete target[prop as any];
|
||||
},
|
||||
ownKeys(target) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const keys = new Set<string | symbol>([
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
...Object.keys(taskEnv),
|
||||
]);
|
||||
taskEnv[deletedKeysSymbol]?.forEach((key) => {
|
||||
keys.delete(key);
|
||||
});
|
||||
return Array.from(keys);
|
||||
}
|
||||
return [
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && typeof prop === 'string') {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return {
|
||||
value: taskEnv[prop],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
defineProperty(target, prop, descriptor) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] =
|
||||
descriptor.value !== undefined ? String(descriptor.value) : undefined;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.defineProperty(target, prop as any, descriptor);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'env', {
|
||||
value: envProxy,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
|
||||
// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
|
||||
// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
|
||||
// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
|
||||
// working directory of the process, NOT the JS-level process.cwd() function.
|
||||
// To prevent cross-task interference, all file paths in the core package must be resolved to
|
||||
// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
|
||||
// before being passed to native APIs.
|
||||
const originalCwd = process.cwd;
|
||||
process.cwd = function () {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && taskEnv[cwdSymbol]) {
|
||||
return taskEnv[cwdSymbol];
|
||||
}
|
||||
return originalCwd.call(process);
|
||||
};
|
||||
|
||||
const originalChdir = process.chdir;
|
||||
process.chdir = function (directory: string) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const resolved = path.resolve(process.cwd(), directory);
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
const err = new Error(
|
||||
"ENOTDIR: not a directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(err as NodeJS.ErrnoException).code = 'ENOTDIR';
|
||||
throw err;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const chdirErr = new Error(
|
||||
"ENOENT: no such file or directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw chdirErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
taskEnv[cwdSymbol] = resolved;
|
||||
return;
|
||||
}
|
||||
return originalChdir.call(process, directory);
|
||||
};
|
||||
|
||||
export function getEnv(key: string): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const envVars: Record<string, string> = { ...process.env } as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
Object.assign(envVars, workspaceEnv);
|
||||
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
|
||||
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
let checkpointing = getEnvLocal('CHECKPOINTING')
|
||||
? getEnvLocal('CHECKPOINTING') === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
@@ -62,7 +282,7 @@ export async function loadConfig(
|
||||
}
|
||||
|
||||
const approvalMode =
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
@@ -91,8 +311,9 @@ export async function loadConfig(
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
debugMode: getEnvLocal('DEBUG') === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
env: envVars,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
excludeTools: settings.tools?.exclude || undefined,
|
||||
@@ -107,7 +328,7 @@ export async function loadConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: settings.telemetry?.logPrompts,
|
||||
},
|
||||
@@ -119,8 +340,8 @@ export async function loadConfig(
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
|
||||
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
@@ -179,7 +400,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, 'Config');
|
||||
await refreshAuthentication(config, 'Config', envVars);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -187,16 +408,19 @@ export async function loadConfig(
|
||||
export function setIsTrusted(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): boolean {
|
||||
if (INITIAL_FOLDER_TRUST !== undefined) {
|
||||
return INITIAL_FOLDER_TRUST === 'true';
|
||||
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
|
||||
if (folderTrustEnv !== undefined) {
|
||||
return folderTrustEnv === 'true';
|
||||
}
|
||||
return !!agentSettings?.isTrusted;
|
||||
}
|
||||
|
||||
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
export async function setTargetDir(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): Promise<string> {
|
||||
const originalCWD = process.cwd();
|
||||
const targetDir =
|
||||
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
|
||||
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
|
||||
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
|
||||
? agentSettings.workspacePath
|
||||
: undefined);
|
||||
@@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
);
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(targetDir);
|
||||
process.chdir(resolvedPath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(targetDir);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const parentDir = path.dirname(path.resolve(targetDir));
|
||||
resolvedPath = path.join(
|
||||
resolveToRealPath(parentDir),
|
||||
path.basename(targetDir),
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const isTestEnv =
|
||||
process.env['VITEST'] === 'true' ||
|
||||
process.env['NODE_ENV'] === 'test' ||
|
||||
process.argv.some((arg) => arg.includes('vitest')) ||
|
||||
resolvedPath.startsWith(resolveToRealPath(tmpdir()));
|
||||
|
||||
const allowedRoot = resolveToRealPath(
|
||||
getEnv('CODER_AGENT_ALLOWED_ROOT') ||
|
||||
(isTestEnv ? path.parse(resolvedPath).root : homedir()),
|
||||
);
|
||||
const relative = path.relative(allowedRoot, resolvedPath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Workspace path ${resolvedPath} is outside the allowed root directory`,
|
||||
);
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
if (isTestEnv) {
|
||||
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} else {
|
||||
throw new Error(`Workspace path ${resolvedPath} does not exist`);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Workspace path ${resolvedPath} is not a directory`);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
|
||||
);
|
||||
return originalCWD;
|
||||
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
export async function loadEnvironment(
|
||||
isTrusted: boolean = false,
|
||||
workspacePath: string = process.cwd(),
|
||||
): Promise<Record<string, string>> {
|
||||
// For untrusted workspaces, we completely bypass workspace-level .env loading
|
||||
// and only load environment variables from the user's trusted home directory.
|
||||
let envFilePath: string | null = null;
|
||||
if (isTrusted) {
|
||||
envFilePath = await findEnvFile(workspacePath);
|
||||
} else {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
envFilePath = homeGeminiEnvPath;
|
||||
} catch {
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
envFilePath = homeEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
const envVars: Record<string, string> = {};
|
||||
if (envFilePath) {
|
||||
dotenv.config({ path: envFilePath, override: true });
|
||||
try {
|
||||
const content = await fs.promises.readFile(envFilePath, 'utf-8');
|
||||
const parsed = dotenv.parse(content);
|
||||
for (const key in parsed) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(parsed, key) &&
|
||||
key !== '__proto__' &&
|
||||
key !== 'constructor' &&
|
||||
key !== 'prototype'
|
||||
) {
|
||||
envVars[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
async function findEnvFile(startDir: string): Promise<string | null> {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
try {
|
||||
await fs.promises.access(geminiEnvPath);
|
||||
return geminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
try {
|
||||
await fs.promises.access(envPath);
|
||||
return envPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
return homeEnvPath;
|
||||
}
|
||||
return null;
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
return homeGeminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
return homeEnvPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
logPrefix: string,
|
||||
envVars: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
if (getEnvLocal('USE_CCPA')) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
|
||||
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||
@@ -276,7 +612,7 @@ async function refreshAuthentication(
|
||||
);
|
||||
|
||||
const useComputeAdc =
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
|
||||
if (isHeadless || useComputeAdc) {
|
||||
@@ -305,11 +641,14 @@ async function refreshAuthentication(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
} else if (getEnvLocal('GEMINI_API_KEY')) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
await config.refreshAuth(
|
||||
AuthType.USE_GEMINI,
|
||||
getEnvLocal('GEMINI_API_KEY'),
|
||||
);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
|
||||
@@ -36,9 +36,12 @@ interface ExtensionConfig {
|
||||
excludeTools?: string[];
|
||||
}
|
||||
|
||||
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
|
||||
export function loadExtensions(
|
||||
workspaceDir: string,
|
||||
isTrusted: boolean = false,
|
||||
): GeminiCLIExtension[] {
|
||||
const allExtensions = [
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
|
||||
...loadExtensionsFromDir(homedir()),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
let mockHomeDir = '';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
homedir: () => mockHomeDir,
|
||||
};
|
||||
});
|
||||
|
||||
import { loadEnvironment } from './config.js';
|
||||
|
||||
describe('Vulnerability Mitigation: b-519269096', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary home directory securely using mkdtempSync to ensure hermeticity
|
||||
mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-'));
|
||||
|
||||
// Create a temporary workspace directory representing an untrusted repo
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
|
||||
);
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
|
||||
// Mock process.cwd to return the untrusted workspace
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(mockHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: In a SECURE system, these variables should NOT be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not load any variables from untrusted workspaces', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempWorkspaceDir, '.env'),
|
||||
'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: No variables should be loaded from the untrusted workspace
|
||||
expect(envVars['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toBeFalsy();
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should load all variables in trusted workspaces with isolation', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Load environment variables
|
||||
const envVars = await loadEnvironment(true);
|
||||
|
||||
// Assert: In a trusted workspace, variables should be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
|
||||
|
||||
// Assert: Global process.env should NOT be polluted
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -197,8 +197,7 @@ async function handleExecuteCommand(
|
||||
export async function createApp() {
|
||||
try {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const workspaceRoot = await setTargetDir(undefined);
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
@@ -209,13 +208,35 @@ export async function createApp() {
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
|
||||
// Change the global working directory to the workspace root during startup
|
||||
process.chdir(workspaceRoot);
|
||||
|
||||
// Load environment globally for the server startup
|
||||
const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
|
||||
// Only assign safe server-config variables to process.env to prevent credential leakage
|
||||
const allowedServerKeys = [
|
||||
'CODER_AGENT_PORT',
|
||||
'CODER_AGENT_WORKSPACE_PATH',
|
||||
'GCS_BUCKET_NAME',
|
||||
'LOG_LEVEL',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GEMINI_CLI_USE_COMPUTE_ADC',
|
||||
];
|
||||
for (const key of allowedServerKeys) {
|
||||
if (globalEnv[key] !== undefined) {
|
||||
process.env[key] = globalEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
}
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
|
||||
const workDir = setTargetDir(agentSettings);
|
||||
const workDir = await setTargetDir(agentSettings);
|
||||
await fse.ensureDir(workDir);
|
||||
const workspaceFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Validates a workspace path to prevent path traversal attacks.
|
||||
*
|
||||
* @param workspacePath The path to validate.
|
||||
* @param allowedRoot The root directory the path must be within. Defaults to CWD.
|
||||
* @returns The resolved, safe path.
|
||||
* @throws An error if the path is invalid or outside the allowed root.
|
||||
*/
|
||||
export async function validateWorkspacePath(
|
||||
workspacePath?: string,
|
||||
allowedRoot: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const trimmedPath = workspacePath?.trim();
|
||||
if (!trimmedPath) {
|
||||
return resolveToRealPath(allowedRoot);
|
||||
}
|
||||
|
||||
if (trimmedPath.includes('\0')) {
|
||||
throw new Error('Security violation: Null byte detected in path.');
|
||||
}
|
||||
|
||||
try {
|
||||
const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
|
||||
const resolvedWorkspacePath = path.resolve(
|
||||
canonicalAllowedRoot,
|
||||
trimmedPath,
|
||||
);
|
||||
const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
|
||||
|
||||
// Check if the resolved path is within the allowed root directory
|
||||
if (
|
||||
canonicalWorkspacePath !== canonicalAllowedRoot &&
|
||||
!isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(canonicalWorkspacePath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`The path "${trimmedPath}" is not a directory.`);
|
||||
}
|
||||
|
||||
return canonicalWorkspacePath;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
throw new Error(`The path "${trimmedPath}" does not exist.`);
|
||||
}
|
||||
throw e; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.1",
|
||||
"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.54.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
|
||||
@@ -319,6 +319,41 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
|
||||
{ type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
|
||||
{ type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
|
||||
{ type: 'OTHER_BLOCKED', reason: 'OTHER' },
|
||||
{ type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
|
||||
])(
|
||||
'should gracefully handle InvalidStreamError with type $type in ACP session',
|
||||
async ({ type, reason }) => {
|
||||
const error = new InvalidStreamError(
|
||||
`Stream failed with ${reason}`,
|
||||
type as InvalidStreamError['type'],
|
||||
);
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
async function* errorGen(): AsyncGenerator<
|
||||
ServerGeminiStreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* [] as any;
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
});
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
|
||||
@@ -510,7 +510,12 @@ export class Session {
|
||||
(error.type === 'NO_RESPONSE_TEXT' ||
|
||||
error.type === 'NO_FINISH_REASON' ||
|
||||
error.type === 'MALFORMED_FUNCTION_CALL' ||
|
||||
error.type === 'UNEXPECTED_TOOL_CALL'))
|
||||
error.type === 'UNEXPECTED_TOOL_CALL' ||
|
||||
error.type === 'MAX_TOKENS_EXCEEDED' ||
|
||||
error.type === 'SAFETY_BLOCKED' ||
|
||||
error.type === 'RECITATION_BLOCKED' ||
|
||||
error.type === 'OTHER_BLOCKED' ||
|
||||
error.type === 'THINKING_ONLY_RESPONSE'))
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
@@ -110,6 +112,7 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -165,6 +168,7 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -193,6 +197,7 @@ describe('runNonInteractive', () => {
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -1820,7 +1825,6 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
@@ -2298,7 +2302,13 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2312,7 +2322,7 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
|
||||
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -2325,7 +2335,13 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2341,9 +2357,7 @@ describe('runNonInteractive', () => {
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2355,7 +2369,13 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2371,8 +2391,33 @@ describe('runNonInteractive', () => {
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"error": {');
|
||||
expect(output).toContain('"type": "INVALID_STREAM"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Custom malformed function call message',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream malformed',
|
||||
prompt_id: 'prompt-id-invalid-malformed',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[ERROR] Custom malformed function call message\n',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
@@ -433,8 +439,31 @@ export async function runNonInteractive(
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
} else if (event.type === GeminiEventType.InvalidStream) {
|
||||
invalidStreamError =
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
const eventValue = event.value;
|
||||
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
|
||||
invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
|
||||
invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
|
||||
invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
|
||||
invalidStreamError = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
|
||||
invalidStreamError = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'OTHER_BLOCKED') {
|
||||
invalidStreamError = OTHER_BLOCKED_MESSAGE;
|
||||
} else {
|
||||
invalidStreamError =
|
||||
eventValue?.message?.trim() ||
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
eventValue?.type || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
|
||||
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession: original.LegacyAgentSession,
|
||||
geminiPartsToContentParts: original.geminiPartsToContentParts,
|
||||
@@ -199,6 +201,7 @@ describe('runNonInteractive', () => {
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -2457,6 +2460,126 @@ describe('runNonInteractive', () => {
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"error": {');
|
||||
expect(output).toContain('"type": "INVALID_STREAM"');
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Malformed call',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
geminiPartsToContentParts,
|
||||
displayContentToString,
|
||||
debugLogger,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
@@ -332,14 +338,17 @@ export async function runNonInteractive({
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
const emitFinalResult = (errorPayload?: {
|
||||
type: string;
|
||||
message: string;
|
||||
}): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
status: errorPayload ? 'error' : 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
@@ -350,7 +359,7 @@ export async function runNonInteractive({
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
errorPayload,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
@@ -545,6 +554,52 @@ export async function runNonInteractive({
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event._meta?.['code'] === 'INVALID_STREAM') {
|
||||
const errorTypeVal = event._meta?.['errorType'];
|
||||
const errorType =
|
||||
typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
|
||||
|
||||
let errorMessage = event.message;
|
||||
if (errorType === 'NO_RESPONSE_TEXT') {
|
||||
errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (errorType === 'THINKING_ONLY_RESPONSE') {
|
||||
errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (errorType === 'MAX_TOKENS_EXCEEDED') {
|
||||
errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (errorType === 'SAFETY_BLOCKED') {
|
||||
errorMessage = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (errorType === 'RECITATION_BLOCKED') {
|
||||
errorMessage = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (errorType === 'OTHER_BLOCKED') {
|
||||
errorMessage = OTHER_BLOCKED_MESSAGE;
|
||||
}
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: errorMessage,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[ERROR] ${errorMessage}\n`);
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
errorType || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
// If it's a fatal stream error, we should terminate and output final results
|
||||
emitFinalResult({
|
||||
type: 'INVALID_STREAM',
|
||||
message: errorMessage,
|
||||
});
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
@@ -613,7 +668,7 @@ export async function runNonInteractive({
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
emitFinalResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const errorsA = a.api.errorsByType || {};
|
||||
const errorsB = b.api.errorsByType || {};
|
||||
const keysA = Object.keys(errorsA);
|
||||
const keysB = Object.keys(errorsB);
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of keysA) {
|
||||
if (errorsA[key] !== errorsB[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
a.tokens.input !== b.tokens.input ||
|
||||
a.tokens.prompt !== b.tokens.prompt ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -1045,6 +1046,107 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should record tool responses in history when the model was switched due to a quota error', async () => {
|
||||
// Regression test: returning early on a quota-triggered model switch
|
||||
// without recording the responses leaves the already-recorded
|
||||
// functionCall unpaired, which corrupts all subsequent requests.
|
||||
const responseParts: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'testTool',
|
||||
id: 'call1',
|
||||
response: { output: 'tool result' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const completedToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-quota',
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
responseSubmittedToGemini: false,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts,
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: { displayName: 'MockTool' },
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
} as TrackedCompletedToolCall,
|
||||
];
|
||||
|
||||
const client = new MockedGeminiClientClass(mockConfig);
|
||||
const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database');
|
||||
|
||||
let capturedOnComplete:
|
||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
mockUseToolScheduler.mockImplementation((onComplete) => {
|
||||
capturedOnComplete = onComplete;
|
||||
return [
|
||||
[],
|
||||
mockScheduleToolCalls,
|
||||
mockMarkToolsAsSubmitted,
|
||||
vi.fn(),
|
||||
mockCancelAllToolCalls,
|
||||
0,
|
||||
];
|
||||
});
|
||||
|
||||
await renderHookWithProviders(() =>
|
||||
useGeminiStream(
|
||||
client,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
true, // modelSwitchedFromQuotaError
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
false,
|
||||
mockConsumeUserHint,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await capturedOnComplete(completedToolCalls);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
|
||||
// The tool response must be paired with its functionCall in history,
|
||||
// with no steering-hint text ahead of it...
|
||||
expect(client.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: responseParts,
|
||||
});
|
||||
// ...the turn must NOT auto-continue on the fallback model...
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
// ...and the pending hint is left for the next real submit.
|
||||
expect(mockConsumeUserHint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT stop responding when only update_topic is called', async () => {
|
||||
const topicToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
@@ -1772,6 +1874,120 @@ describe('useGeminiStream', () => {
|
||||
expect(mockCancelAllToolCalls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should transition to Idle state when cancelled while a tool call is in progress and completes', async () => {
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: { callId: 'call1', name: 'tool1', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
build: vi.fn().mockImplementation((_) => ({
|
||||
getDescription: () => `Mock description`,
|
||||
})),
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
liveOutput: '...',
|
||||
} as TrackedExecutingToolCall,
|
||||
];
|
||||
|
||||
const { result } = await renderTestHook(toolCalls);
|
||||
|
||||
// State is `Responding` because a tool is running
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// Try to cancel
|
||||
simulateEscapeKeyPress();
|
||||
|
||||
// Trigger the onComplete callback with the cancelled tool call
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([
|
||||
{
|
||||
...toolCalls[0],
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: [],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// The final state should be idle because the cancelled tool call was marked as submitted
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should append cancelled tool responses to history when cancelled while a tool call is in progress and completes with response parts', async () => {
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: { callId: 'call1', name: 'tool1', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
build: vi.fn().mockImplementation((_) => ({
|
||||
getDescription: () => `Mock description`,
|
||||
})),
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
liveOutput: '...',
|
||||
} as TrackedExecutingToolCall,
|
||||
];
|
||||
|
||||
const { result, client } = await renderTestHook(toolCalls);
|
||||
|
||||
// State is `Responding` because a tool is running
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// Try to cancel
|
||||
simulateEscapeKeyPress();
|
||||
|
||||
const expectedResponseParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'tool1',
|
||||
id: 'call1',
|
||||
response: { error: 'cancelled' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Trigger the onComplete callback with the cancelled tool call having non-empty response parts
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([
|
||||
{
|
||||
...toolCalls[0],
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: expectedResponseParts,
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that addHistory was called with the combined response parts
|
||||
expect(client.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: expectedResponseParts,
|
||||
});
|
||||
|
||||
// The final state should be idle because the cancelled tool call was marked as submitted
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should cancel a request when a tool is awaiting confirmation', async () => {
|
||||
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
@@ -2306,6 +2522,68 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use TRUE_EMPTY_RESPONSE_MESSAGE when receiving an invalid stream event of type NO_RESPONSE_TEXT', async () => {
|
||||
mockSendMessageStream.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'empty response text',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the event message when receiving a non-NO_RESPONSE_TEXT invalid stream event', async () => {
|
||||
mockSendMessageStream.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Custom malformed function call message',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Custom malformed function call message',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApprovalModeChange', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
uiTelemetryService,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
@@ -45,6 +46,12 @@ import {
|
||||
buildToolVisibilityContext,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -54,6 +61,7 @@ import type {
|
||||
ServerGeminiContentEvent as ContentEvent,
|
||||
ServerGeminiFinishedEvent,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
ServerGeminiInvalidStreamEvent,
|
||||
ThoughtSummary,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
@@ -1229,6 +1237,61 @@ export const useGeminiStream = (
|
||||
],
|
||||
);
|
||||
|
||||
const handleInvalidStreamEvent = useCallback(
|
||||
(
|
||||
eventValue: ServerGeminiInvalidStreamEvent['value'],
|
||||
userMessageTimestamp: number,
|
||||
) => {
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
||||
|
||||
let text =
|
||||
eventValue?.message?.trim() || 'Invalid stream received from model';
|
||||
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
|
||||
text = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
|
||||
text = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
|
||||
text = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
|
||||
text = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
|
||||
text = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'OTHER_BLOCKED') {
|
||||
text = OTHER_BLOCKED_MESSAGE;
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
eventValue?.type || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
setThought(null); // Reset thought when there's an error
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
setThought,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
config,
|
||||
geminiClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCitationEvent = useCallback(
|
||||
(text: string, userMessageTimestamp: number) => {
|
||||
if (!showCitations(settings)) {
|
||||
@@ -1541,8 +1604,10 @@ export const useGeminiStream = (
|
||||
loopDetectedRef.current = true;
|
||||
break;
|
||||
case ServerGeminiEventType.Retry:
|
||||
// Handled transparently by the backend stream retries.
|
||||
break;
|
||||
case ServerGeminiEventType.InvalidStream:
|
||||
// Will add the missing logic later
|
||||
handleInvalidStreamEvent(event.value, userMessageTimestamp);
|
||||
break;
|
||||
default: {
|
||||
// enforces exhaustive switch-case
|
||||
@@ -1575,6 +1640,7 @@ export const useGeminiStream = (
|
||||
handleChatModelEvent,
|
||||
handleAgentExecutionStoppedEvent,
|
||||
handleAgentExecutionBlockedEvent,
|
||||
handleInvalidStreamEvent,
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
@@ -1886,6 +1952,30 @@ export const useGeminiStream = (
|
||||
},
|
||||
);
|
||||
|
||||
if (turnCancelledRef.current) {
|
||||
setIsResponding(false);
|
||||
const geminiTools = completedAndReadyToSubmitTools.filter(
|
||||
(t) => !t.request.isClientInitiated,
|
||||
);
|
||||
if (geminiClient && geminiTools.length > 0) {
|
||||
const combinedParts = geminiTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
if (combinedParts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: combinedParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
const callIdsToMarkAsSubmitted = toolCalls.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize any client-initiated tools as soon as they are done.
|
||||
const clientTools = completedAndReadyToSubmitTools.filter(
|
||||
(t) => t.request.isClientInitiated,
|
||||
@@ -2020,6 +2110,27 @@ export const useGeminiStream = (
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
|
||||
// Don't continue if model was switched due to quota error, but still
|
||||
// record the responses: the matching functionCall is already in history,
|
||||
// and leaving it unpaired corrupts every subsequent request. Any pending
|
||||
// steering hint is deliberately left unconsumed so it rides along with
|
||||
// the next query the user actually submits.
|
||||
if (modelSwitchedFromQuotaError) {
|
||||
if (geminiClient && responsesToSend.length > 0) {
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: responsesToSend,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumeUserHint) {
|
||||
const userHint = consumeUserHint();
|
||||
if (userHint && userHint.trim().length > 0) {
|
||||
@@ -2030,21 +2141,10 @@ export const useGeminiStream = (
|
||||
}
|
||||
}
|
||||
|
||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
|
||||
const prompt_ids = geminiTools.map(
|
||||
(toolCall) => toolCall.request.prompt_id,
|
||||
);
|
||||
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
|
||||
// Don't continue if model was switched due to quota error
|
||||
if (modelSwitchedFromQuotaError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
submitQuery(
|
||||
responsesToSend,
|
||||
@@ -2066,6 +2166,7 @@ export const useGeminiStream = (
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
setIsResponding,
|
||||
toolCalls,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('usePrivacySettings', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('should throw error when content generator is not a CodeAssistServer', async () => {
|
||||
it('should report tier unavailable when OAuth is not being used', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
@@ -58,7 +58,8 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('Oauth not being used');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle paid tier users correctly', async () => {
|
||||
@@ -79,7 +80,7 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when CodeAssistServer has no projectId', async () => {
|
||||
it('should report tier unavailable when CodeAssistServer has no projectId', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
userTier: UserTierId.FREE,
|
||||
} as unknown as CodeAssistServer);
|
||||
@@ -90,9 +91,63 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe(
|
||||
'CodeAssist server is missing a project ID',
|
||||
);
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the user has no tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: undefined,
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.isFreeTier).toBeUndefined();
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the backend reports no current tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('User does not have a current tier')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should surface unexpected errors while loading opt-in settings', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('network unavailable')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('network unavailable');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update data collection opt-in setting', async () => {
|
||||
|
||||
@@ -18,8 +18,22 @@ export interface PrivacyState {
|
||||
error?: string;
|
||||
isFreeTier?: boolean;
|
||||
dataCollectionOptIn?: boolean;
|
||||
/**
|
||||
* True when the signed-in account has no consumer Code Assist tier, so the
|
||||
* data-collection opt-in isn't applicable (e.g. Workspace/enterprise accounts,
|
||||
* or an OAuth login without a Google Cloud project). This is an expected state
|
||||
* rendered as a friendly, actionable notice rather than a raw backend `error`.
|
||||
*/
|
||||
isTierUnavailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the current account can't be mapped to a consumer Code Assist
|
||||
* tier, so the privacy opt-in can't be shown. Handled by rendering a friendly
|
||||
* notice instead of surfacing a raw backend error.
|
||||
*/
|
||||
class TierUnavailableError extends Error {}
|
||||
|
||||
export const usePrivacySettings = (config: Config) => {
|
||||
const [privacyState, setPrivacyState] = useState<PrivacyState>({
|
||||
isLoading: true,
|
||||
@@ -34,7 +48,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
const server = getCodeAssistServerOrFail(config);
|
||||
const tier = server.userTier;
|
||||
if (tier === undefined) {
|
||||
throw new Error('Could not determine user tier.');
|
||||
// The account has no resolved Code Assist tier (e.g. Workspace or an
|
||||
// incomplete OAuth). Show a friendly notice instead of a raw error.
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tier !== UserTierId.FREE) {
|
||||
// We don't need to fetch opt-out info since non-free tier
|
||||
@@ -53,6 +73,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: optIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -74,6 +101,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: updatedOptIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -92,13 +126,30 @@ export const usePrivacySettings = (config: Config) => {
|
||||
function getCodeAssistServerOrFail(config: Config): CodeAssistServer {
|
||||
const server = getCodeAssistServer(config);
|
||||
if (server === undefined) {
|
||||
throw new Error('Oauth not being used');
|
||||
throw new TierUnavailableError('Oauth not being used');
|
||||
} else if (server.projectId === undefined) {
|
||||
throw new Error('CodeAssist server is missing a project ID');
|
||||
throw new TierUnavailableError('CodeAssist server is missing a project ID');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an error means the account simply has no consumer Code
|
||||
* Assist tier, as opposed to an unexpected failure. Covers the local
|
||||
* {@link TierUnavailableError} as well as the Code Assist backend error (e.g.
|
||||
* "User does not have a current tier") returned for Workspace/enterprise
|
||||
* accounts.
|
||||
*/
|
||||
function isTierUnavailableError(error: unknown): boolean {
|
||||
if (error instanceof TierUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Match the specific Code Assist backend message rather than a broad substring
|
||||
// so an unrelated error that merely mentions "tier" isn't masked as a benign notice.
|
||||
return /does not have a current tier/i.test(message);
|
||||
}
|
||||
|
||||
async function getRemoteDataCollectionOptIn(
|
||||
server: CodeAssistServer,
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -71,6 +71,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
expectedText: 'Gemini Code Assist Privacy Notice',
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
expectedText: 'GOOGLE_CLOUD_PROJECT',
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state',
|
||||
mockState: { isFreeTier: true },
|
||||
@@ -101,6 +106,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state (no selection)',
|
||||
mockState: { isFreeTier: true },
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CloudFreePrivacyNotice = ({
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
(privacyState.error || privacyState.isFreeTier === false) &&
|
||||
(privacyState.error ||
|
||||
privacyState.isFreeTier === false ||
|
||||
privacyState.isTierUnavailable) &&
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
@@ -53,6 +55,35 @@ export const CloudFreePrivacyNotice = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isTierUnavailable) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Gemini Code Assist Privacy Notice
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
The data collection opt-in isn't available for this account
|
||||
because it doesn't have a Gemini Code Assist for Individuals
|
||||
(free) tier.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
If you're on a Google Workspace or enterprise account, use the
|
||||
Vertex AI / Google Cloud path instead by setting the
|
||||
GOOGLE_CLOUD_PROJECT environment variable to your Google Cloud
|
||||
project.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
Learn more: https://geminicli.com/docs/get-started/authentication/
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.secondary}>Press Esc to exit.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isFreeTier === false) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-open: uses (deny default) and explicitly allows the operations the
|
||||
;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list
|
||||
;; minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest)
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +76,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -25,3 +88,48 @@
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-open reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic (local dev/test servers, the debugger on :9229,
|
||||
;; OAuth localhost callbacks)
|
||||
(allow network-inbound (local ip "*:*"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; allow all outbound network traffic
|
||||
(allow network-outbound)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-proxied: uses (deny default) and explicitly allows the operations
|
||||
;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the
|
||||
;; allow-list minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest).
|
||||
;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed
|
||||
;; for PTY support under deny default.
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +78,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -22,16 +87,52 @@
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; deny all inbound network traffic EXCEPT on debugger port
|
||||
(deny network-inbound)
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-proxied reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic on debugger port
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; deny all outbound network traffic EXCEPT through proxy on localhost:8877
|
||||
;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
|
||||
;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
|
||||
(deny network-outbound)
|
||||
(allow network-outbound (remote tcp "localhost:8877"))
|
||||
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Strip SBPL comments (`; ...` to end of line) so assertions run against the
|
||||
* actual sandbox rules rather than any keywords that happen to appear in the
|
||||
* explanatory comments.
|
||||
*/
|
||||
function readRules(profile: string): string {
|
||||
return readFileSync(path.join(utilsDir, profile), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const commentStart = line.indexOf(';');
|
||||
return commentStart === -1 ? line : line.slice(0, commentStart);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const PERMISSIVE_PROFILES = [
|
||||
'sandbox-macos-permissive-open.sb',
|
||||
'sandbox-macos-permissive-proxied.sb',
|
||||
];
|
||||
|
||||
// These two profiles are the default macOS Seatbelt profiles, so the invariants
|
||||
// below must never silently regress. Keep them deny-default and confirm the
|
||||
// reviewed allow-list stays in place.
|
||||
describe('macOS permissive Seatbelt profiles', () => {
|
||||
describe.each(PERMISSIVE_PROFILES)('%s', (profile) => {
|
||||
const rules = readRules(profile);
|
||||
|
||||
it('uses a deny-default foundation', () => {
|
||||
expect(rules).toContain('(deny default)');
|
||||
});
|
||||
|
||||
it('does not use an allow-default foundation', () => {
|
||||
expect(rules).not.toContain('(allow default)');
|
||||
});
|
||||
|
||||
it('does not permit filesystem (un)mounts', () => {
|
||||
expect(rules).not.toMatch(/file-mount/);
|
||||
expect(rules).not.toMatch(/file-unmount/);
|
||||
});
|
||||
|
||||
it('does not grant broad service lookups', () => {
|
||||
expect(rules).not.toMatch(/launchd/);
|
||||
expect(rules).not.toMatch(/launchservices/i);
|
||||
});
|
||||
|
||||
it('allows binding local ports for dev/test servers', () => {
|
||||
expect(rules).toContain('(allow network-bind (local ip "*:*"))');
|
||||
});
|
||||
});
|
||||
|
||||
it('permissive-open keeps broad inbound and outbound network', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-open.sb');
|
||||
expect(rules).toContain('(allow network-inbound (local ip "*:*"))');
|
||||
expect(rules).toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
|
||||
it('permissive-proxied confines outbound to the proxy', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-proxied.sb');
|
||||
expect(rules).toContain(
|
||||
'(allow network-outbound (remote tcp "localhost:8877"))',
|
||||
);
|
||||
// Proxied mode must never grant unrestricted outbound network.
|
||||
expect(rules).not.toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.1",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"fdir": "6.4.6",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"google-auth-library": "9.11.0",
|
||||
"google-auth-library": "10.9.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
|
||||
@@ -516,10 +516,34 @@ describe('translateEvent', () => {
|
||||
});
|
||||
|
||||
describe('InvalidStream events', () => {
|
||||
it('emits fatal error', () => {
|
||||
it('emits fatal error with specific message from event', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Empty response');
|
||||
expect(err.fatal).toBe(true);
|
||||
expect(err._meta?.['code']).toBe('INVALID_STREAM');
|
||||
expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT');
|
||||
expect(err._meta?.['rawMessage']).toBe('Empty response');
|
||||
});
|
||||
|
||||
it('falls back to default message when message is missing', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: '',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
@@ -222,8 +222,15 @@ export function translateEvent(
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message: 'Invalid stream received from model',
|
||||
message:
|
||||
event.value?.message?.trim() ||
|
||||
'Invalid stream received from model',
|
||||
fatal: true,
|
||||
_meta: {
|
||||
code: 'INVALID_STREAM',
|
||||
errorType: event.value?.type,
|
||||
rawMessage: event.value?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, FinishReason } from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
@@ -192,6 +192,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
let finishedReason: FinishReason | undefined = undefined;
|
||||
const responseStream = this._client.sendMessageStream(
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
@@ -220,10 +221,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
case GeminiEventType.Finished:
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream(mapFinishReason(event.value.reason));
|
||||
return;
|
||||
}
|
||||
finishedReason = event.value.reason;
|
||||
break;
|
||||
case GeminiEventType.AgentExecutionStopped:
|
||||
case GeminiEventType.UserCancelled:
|
||||
@@ -241,7 +239,11 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream('completed');
|
||||
if (finishedReason !== undefined) {
|
||||
this._finishStream(mapFinishReason(finishedReason));
|
||||
} else {
|
||||
this._finishStream('completed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
|
||||
import type { GoogleCredentialsAuthConfig } from './types.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
|
||||
vi.mock('google-auth-library', () => ({
|
||||
GoogleAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Credential Leak Prevention (RCA / PoC Verification)', () => {
|
||||
const mockConfig: GoogleCredentialsAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
|
||||
getClient: vi.fn().mockResolvedValue({
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: 'leaked-token' }),
|
||||
credentials: { expiry_date: Date.now() + 3600 * 1000 },
|
||||
}),
|
||||
getIdTokenClient: vi.fn().mockResolvedValue({
|
||||
idTokenProvider: {
|
||||
fetchIdToken: vi.fn().mockResolvedValue('leaked-id-token'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should FAIL (throw error) when trying to initialize with an untrusted arbitrary remote agent URL (reproducing vulnerability prevention)', () => {
|
||||
// This test simulates the reproduction scenario: registering a remote agent with an arbitrary external URL
|
||||
// e.g., http://127.0.0.1:1337 or https://malicious-agent.evil.com
|
||||
const untrustedUrls = [
|
||||
{
|
||||
url: 'http://127.0.0.1:1337/.well-known/agent.json',
|
||||
error: /requires HTTPS/,
|
||||
},
|
||||
{
|
||||
url: 'https://malicious-agent.evil.com/card',
|
||||
error: /is not an allowed host/,
|
||||
},
|
||||
{
|
||||
url: 'https://untrusted-third-party.com/agent',
|
||||
error: /is not an allowed host/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of untrustedUrls) {
|
||||
expect(() => {
|
||||
new GoogleCredentialsAuthProvider(mockConfig, item.url);
|
||||
}).toThrow(item.error);
|
||||
}
|
||||
});
|
||||
|
||||
it('should SUCCEED only for allowed Google Services (proving the allowlist constraint)', () => {
|
||||
const trustedUrls = [
|
||||
'https://language.googleapis.com/v1/models',
|
||||
'https://vertex-ai-agent.googleapis.com/agent',
|
||||
'https://my-secure-service-abc.run.app/card',
|
||||
];
|
||||
|
||||
for (const url of trustedUrls) {
|
||||
expect(() => {
|
||||
new GoogleCredentialsAuthProvider(mockConfig, url);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,24 @@ describe('GoogleCredentialsAuthProvider', () => {
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws if the protocol is not HTTPS', () => {
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'http://language.googleapis.com/v1/models',
|
||||
),
|
||||
).toThrow(/requires HTTPS/);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'http://my-cloud-run-service.run.app',
|
||||
),
|
||||
).toThrow(/requires HTTPS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Fetching', () => {
|
||||
|
||||
@@ -40,7 +40,14 @@ export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = new URL(targetUrl).hostname;
|
||||
const urlObj = new URL(targetUrl);
|
||||
if (urlObj.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`Protocol "${urlObj.protocol}" is not secure. Google Credential provider requires HTTPS.`,
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = urlObj.hostname;
|
||||
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
|
||||
|
||||
if (isRunAppHost) {
|
||||
|
||||
@@ -414,4 +414,86 @@ describe('Auto Routing Fallback Integration', () => {
|
||||
'Pro success',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rotate session ID on fallback and retry successfully with the Flash model', async () => {
|
||||
const originalSessionId = 'test-session-rotate-id';
|
||||
config = new Config({
|
||||
sessionId: originalSessionId,
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
|
||||
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
|
||||
|
||||
client = new BaseLlmClient(
|
||||
fakeGenerator,
|
||||
config,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
let attemptsPro = 0;
|
||||
let attemptsFlash = 0;
|
||||
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message:
|
||||
'Automatically switching from gemini-2.5-pro to gemini-2.5-flash for faster responses for the remainder of this session. Possible reasons for this are...',
|
||||
details: [],
|
||||
};
|
||||
|
||||
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
|
||||
async (params) => {
|
||||
if (params.model === PREVIEW_GEMINI_MODEL) {
|
||||
attemptsPro++;
|
||||
throw new RetryableQuotaError(
|
||||
'Quota exceeded for Pro',
|
||||
mockGoogleApiError,
|
||||
0,
|
||||
);
|
||||
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
|
||||
attemptsFlash++;
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Flash success after rotation' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
}
|
||||
throw new Error(`Unexpected model: ${params.model}`);
|
||||
},
|
||||
);
|
||||
|
||||
config.setFallbackModelHandler(
|
||||
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
|
||||
'retry_always', // Approve switch to Flash
|
||||
);
|
||||
|
||||
const promise = client.generateContent({
|
||||
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
|
||||
contents: [{ role: 'user', parts: [{ text: 'test query' }] }],
|
||||
abortSignal: new AbortController().signal,
|
||||
promptId: 'test-prompt',
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
// Verify it resolved to Flash success instead of failing with Please submit a new query
|
||||
expect(result.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
|
||||
'Flash success after rotation',
|
||||
);
|
||||
expect(attemptsPro).toBe(3);
|
||||
expect(attemptsFlash).toBe(1);
|
||||
|
||||
// Verify session ID has been rotated
|
||||
expect(config.getSessionId()).not.toBe(originalSessionId);
|
||||
expect(config.getSessionId()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -680,6 +680,64 @@ describe('oauth2', () => {
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials);
|
||||
expect(client).toBe(mockExternalAccountClient);
|
||||
});
|
||||
|
||||
it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => {
|
||||
// Setup default cached credentials that are expired/invalid
|
||||
const defaultCreds = { refresh_token: 'expired-token' };
|
||||
const defaultCredsPath = path.join(
|
||||
tempHomeDir,
|
||||
GEMINI_DIR,
|
||||
'oauth_creds.json',
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(defaultCredsPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
defaultCredsPath,
|
||||
JSON.stringify(defaultCreds),
|
||||
);
|
||||
|
||||
// Setup valid fallback credentials via environment variable
|
||||
const envCreds = { refresh_token: 'valid-env-token' };
|
||||
const envCredsPath = path.join(tempHomeDir, 'env_creds.json');
|
||||
await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds));
|
||||
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath);
|
||||
|
||||
let currentCredentials: Credentials | null = null;
|
||||
const mockClient = {
|
||||
setCredentials: vi.fn((creds) => {
|
||||
currentCredentials = creds as Credentials;
|
||||
}),
|
||||
getAccessToken: vi.fn(async () => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return { token: 'valid-token' };
|
||||
}),
|
||||
getTokenInfo: vi.fn(async (_token) => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(OAuth2Client).mockImplementation(
|
||||
() => mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
|
||||
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
|
||||
|
||||
// Assert that fallback envCreds were eventually loaded and used
|
||||
expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with GCP environment variables', () => {
|
||||
|
||||
@@ -113,46 +113,52 @@ function getUseEncryptedStorageFlag() {
|
||||
return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given credentials object represents ADC credentials.
|
||||
*/
|
||||
function isAdcCredentials(
|
||||
credentials: unknown,
|
||||
): credentials is JWTInput & { type: string } {
|
||||
if (credentials && typeof credentials === 'object' && 'type' in credentials) {
|
||||
const type = credentials.type;
|
||||
return typeof type === 'string' && type !== 'authorized_user';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function initOauthClient(
|
||||
authType: AuthType,
|
||||
config: Config,
|
||||
): Promise<AuthClient> {
|
||||
const credentials = await fetchCachedCredentials();
|
||||
function createBaseOAuth2Client(): OAuth2Client {
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
if (
|
||||
credentials &&
|
||||
typeof credentials === 'object' &&
|
||||
'type' in credentials &&
|
||||
(credentials.type === 'external_account_authorized_user' ||
|
||||
credentials.type === 'service_account')
|
||||
) {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
const byoidClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const token = await byoidClient.getAccessToken();
|
||||
if (token) {
|
||||
debugLogger.debug(`Created ${credentials.type} auth client.`);
|
||||
return byoidClient;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
|
||||
if (
|
||||
process.env['GOOGLE_GENAI_USE_GCA'] &&
|
||||
process.env['GOOGLE_CLOUD_ACCESS_TOKEN']
|
||||
) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials({
|
||||
access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'],
|
||||
});
|
||||
@@ -160,49 +166,70 @@ async function initOauthClient(
|
||||
return client;
|
||||
}
|
||||
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
const credentialsList = await fetchCachedCredentialsList();
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
|
||||
if (credentials) {
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
// 2. Iterate sequentially over the credentials list in their natural priority order
|
||||
for (const credentials of credentialsList) {
|
||||
if (isAdcCredentials(credentials)) {
|
||||
try {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
});
|
||||
const adcClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const response = await adcClient.getAccessToken();
|
||||
const token = response.token ?? null;
|
||||
if (token) {
|
||||
debugLogger.debug('Created ' + credentials.type + ' auth client.');
|
||||
return adcClient;
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(credentials as Credentials);
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'ADC credentials verification failed:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} else if (credentials) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(
|
||||
client.credentials || (credentials as Credentials),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Cached credentials are not valid:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Cached credentials are not valid:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createBaseOAuth2Client();
|
||||
|
||||
// In Google Compute Engine based environments (including Cloud Shell), we can
|
||||
// use Application Default Credentials (ADC) provided via its metadata server
|
||||
// to authenticate non-interactively using the identity of the logged-in user.
|
||||
@@ -663,16 +690,27 @@ export function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCachedCredentials(): Promise<
|
||||
Credentials | JWTInput | null
|
||||
async function fetchCachedCredentialsList(): Promise<
|
||||
Array<Credentials | JWTInput>
|
||||
> {
|
||||
const credentialsList: Array<Credentials | JWTInput> = [];
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
if (useEncryptedStorage) {
|
||||
return OAuthCredentialStorage.loadCredentials();
|
||||
try {
|
||||
const creds = await OAuthCredentialStorage.loadCredentials();
|
||||
if (creds) {
|
||||
credentialsList.push(creds);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Failed to load credentials from encrypted storage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pathsToTry = [
|
||||
Storage.getOAuthCredsPath(),
|
||||
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
|
||||
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
@@ -683,9 +721,10 @@ async function fetchCachedCredentials(): Promise<
|
||||
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
|
||||
typeof val === 'object' && val !== null;
|
||||
if (isOAuthCreds(parsed)) {
|
||||
return parsed;
|
||||
credentialsList.push(parsed);
|
||||
} else {
|
||||
throw new Error('Invalid credentials format');
|
||||
}
|
||||
throw new Error('Invalid credentials format');
|
||||
} catch (error) {
|
||||
// Log specific error for debugging, but continue trying other paths
|
||||
debugLogger.debug(
|
||||
@@ -695,7 +734,7 @@ async function fetchCachedCredentials(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return credentialsList;
|
||||
}
|
||||
|
||||
export function clearOauthClientCache() {
|
||||
|
||||
@@ -86,6 +86,10 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
getEffectiveSessionId(): string | undefined {
|
||||
return this.config?.getSessionId() ?? this.sessionId;
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
req: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
@@ -117,7 +121,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -153,7 +157,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
server.sessionId, // Use sessionId as trajectoryId
|
||||
server.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
@@ -204,7 +208,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -224,7 +228,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
this.sessionId, // Use sessionId as trajectoryId
|
||||
this.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
|
||||
@@ -678,6 +678,7 @@ export interface ConfigParameters {
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
env?: Record<string, string>;
|
||||
workspacePoliciesDir?: string;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
directWebFetch?: boolean;
|
||||
@@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly useTerminalBuffer: boolean;
|
||||
private readonly useRenderProcess: boolean;
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
readonly env?: Record<string, string>;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
@@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.checkpointing = params.checkpointing ?? false;
|
||||
this.proxy = params.proxy;
|
||||
this.cwd = params.cwd ?? process.cwd();
|
||||
this.env = params.env;
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
@@ -1858,6 +1861,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
|
||||
rotateSessionId(sessionId: string): void {
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
resetNewSessionState(sessionId: string): void {
|
||||
this.setSessionId(sessionId);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,29 @@ describe('AgentHistoryProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use unambiguous label in fallback summary to avoid LLM confusion', async () => {
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
generateContentMock.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15);
|
||||
// The fallback summary should use clear and unambiguous phrasing
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'Previous User Intent (Truncated):',
|
||||
);
|
||||
expect(result[0].parts![0].text).not.toContain('Last User Intent:');
|
||||
});
|
||||
|
||||
it('should pass the contextual bridge to the summarizer', async () => {
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
|
||||
@@ -267,7 +267,9 @@ export class AgentHistoryProvider {
|
||||
];
|
||||
|
||||
if (lastUserText) {
|
||||
summaryParts.push(`- **Last User Intent:** "${lastUserText}"`);
|
||||
summaryParts.push(
|
||||
`- **Previous User Intent (Truncated):** "${lastUserText}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (actionPath) {
|
||||
|
||||
@@ -165,6 +165,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -361,6 +371,16 @@ An approved plan is available for this task at \`../plans/feature-x.md\`.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -671,6 +691,16 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -845,6 +875,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1005,6 +1045,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1148,6 +1198,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1837,6 +1897,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2011,6 +2081,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2189,6 +2269,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2367,6 +2457,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2541,6 +2641,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2709,6 +2819,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2851,6 +2971,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3025,6 +3155,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3345,6 +3485,16 @@ You are operating with a persistent file-based task tracking system located at \
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3774,6 +3924,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3948,6 +4108,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -4241,6 +4411,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -4415,6 +4595,16 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
|
||||
|
||||
describe('AgentChatHistory', () => {
|
||||
const dummyTurns: HistoryTurn[] = [
|
||||
{
|
||||
id: 'turn-1',
|
||||
content: { role: 'user', parts: [{ text: 'Hello' }] },
|
||||
},
|
||||
{
|
||||
id: 'turn-2',
|
||||
content: { role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
},
|
||||
{
|
||||
id: 'turn-3',
|
||||
content: { role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
},
|
||||
];
|
||||
|
||||
it('should initialize with empty history by default', () => {
|
||||
const history = new AgentChatHistory();
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with provided turns', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should push new turns', () => {
|
||||
const history = new AgentChatHistory();
|
||||
history.push(dummyTurns[0]);
|
||||
expect(history.length).toBe(1);
|
||||
expect(history.get()[0]).toEqual(dummyTurns[0]);
|
||||
});
|
||||
|
||||
it('should set and overwrite history turns', () => {
|
||||
const history = new AgentChatHistory(dummyTurns.slice(0, 1));
|
||||
expect(history.length).toBe(1);
|
||||
history.set(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should clear history', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
history.clear();
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('rollback', () => {
|
||||
it('should roll back history to a specified length', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(1);
|
||||
expect(history.length).toBe(1);
|
||||
expect(history.get()).toEqual([dummyTurns[0]]);
|
||||
});
|
||||
|
||||
it('should roll back to 0', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(0);
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should do nothing if rollback length is out of bounds (negative)', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(-1);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should do nothing if rollback length is out of bounds (greater than current history length)', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(5);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return raw Content array via getContents()', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.getContents()).toEqual(
|
||||
dummyTurns.map((turn) => turn.content),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support mapping and flatMapping operations', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
const mappedIds = history.map((turn) => turn.id);
|
||||
expect(mappedIds).toEqual(['turn-1', 'turn-2', 'turn-3']);
|
||||
|
||||
const flatMappedParts = history.flatMap((turn) => turn.content.parts || []);
|
||||
expect(flatMappedParts).toEqual([
|
||||
{ text: 'Hello' },
|
||||
{ text: 'Hi there' },
|
||||
{ text: 'How are you?' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,16 @@ export class AgentChatHistory {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back the history to a specified length.
|
||||
* Useful when a stream fails and we need to remove the un-responded turn(s).
|
||||
*/
|
||||
rollback(length: number) {
|
||||
if (length >= 0 && length <= this.history.length) {
|
||||
this.history = this.history.slice(0, length);
|
||||
}
|
||||
}
|
||||
|
||||
get(): readonly HistoryTurn[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockReturnValue(contentGeneratorConfig),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-1.5-pro'),
|
||||
getUserTier: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
|
||||
getApiKey: vi.fn().mockReturnValue('test-key'),
|
||||
|
||||
@@ -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);
|
||||
@@ -145,6 +154,13 @@ export async function createContentGeneratorConfig(
|
||||
vertexAiRouting,
|
||||
};
|
||||
|
||||
const getEnv = (key: string) => {
|
||||
if (config?.env && config.env[key] !== undefined) {
|
||||
return config.env[key];
|
||||
}
|
||||
return process.env[key];
|
||||
};
|
||||
|
||||
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
|
||||
// Return before touching the API-key keychain: on Linux without a Secret Service
|
||||
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
|
||||
@@ -156,16 +172,13 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
const geminiApiKey =
|
||||
apiKey ||
|
||||
process.env['GEMINI_API_KEY'] ||
|
||||
(await loadApiKey()) ||
|
||||
undefined;
|
||||
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
||||
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
|
||||
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
|
||||
const googleCloudProject =
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
|
||||
getEnv('GOOGLE_CLOUD_PROJECT') ||
|
||||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
|
||||
undefined;
|
||||
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
|
||||
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
|
||||
|
||||
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
|
||||
contentGeneratorConfig.apiKey = geminiApiKey;
|
||||
@@ -185,8 +198,7 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
if (authType === AuthType.GATEWAY) {
|
||||
contentGeneratorConfig.apiKey =
|
||||
apiKey || process.env['GEMINI_API_KEY'] || '';
|
||||
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
|
||||
contentGeneratorConfig.vertexai = false;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
@@ -341,6 +353,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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,11 @@ import {
|
||||
getRetryErrorType,
|
||||
} from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { resolveModel, supportsModernFeatures } from '../config/models.js';
|
||||
import {
|
||||
resolveModel,
|
||||
supportsModernFeatures,
|
||||
isGemini2Model,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import type { CompletedToolCall } from '../scheduler/types.js';
|
||||
@@ -104,6 +108,13 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
|
||||
|
||||
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
/**
|
||||
* Stands in for a model turn that never arrived because the stream failed
|
||||
* after a tool response was already committed to history.
|
||||
*/
|
||||
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
|
||||
'[The previous response was interrupted before it completed.]';
|
||||
|
||||
/**
|
||||
* Internal interface for parts that carry the magic 'callIndex' property
|
||||
* used during model response consolidation.
|
||||
@@ -221,7 +232,12 @@ export class InvalidStreamError extends Error {
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL';
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE';
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -229,7 +245,12 @@ export class InvalidStreamError extends Error {
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL',
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InvalidStreamError';
|
||||
@@ -383,6 +404,9 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<StreamEvent>> {
|
||||
await this.sendPromise;
|
||||
|
||||
const historyLengthBefore = this.agentHistory.length;
|
||||
const baselinePromptTokenCount = this.lastPromptTokenCount;
|
||||
|
||||
let streamDoneResolver: () => void;
|
||||
const streamDonePromise = new Promise<void>((resolve) => {
|
||||
streamDoneResolver = resolve;
|
||||
@@ -390,6 +414,17 @@ export class GeminiChat {
|
||||
this.sendPromise = streamDonePromise;
|
||||
|
||||
let userContent = createUserContent(message);
|
||||
const isOriginalFunctionResponse = isFunctionResponse(userContent);
|
||||
|
||||
// A turn can end leaving history on an unanswered tool response: a stream
|
||||
// error after the response was committed, or a cancelled tool call. Close
|
||||
// it before recording a genuinely new user message, otherwise the two user
|
||||
// turns are coalesced into one and the model continues the trailing text
|
||||
// instead of answering it.
|
||||
if (!isOriginalFunctionResponse) {
|
||||
this.closeUnansweredToolResponseTurn();
|
||||
}
|
||||
|
||||
const { model } =
|
||||
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
|
||||
@@ -398,7 +433,7 @@ export class GeminiChat {
|
||||
|
||||
// Record user input - capture complete message with all parts (text, files, images, etc.)
|
||||
// but skip recording function responses (tool call results) as they should be stored in tool call records
|
||||
if (!isFunctionResponse(userContent)) {
|
||||
if (!isOriginalFunctionResponse) {
|
||||
const userMessageParts = userContent.parts || [];
|
||||
const userMessageContent = partListUnionToString(userMessageParts);
|
||||
|
||||
@@ -515,6 +550,7 @@ export class GeminiChat {
|
||||
): AsyncGenerator<StreamEvent, void, void> {
|
||||
try {
|
||||
const maxAttempts = this.context.config.getMaxAttempts();
|
||||
let lastStreamError: unknown = undefined;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
let isConnectionPhase = true;
|
||||
@@ -526,7 +562,7 @@ export class GeminiChat {
|
||||
// If this is a retry, update the key with the new context.
|
||||
const currentConfigKey =
|
||||
attempt > 0
|
||||
? { ...modelConfigKey, isRetry: true }
|
||||
? { ...modelConfigKey, isRetry: true, lastStreamError }
|
||||
: modelConfigKey;
|
||||
|
||||
isConnectionPhase = true;
|
||||
@@ -545,6 +581,10 @@ export class GeminiChat {
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStreamError) {
|
||||
lastStreamError = error;
|
||||
}
|
||||
|
||||
if (error instanceof AgentExecutionStoppedError) {
|
||||
yield {
|
||||
type: StreamEventType.AGENT_EXECUTION_STOPPED,
|
||||
@@ -581,8 +621,7 @@ export class GeminiChat {
|
||||
);
|
||||
|
||||
const isContentError = error instanceof InvalidStreamError;
|
||||
const isRetryableContentError =
|
||||
isContentError && error.type !== 'NO_RESPONSE_TEXT';
|
||||
const isRetryableContentError = isContentError;
|
||||
const errorType = isContentError
|
||||
? error.type
|
||||
: getRetryErrorType(error);
|
||||
@@ -644,6 +683,15 @@ export class GeminiChat {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isOriginalFunctionResponse) {
|
||||
this.agentHistory.rollback(historyLengthBefore);
|
||||
this.chatRecordingService.updateMessagesFromHistory(
|
||||
this.agentHistory.get(),
|
||||
);
|
||||
this.lastPromptTokenCount = baselinePromptTokenCount;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
streamDoneResolver!();
|
||||
}
|
||||
@@ -652,6 +700,28 @@ export class GeminiChat {
|
||||
return streamWithRetries.call(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a closing model turn when history ends with an unanswered tool
|
||||
* response, so the next user message stays a turn of its own.
|
||||
*/
|
||||
private closeUnansweredToolResponseTurn(): void {
|
||||
const turns = this.agentHistory.get();
|
||||
const last = turns[turns.length - 1];
|
||||
if (
|
||||
last?.content.role !== 'user' ||
|
||||
!last.content.parts?.some((part) => !!part.functionResponse)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.agentHistory.push({
|
||||
id: randomUUID(),
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private extractBinaryInjections(
|
||||
parts: Part[] | undefined,
|
||||
): Part[] | undefined {
|
||||
@@ -683,10 +753,13 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
|
||||
// before sending to the Gemini API. This whitelists only standard Gemini fields.
|
||||
const scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
let scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
? scrubHistory([...requestHistory])
|
||||
: [...requestHistory];
|
||||
|
||||
// Always coalesce consecutive roles to prevent 400 Bad Request errors
|
||||
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
|
||||
|
||||
const scrubbedContents = scrubbedHistory.map((h) => h.content);
|
||||
|
||||
const requestContents = apiHistoryOverride
|
||||
@@ -763,9 +836,34 @@ export class GeminiChat {
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
// Apply Context-Aware Retries (On-Retry Nudging) to guide the model out of silent loops
|
||||
if (
|
||||
modelConfigKey.isRetry &&
|
||||
modelConfigKey.lastStreamError instanceof InvalidStreamError
|
||||
) {
|
||||
const lastError = modelConfigKey.lastStreamError;
|
||||
let nudgeMessage = '';
|
||||
if (lastError.type === 'THINKING_ONLY_RESPONSE') {
|
||||
nudgeMessage =
|
||||
'\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]';
|
||||
} else if (lastError.type === 'NO_RESPONSE_TEXT') {
|
||||
nudgeMessage =
|
||||
'\n[System: You previously returned an empty response with no text or thoughts. Please ensure you provide your final answer or call a tool now.]';
|
||||
}
|
||||
|
||||
if (nudgeMessage) {
|
||||
if (typeof config.systemInstruction === 'string') {
|
||||
config.systemInstruction += nudgeMessage;
|
||||
} else if (config.systemInstruction === undefined) {
|
||||
config.systemInstruction = nudgeMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let contentsToUse: Content[] =
|
||||
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
@@ -807,9 +905,10 @@ export class GeminiChat {
|
||||
);
|
||||
lastModelToUse = modelToUse;
|
||||
// Re-evaluate contentsToUse based on the new model's feature support
|
||||
contentsToUse = supportsModernFeatures(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
contentsToUse =
|
||||
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
}
|
||||
if (beforeModelResult.modifiedConfig) {
|
||||
Object.assign(config, beforeModelResult.modifiedConfig);
|
||||
@@ -953,9 +1052,16 @@ export class GeminiChat {
|
||||
? extractCuratedHistory(this.agentHistory.get())
|
||||
: [...this.agentHistory.get()];
|
||||
|
||||
return this.context.config.isContextManagementEnabled()
|
||||
? scrubHistory(history)
|
||||
: history;
|
||||
if (this.context.config.isContextManagementEnabled()) {
|
||||
return scrubHistory(history);
|
||||
}
|
||||
|
||||
const model = this.context.config.getModel();
|
||||
if (isGemini2Model(model) || supportsModernFeatures(model)) {
|
||||
return coalesceConsecutiveRoles(stripThoughts(history));
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1028,11 +1134,19 @@ export class GeminiChat {
|
||||
requestContents: readonly Content[],
|
||||
): readonly Content[] {
|
||||
// First, find the start of the active loop by finding the last user turn
|
||||
// with a text message, i.e. that is not a function response.
|
||||
// with a text message, i.e. that is not a function response. Testing for
|
||||
// text alone is not enough: `coalesceConsecutiveRoles` can merge a function
|
||||
// response turn with the prompt that follows it, and starting the loop at
|
||||
// such a turn starts it later than the API starts the turn, leaving earlier
|
||||
// function calls unsigned but still validated.
|
||||
let activeLoopStartIndex = -1;
|
||||
for (let i = requestContents.length - 1; i >= 0; i--) {
|
||||
const content = requestContents[i];
|
||||
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
|
||||
if (
|
||||
content.role === 'user' &&
|
||||
content.parts?.some((part) => part.text) &&
|
||||
!content.parts?.some((part) => part.functionResponse)
|
||||
) {
|
||||
activeLoopStartIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -1119,6 +1233,13 @@ export class GeminiChat {
|
||||
let hasThoughts = false;
|
||||
let finishReason: FinishReason | undefined;
|
||||
|
||||
// Buffers to prevent failed stream attempts from polluting telemetry and logs
|
||||
const bufferedThoughts: Array<{ subject: string; description: string }> =
|
||||
[];
|
||||
let bufferedUsageMetadata:
|
||||
| GenerateContentResponse['usageMetadata']
|
||||
| undefined = undefined;
|
||||
|
||||
// The SDK provides fully assembled FunctionCall objects in chunk.functionCalls
|
||||
// We use a Map to ensure we only keep the latest version of each call (by ID)
|
||||
const finalFunctionCallsMap = new Map<string, FunctionCall>();
|
||||
@@ -1174,7 +1295,10 @@ export class GeminiChat {
|
||||
if (content.parts.some((part) => part.thought)) {
|
||||
// Record thoughts
|
||||
hasThoughts = true;
|
||||
this.recordThoughtFromContent(content);
|
||||
const thought = this.extractThoughtFromContent(content);
|
||||
if (thought) {
|
||||
bufferedThoughts.push(thought);
|
||||
}
|
||||
}
|
||||
if (content.parts.some((part) => part.functionCall)) {
|
||||
hasToolCall = true;
|
||||
@@ -1202,12 +1326,9 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
// Record token usage if this chunk has usageMetadata
|
||||
// Buffer token usage if this chunk has usageMetadata
|
||||
if (chunk.usageMetadata) {
|
||||
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
|
||||
if (chunk.usageMetadata.promptTokenCount !== undefined) {
|
||||
this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount;
|
||||
}
|
||||
bufferedUsageMetadata = chunk.usageMetadata;
|
||||
}
|
||||
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
@@ -1294,29 +1415,22 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
const responseText = consolidatedParts
|
||||
const rawResponseText = consolidatedParts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim();
|
||||
.join('');
|
||||
|
||||
let id: string;
|
||||
// Record model response text from the collected parts.
|
||||
// Also flush when there are thoughts or a tool call (even with no text)
|
||||
// so that BeforeTool hooks always see the latest transcript state.
|
||||
if (responseText || hasThoughts || hasToolCall) {
|
||||
id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'gemini',
|
||||
content: responseText,
|
||||
});
|
||||
} else {
|
||||
// Still need a durable ID even if response is empty (e.g. only tool calls)
|
||||
id = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
consolidatedParts,
|
||||
);
|
||||
}
|
||||
// Clean zero-width/invisible characters and HTML comments to determine actual printable/visible content
|
||||
let responseText = rawResponseText.replace(
|
||||
/[\u200B-\u200D\uFEFF\u200E\u200F]/g,
|
||||
'',
|
||||
);
|
||||
let previous: string;
|
||||
do {
|
||||
previous = responseText;
|
||||
responseText = responseText.replace(/<!--[\s\S]*?-->/g, '');
|
||||
} while (responseText !== previous);
|
||||
responseText = responseText.trim();
|
||||
|
||||
// Stream validation logic: A stream is considered successful if:
|
||||
// 1. There's a tool call OR
|
||||
@@ -1346,6 +1460,36 @@ export class GeminiChat {
|
||||
);
|
||||
}
|
||||
if (!responseText) {
|
||||
if (finishReason === FinishReason.MAX_TOKENS) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to token limit exhaustion (MAX_TOKENS) with empty response text.',
|
||||
'MAX_TOKENS_EXCEEDED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.SAFETY) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to safety settings (SAFETY) with empty response text.',
|
||||
'SAFETY_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.RECITATION) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to recitation settings (RECITATION) with empty response text.',
|
||||
'RECITATION_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.OTHER) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to other settings (OTHER) with empty response text.',
|
||||
'OTHER_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (hasThoughts) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended with empty response text but contained reasoning thoughts.',
|
||||
'THINKING_ONLY_RESPONSE',
|
||||
);
|
||||
}
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended with empty response text.',
|
||||
'NO_RESPONSE_TEXT',
|
||||
@@ -1353,6 +1497,37 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush buffered thoughts from the successful attempt
|
||||
for (const thought of bufferedThoughts) {
|
||||
this.chatRecordingService.recordThought(thought);
|
||||
}
|
||||
|
||||
// Flush buffered usage metadata and token counts from the successful attempt
|
||||
if (bufferedUsageMetadata) {
|
||||
this.chatRecordingService.recordMessageTokens(bufferedUsageMetadata);
|
||||
if (bufferedUsageMetadata.promptTokenCount !== undefined) {
|
||||
this.lastPromptTokenCount = bufferedUsageMetadata.promptTokenCount;
|
||||
}
|
||||
}
|
||||
|
||||
let id: string;
|
||||
// Record model response text from the collected parts.
|
||||
// Also flush when there are thoughts or a tool call (even with no text)
|
||||
// so that BeforeTool hooks always see the latest transcript state.
|
||||
if (responseText || hasThoughts || hasToolCall) {
|
||||
id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'gemini',
|
||||
content: responseText,
|
||||
});
|
||||
} else {
|
||||
// Still need a durable ID even if response is empty (e.g. only tool calls)
|
||||
id = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
consolidatedParts,
|
||||
);
|
||||
}
|
||||
|
||||
this.agentHistory.push({
|
||||
id,
|
||||
content: { role: 'model', parts: consolidatedParts },
|
||||
@@ -1407,11 +1582,13 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and records thought from thought content.
|
||||
* Extracts thought from thought content.
|
||||
*/
|
||||
private recordThoughtFromContent(content: Content): void {
|
||||
private extractThoughtFromContent(
|
||||
content: Content,
|
||||
): { subject: string; description: string } | undefined {
|
||||
if (!content.parts || content.parts.length === 0) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const thoughtPart = content.parts[0];
|
||||
@@ -1424,11 +1601,12 @@ export class GeminiChat {
|
||||
: '';
|
||||
const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
|
||||
|
||||
this.chatRecordingService.recordThought({
|
||||
return {
|
||||
subject,
|
||||
description,
|
||||
});
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1472,3 +1650,73 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function coalesceConsecutiveRoles(
|
||||
history: HistoryTurn[],
|
||||
): HistoryTurn[] {
|
||||
const result: HistoryTurn[] = [];
|
||||
for (const turn of history) {
|
||||
const lastIdx = result.length - 1;
|
||||
const last = result[lastIdx];
|
||||
if (last && last.content.role && last.content.role === turn.content.role) {
|
||||
const hasParts = last.content.parts || turn.content.parts;
|
||||
result[lastIdx] = {
|
||||
id: last.id,
|
||||
content: {
|
||||
...last.content,
|
||||
parts: hasParts
|
||||
? [...(last.content.parts || []), ...(turn.content.parts || [])]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
result.push({
|
||||
id: turn.id,
|
||||
content: { ...turn.content },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
|
||||
return history
|
||||
.map((turn) => {
|
||||
if (!turn.content.parts) return turn;
|
||||
const hasThought = turn.content.parts.some((p) => p && p.thought);
|
||||
if (!hasThought) return turn;
|
||||
|
||||
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
|
||||
|
||||
// The thoughtSignature the API requires on the first functionCall of a
|
||||
// model turn is sometimes only carried by the thought part we just
|
||||
// removed, not by the functionCall part itself. Without it, replaying
|
||||
// this turn in a later request gets rejected with a 400 "missing
|
||||
// thought_signature" error, so inject a synthetic one if needed.
|
||||
let patchedFirstCall = false;
|
||||
const finalParts =
|
||||
turn.content.role === 'model'
|
||||
? nonThoughtParts.map((p) => {
|
||||
if (!patchedFirstCall && p.functionCall) {
|
||||
patchedFirstCall = true;
|
||||
if (!p.thoughtSignature) {
|
||||
return {
|
||||
...p,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
return p;
|
||||
})
|
||||
: nonThoughtParts;
|
||||
|
||||
return {
|
||||
...turn,
|
||||
content: {
|
||||
...turn.content,
|
||||
parts: finalParts,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((turn) => !turn.content.parts || turn.content.parts.length > 0);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,15 @@ describe('Turn', () => {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_FINISH_REASON',
|
||||
message: 'Test invalid stream',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(turn.getDebugResponses().length).toBe(0);
|
||||
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
|
||||
});
|
||||
|
||||
@@ -105,6 +105,19 @@ export type ServerGeminiContextWindowWillOverflowEvent = {
|
||||
|
||||
export type ServerGeminiInvalidStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream;
|
||||
value: {
|
||||
type:
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE';
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServerGeminiModelInfoEvent = {
|
||||
@@ -408,7 +421,13 @@ export class Turn {
|
||||
}
|
||||
|
||||
if (e instanceof InvalidStreamError) {
|
||||
yield { type: GeminiEventType.InvalidStream };
|
||||
yield {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: e.type,
|
||||
message: e.message,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setActiveModel: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
activateFallbackMode: vi.fn(),
|
||||
rotateSessionId: vi.fn(),
|
||||
getModelAvailabilityService: vi.fn(() =>
|
||||
createAvailabilityServiceMock({
|
||||
selectedModel: FALLBACK_MODEL,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { createSessionId } from '../utils/session.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
@@ -161,8 +162,9 @@ async function processIntent(
|
||||
): Promise<boolean> {
|
||||
switch (intent) {
|
||||
case 'retry_always':
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
// Rotate the session ID to ensure the backend treats the retried request
|
||||
// as a brand-new session, preventing stateful model-switching errors.
|
||||
config.rotateSessionId(createSessionId());
|
||||
config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return true;
|
||||
|
||||
|
||||
@@ -109,23 +109,90 @@ priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform)
|
||||
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
|
||||
# This rule handles the case where there is a session ID in the plan file path
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform).
|
||||
# This rule employs split, traversal-safe, and ReDoS-safe patterns to provide defense-in-depth:
|
||||
# - Absolute paths must strictly be inside the designated plans directory under `.gemini/tmp/`
|
||||
# - Relative paths must be clean (no path traversal `..` and no absolute prefixes)
|
||||
|
||||
# This rule handles the case where there isn't a session ID in the plan file path
|
||||
# 1. Absolute paths with session ID
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 2. Absolute paths without session ID
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 3. Relative paths starting with .gemini (with session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 4. Relative paths starting with .gemini (without session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 5. Relative paths starting with ./.gemini (with session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 6. Relative paths starting with ./.gemini (without session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 7. Clean relative filename (no directories, e.g. plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 8. Clean relative path starting with ./ (no directories, e.g. ./plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 9. Clean relative path under plans directory (e.g. plans/plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 10. Clean relative path under plans directory starting with ./ (e.g. ./plans/plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# Explicitly Deny other write operations in Plan mode with a clear message.
|
||||
[[rule]]
|
||||
|
||||
@@ -413,6 +413,16 @@ export function renderOperationalGuidelines(
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. ${formatToolName(EDIT_TOOL_NAME)}, ${formatToolName(WRITE_FILE_TOOL_NAME)}), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ describe('CheckerRunner', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockContextBuilder = new ContextBuilder({} as Config);
|
||||
vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({
|
||||
env: {},
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
} as unknown as Config);
|
||||
mockRegistry = new CheckerRegistry('/mock/dist');
|
||||
CheckerRegistry.prototype.resolveInProcess = vi.fn();
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export class CheckerRunner {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(checkerPath, [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: this.contextBuilder.config.getWorkingDir(),
|
||||
env: { ...process.env, ...this.contextBuilder.config.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
|
||||
@@ -15,6 +15,10 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
export class ContextBuilder {
|
||||
constructor(private readonly context: AgentLoopContext) {}
|
||||
|
||||
get config() {
|
||||
return this.context.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full context object with all available data.
|
||||
*/
|
||||
|
||||
@@ -630,8 +630,8 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
// finalizeCall is handled by the processing loop, not synchronously by cancelAll
|
||||
// expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
// finalizeCall is called synchronously by cancelAll to ensure completedBatch is populated and isActive is updated immediately
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
expect(mockStateManager.cancelAllQueued).toHaveBeenCalledWith(
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
|
||||
@@ -278,6 +278,7 @@ export class Scheduler {
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
this.state.finalizeCall(activeCall.request.callId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +439,14 @@ export class Scheduler {
|
||||
*/
|
||||
private async _processNextItem(signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted || this.isCancelling) {
|
||||
// Finalize active calls that are terminal
|
||||
const activeCalls = this.state.allActiveCalls;
|
||||
for (const call of activeCalls) {
|
||||
if (this.isTerminal(call.status)) {
|
||||
this.state.finalizeCall(call.request.callId);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.cancelAllQueued('Operation cancelled');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -308,6 +308,125 @@ describe('ChatRecordingService', () => {
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.sessionId).toBe('old-session-id');
|
||||
});
|
||||
|
||||
it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => {
|
||||
// Regression test for the `/compress` "Failed to load resumed session
|
||||
// data from file" bug: when resuming with a filePath that cannot be
|
||||
// loaded from disk, initialize must NOT throw. It should adopt the
|
||||
// in-memory conversation it was handed and rewrite a clean file.
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const missingFile = path.join(chatsDir, 'missing-session.jsonl');
|
||||
expect(fs.existsSync(missingFile)).toBe(false);
|
||||
|
||||
const inMemoryConversation = {
|
||||
sessionId: 'resumed-session-id',
|
||||
projectHash: 'resumed-project-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: 'hello from memory',
|
||||
},
|
||||
],
|
||||
} as unknown as ConversationRecord;
|
||||
|
||||
await expect(
|
||||
chatRecordingService.initialize({
|
||||
filePath: missingFile,
|
||||
conversation: inMemoryConversation,
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The in-memory conversation is adopted.
|
||||
expect(chatRecordingService.getConversation()?.sessionId).toBe(
|
||||
'resumed-session-id',
|
||||
);
|
||||
|
||||
// A clean, loadable file is rewritten from the in-memory copy so future
|
||||
// loads and appends succeed.
|
||||
const reloaded = (await loadConversationRecord(
|
||||
missingFile,
|
||||
)) as ConversationRecord;
|
||||
expect(reloaded).not.toBeNull();
|
||||
expect(reloaded.sessionId).toBe('resumed-session-id');
|
||||
expect(reloaded.projectHash).toBe('resumed-project-hash');
|
||||
expect(reloaded.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should preserve an unreadable session file instead of destroying it', async () => {
|
||||
// The reload may have failed only transiently, so the original bytes
|
||||
// must survive the recovery rewrite.
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'unreadable.jsonl');
|
||||
|
||||
// No usable metadata line => loadConversationRecord() returns null.
|
||||
const originalBytes = '{"not":"a valid metadata line"}\n';
|
||||
fs.writeFileSync(sessionFile, originalBytes);
|
||||
|
||||
await chatRecordingService.initialize({
|
||||
filePath: sessionFile,
|
||||
conversation: {
|
||||
sessionId: 'recovered-session-id',
|
||||
projectHash: 'recovered-project-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [],
|
||||
} as unknown as ConversationRecord,
|
||||
});
|
||||
|
||||
// The rewritten file is loadable again...
|
||||
const reloaded = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(reloaded.sessionId).toBe('recovered-session-id');
|
||||
|
||||
// ...and the original bytes were kept alongside it.
|
||||
const preserved = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.startsWith('unreadable.jsonl.unreadable-'));
|
||||
expect(preserved).toHaveLength(1);
|
||||
expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe(
|
||||
originalBytes,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not leave a temp file behind when the rewrite fails', async () => {
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl');
|
||||
|
||||
// Fail the rename that publishes the temp file, leaving it orphaned.
|
||||
const realRename = fs.renameSync;
|
||||
vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
|
||||
if (String(from).includes('.tmp-')) {
|
||||
throw new Error('simulated rename failure');
|
||||
}
|
||||
return realRename(from, to);
|
||||
});
|
||||
|
||||
await expect(
|
||||
chatRecordingService.initialize({
|
||||
filePath: sessionFile,
|
||||
conversation: {
|
||||
sessionId: 'temp-cleanup-session',
|
||||
projectHash: 'temp-cleanup-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [],
|
||||
} as unknown as ConversationRecord,
|
||||
}),
|
||||
).rejects.toThrow('simulated rename failure');
|
||||
|
||||
const leftovers = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.includes('.tmp-'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordMessage', () => {
|
||||
|
||||
@@ -462,7 +462,16 @@ export class ChatRecordingService {
|
||||
// Update the session ID in the existing file
|
||||
this.updateMetadata({ sessionId: this.sessionId });
|
||||
} else {
|
||||
throw new Error('Failed to load resumed session data from file');
|
||||
// The file could not be reloaded (missing, corrupt metadata, or an
|
||||
// I/O error). Fall back to the in-memory conversation we were handed
|
||||
// rather than failing the caller, and rewrite a clean file from it.
|
||||
debugLogger.warn(
|
||||
'Failed to reload resumed session data from file; falling back ' +
|
||||
'to the in-memory conversation.',
|
||||
);
|
||||
this.cachedConversation = resumedSessionData.conversation;
|
||||
this.projectHash = this.cachedConversation.projectHash;
|
||||
this.rewriteConversationFile(this.cachedConversation);
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
@@ -563,6 +572,73 @@ export class ChatRecordingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the session file from an in-memory record. Any existing
|
||||
* (unreadable) file is preserved alongside rather than destroyed, and the
|
||||
* new file is written atomically (temp file + rename).
|
||||
*/
|
||||
private rewriteConversationFile(conversation: ConversationRecord): void {
|
||||
if (!this.conversationFile) return;
|
||||
|
||||
// Normalize legacy `.json` paths to the `.jsonl` format we write.
|
||||
if (this.conversationFile.endsWith('.json')) {
|
||||
this.conversationFile = this.conversationFile + 'l';
|
||||
}
|
||||
|
||||
const { messages, memoryScratchpad, ...metadata } = conversation;
|
||||
const lines: string[] = [JSON.stringify(metadata)];
|
||||
for (const msg of messages) {
|
||||
lines.push(JSON.stringify(msg));
|
||||
}
|
||||
if (memoryScratchpad) {
|
||||
lines.push(JSON.stringify({ $set: { memoryScratchpad } }));
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
|
||||
|
||||
// The existing file was unreadable, but it may have been only
|
||||
// transiently so (a lock or I/O blip) rather than truly corrupt. Keep
|
||||
// its bytes rather than destroying them.
|
||||
if (fs.existsSync(this.conversationFile)) {
|
||||
const backup = `${this.conversationFile}.unreadable-${Date.now()}`;
|
||||
try {
|
||||
fs.renameSync(this.conversationFile, backup);
|
||||
debugLogger.warn(
|
||||
`Preserved the unreadable session file at ${backup}.`,
|
||||
);
|
||||
} catch (backupError) {
|
||||
debugLogger.error(
|
||||
'Failed to preserve the unreadable session file.',
|
||||
backupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tempFile = `${this.conversationFile}.tmp-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(tempFile, content);
|
||||
fs.renameSync(tempFile, this.conversationFile);
|
||||
} catch (error) {
|
||||
// The rename did not complete, so the temp file would be left behind.
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors so the original failure still surfaces.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOSPC') {
|
||||
this.conversationFile = null;
|
||||
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateMetadata(updates: Partial<ConversationRecord>): void {
|
||||
if (!this.cachedConversation) return;
|
||||
Object.assign(this.cachedConversation, updates);
|
||||
|
||||
@@ -27,8 +27,15 @@ export class FileKeychain implements Keychain {
|
||||
}
|
||||
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-gcm',
|
||||
this.encryptionKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
@@ -48,10 +55,19 @@ export class FileKeychain implements Keychain {
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
if (iv.length !== 12 && iv.length !== 16) {
|
||||
throw new Error('Invalid IV length: Must be 12 or 16 bytes');
|
||||
}
|
||||
|
||||
if (authTag.length !== 16) {
|
||||
throw new Error('Invalid authentication tag length: Must be 16 bytes');
|
||||
}
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.encryptionKey,
|
||||
iv,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
@@ -67,30 +83,28 @@ export class FileKeychain implements Keychain {
|
||||
}
|
||||
|
||||
private async loadData(): Promise<Record<string, Record<string, string>>> {
|
||||
let data: string;
|
||||
try {
|
||||
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
|
||||
const decrypted = this.decrypt(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
|
||||
data = await fs.readFile(this.tokenFilePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const err = error as NodeJS.ErrnoException & { message?: string };
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
err.message?.includes('Invalid encrypted data format') ||
|
||||
err.message?.includes(
|
||||
'Unsupported state or unable to authenticate data',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = this.decrypt(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { FileKeychain } from './fileKeychain.js';
|
||||
|
||||
describe('AES-GCM Tag Length Verification', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temporary directory for test isolation
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-keychain-'));
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
// Clean up the temporary directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should use a secure 128-bit (16-byte) AES-GCM authentication tag and standard 12-byte IV', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
|
||||
// 4. Verify the lengths of the components
|
||||
const ivBuffer = Buffer.from(ivHex, 'hex');
|
||||
const authTagBuffer = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
// IV should be exactly 12 bytes (96 bits) by default
|
||||
expect(ivBuffer.length).toBe(12);
|
||||
expect(ivHex.length).toBe(24);
|
||||
|
||||
// Authentication Tag should be exactly 16 bytes (128 bits)
|
||||
expect(authTagBuffer.length).toBe(16);
|
||||
expect(authTagHex.length).toBe(32); // 32 hex characters
|
||||
|
||||
// Assert that the tag is NOT truncated to 4 bytes (32 bits)
|
||||
expect(authTagBuffer.length).not.toBe(4);
|
||||
expect(authTagHex.length).not.toBe(8); // 8 hex characters
|
||||
|
||||
// 5. Verify that decryption works correctly with the 16-byte tag
|
||||
const decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
});
|
||||
|
||||
it('should support both 12-byte and 16-byte IVs for backward compatibility', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write (generates 12-byte IV)
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Verify 12-byte IV decryption works
|
||||
let decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
|
||||
// 3. Manually simulate a legacy 16-byte IV credentials file
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const legacyIv = crypto.randomBytes(16);
|
||||
const encryptionKey = (keychain as unknown as { encryptionKey: Buffer })
|
||||
.encryptionKey;
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-gcm',
|
||||
encryptionKey,
|
||||
legacyIv,
|
||||
{
|
||||
authTagLength: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify({ [service]: { [account]: password } }),
|
||||
'utf8',
|
||||
'hex',
|
||||
);
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const legacyPayload =
|
||||
legacyIv.toString('hex') +
|
||||
':' +
|
||||
authTag.toString('hex') +
|
||||
':' +
|
||||
encrypted;
|
||||
await fs.writeFile(credentialsFilePath, legacyPayload, 'utf-8');
|
||||
|
||||
// 4. Verify 16-byte IV decryption works successfully (backward compatibility)
|
||||
decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
});
|
||||
|
||||
it('should reject decryption of a credentials file with a truncated tag', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
const encryptedHex = parts[2];
|
||||
|
||||
// 4. Create a truncated 4-byte tag (8 hex characters)
|
||||
const truncatedTagHex = authTagHex.substring(0, 8);
|
||||
const truncatedEncryptedData = `${ivHex}:${truncatedTagHex}:${encryptedHex}`;
|
||||
|
||||
// 5. Overwrite the credentials file with the truncated-tag payload
|
||||
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
|
||||
|
||||
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
|
||||
await expect(keychain.getPassword(service, account)).rejects.toThrow(
|
||||
'Corrupted credentials file detected',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject decryption of a credentials file with a truncated IV', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
const encryptedHex = parts[2];
|
||||
|
||||
// 4. Create a truncated 4-byte IV (8 hex characters)
|
||||
const truncatedIvHex = ivHex.substring(0, 8);
|
||||
const truncatedEncryptedData = `${truncatedIvHex}:${authTagHex}:${encryptedHex}`;
|
||||
|
||||
// 5. Overwrite the credentials file with the truncated-IV payload
|
||||
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
|
||||
|
||||
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
|
||||
await expect(keychain.getPassword(service, account)).rejects.toThrow(
|
||||
'Corrupted credentials file detected',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,93 @@ describe('LoopDetectionService', () => {
|
||||
}
|
||||
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should detect an alternating tool call loop (cycle of length 2)', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
|
||||
let loopCount = 0;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const currentEvent = i % 2 === 0 ? eventA : eventB;
|
||||
const result = service.addAndCheck(currentEvent);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect a cyclic tool call loop of length 3', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
const eventC = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_c.txt',
|
||||
});
|
||||
|
||||
let loopCount = 0;
|
||||
const sequence = [eventA, eventB, eventC];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = service.addAndCheck(sequence[i % 3]);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect a cyclic tool call loop of length 5', () => {
|
||||
const e1 = createToolCallRequestEvent('t', { id: 1 });
|
||||
const e2 = createToolCallRequestEvent('t', { id: 2 });
|
||||
const e3 = createToolCallRequestEvent('t', { id: 3 });
|
||||
const e4 = createToolCallRequestEvent('t', { id: 4 });
|
||||
const e5 = createToolCallRequestEvent('t', { id: 5 });
|
||||
|
||||
let loopCount = 0;
|
||||
const sequence = [e1, e2, e3, e4, e5];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const result = service.addAndCheck(sequence[i % 5]);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not detect loops for non-looping alternating sequences that vary', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
const eventC = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_c.txt',
|
||||
});
|
||||
|
||||
// Run some alternating calls, then break the pattern with eventC
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
expect(service.addAndCheck(eventC).count).toBe(0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Loop Detection', () => {
|
||||
|
||||
@@ -136,8 +136,7 @@ export class LoopDetectionService {
|
||||
private userPrompt = '';
|
||||
|
||||
// Tool call tracking
|
||||
private lastToolCallKey: string | null = null;
|
||||
private toolCallRepetitionCount: number = 0;
|
||||
private toolCallHistory: string[] = [];
|
||||
|
||||
// Content streaming tracking
|
||||
private streamContentHistory = '';
|
||||
@@ -313,15 +312,39 @@ export class LoopDetectionService {
|
||||
|
||||
private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
|
||||
const key = this.getToolCallKey(toolCall);
|
||||
if (this.lastToolCallKey === key) {
|
||||
this.toolCallRepetitionCount++;
|
||||
} else {
|
||||
this.lastToolCallKey = key;
|
||||
this.toolCallRepetitionCount = 1;
|
||||
this.toolCallHistory.push(key);
|
||||
|
||||
const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD;
|
||||
if (this.toolCallHistory.length > maxRequiredLength) {
|
||||
this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength);
|
||||
}
|
||||
if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
|
||||
return true;
|
||||
|
||||
const n = this.toolCallHistory.length;
|
||||
const R = TOOL_CALL_LOOP_THRESHOLD; // 5
|
||||
|
||||
// Check for repeating patterns of cycle length k from 1 to 5
|
||||
for (let k = 1; k <= 5; k++) {
|
||||
const requiredLength = k * R;
|
||||
if (n >= requiredLength) {
|
||||
const cycle = this.toolCallHistory.slice(-k);
|
||||
let isPatternMatch = true;
|
||||
|
||||
for (let i = 0; i < requiredLength; i++) {
|
||||
const indexFromEnd = requiredLength - i;
|
||||
const actualKey = this.toolCallHistory[n - indexFromEnd];
|
||||
const expectedKey = cycle[i % k];
|
||||
if (actualKey !== expectedKey) {
|
||||
isPatternMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPatternMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -739,8 +762,7 @@ export class LoopDetectionService {
|
||||
}
|
||||
|
||||
private resetToolCallCount(): void {
|
||||
this.lastToolCallKey = null;
|
||||
this.toolCallRepetitionCount = 0;
|
||||
this.toolCallHistory = [];
|
||||
}
|
||||
|
||||
private resetContentTracking(resetHistory = true): void {
|
||||
|
||||
@@ -37,6 +37,9 @@ export interface ModelConfigKey {
|
||||
// Indicates whether this request originates from the primary interactive chat model.
|
||||
// Enables the default fallback configuration to `chat-base` when unknown.
|
||||
isChatModel?: boolean;
|
||||
|
||||
// The last stream error that triggered this retry attempt, if any.
|
||||
lastStreamError?: unknown;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
|
||||
@@ -139,6 +139,7 @@ export interface ShellExecutionConfig {
|
||||
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
|
||||
originalCommand?: string;
|
||||
sessionId?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -461,9 +462,10 @@ export class ShellExecutionService {
|
||||
const spawnArgs = [...argsPrefix, finalCommand];
|
||||
|
||||
// 2. Prepare Environment
|
||||
const sourceEnv = shellExecutionConfig.env ?? process.env;
|
||||
const gitConfigKeys: string[] = [];
|
||||
if (!isInteractive) {
|
||||
for (const key in process.env) {
|
||||
for (const key in sourceEnv) {
|
||||
if (key.startsWith('GIT_CONFIG_')) {
|
||||
gitConfigKeys.push(key);
|
||||
}
|
||||
@@ -479,7 +481,7 @@ export class ShellExecutionService {
|
||||
],
|
||||
};
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
|
||||
const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig);
|
||||
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
...sanitizedEnv,
|
||||
@@ -493,7 +495,7 @@ export class ShellExecutionService {
|
||||
if (!isInteractive) {
|
||||
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
|
||||
for (const key of gitConfigKeys) {
|
||||
baseEnv[key] = process.env[key];
|
||||
baseEnv[key] = sourceEnv[key];
|
||||
}
|
||||
|
||||
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
|
||||
|
||||
@@ -173,6 +173,7 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 1,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 500,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 5,
|
||||
@@ -229,6 +230,7 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 2,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 1100,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 10,
|
||||
@@ -305,6 +307,9 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 1,
|
||||
totalErrors: 1,
|
||||
totalLatencyMs: 300,
|
||||
errorsByType: {
|
||||
UNKNOWN: 1,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
@@ -319,6 +324,42 @@ describe('UiTelemetryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should track errors by error_type distinctly', () => {
|
||||
const event1 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 200,
|
||||
error: 'Empty response',
|
||||
error_type: 'NO_RESPONSE_TEXT',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
const event2 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 250,
|
||||
error: 'Malformed JSON',
|
||||
error_type: 'MALFORMED_FUNCTION_CALL',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
const event3 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 100,
|
||||
error: 'Another empty response',
|
||||
error_type: 'NO_RESPONSE_TEXT',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
service.addEvent(event1);
|
||||
service.addEvent(event2);
|
||||
service.addEvent(event3);
|
||||
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics.models['gemini-2.5-pro'].api.errorsByType).toEqual({
|
||||
NO_RESPONSE_TEXT: 2,
|
||||
MALFORMED_FUNCTION_CALL: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should aggregate ApiErrorEvents and ApiResponseEvents', () => {
|
||||
const responseEvent = {
|
||||
'event.name': EVENT_API_RESPONSE,
|
||||
@@ -351,6 +392,9 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 2,
|
||||
totalErrors: 1,
|
||||
totalLatencyMs: 800,
|
||||
errorsByType: {
|
||||
UNKNOWN: 1,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
input: 5,
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ModelMetrics {
|
||||
totalRequests: number;
|
||||
totalErrors: number;
|
||||
totalLatencyMs: number;
|
||||
errorsByType?: Record<string, number>;
|
||||
};
|
||||
tokens: {
|
||||
input: number;
|
||||
@@ -110,6 +111,7 @@ const createInitialModelMetrics = (): ModelMetrics => ({
|
||||
totalRequests: 0,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 0,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
@@ -170,6 +172,23 @@ export class UiTelemetryService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
recordSemanticValidationError(model: string, errorType: string): void {
|
||||
const modelMetrics = this.getOrCreateModelMetrics(model);
|
||||
modelMetrics.api.totalErrors++;
|
||||
|
||||
if (!modelMetrics.api.errorsByType) {
|
||||
modelMetrics.api.errorsByType = {};
|
||||
}
|
||||
const type = errorType || 'INVALID_STREAM';
|
||||
modelMetrics.api.errorsByType[type] =
|
||||
(modelMetrics.api.errorsByType[type] || 0) + 1;
|
||||
|
||||
this.emit('update', {
|
||||
metrics: this.#metrics,
|
||||
lastPromptTokenCount: this.#lastPromptTokenCount,
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics(): SessionMetrics {
|
||||
return this.#metrics;
|
||||
}
|
||||
@@ -326,6 +345,13 @@ export class UiTelemetryService extends EventEmitter {
|
||||
modelMetrics.api.totalErrors++;
|
||||
modelMetrics.api.totalLatencyMs += event.duration_ms;
|
||||
|
||||
if (!modelMetrics.api.errorsByType) {
|
||||
modelMetrics.api.errorsByType = {};
|
||||
}
|
||||
const errorType = event.error_type || 'UNKNOWN';
|
||||
modelMetrics.api.errorsByType[errorType] =
|
||||
(modelMetrics.api.errorsByType[errorType] || 0) + 1;
|
||||
|
||||
if (event.role) {
|
||||
if (!modelMetrics.roles[event.role]) {
|
||||
modelMetrics.roles[event.role] = createInitialRoleMetrics();
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -1304,6 +1331,44 @@ function doIt() {
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call FixLLMEditWithInstruction for .json files even when disableLLMCorrection is false', async () => {
|
||||
const filePath = path.join(rootDir, 'test.json');
|
||||
fs.writeFileSync(filePath, '{"key": "value"}', 'utf8');
|
||||
|
||||
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: filePath,
|
||||
instruction: 'Replace value',
|
||||
old_string: 'nonexistent',
|
||||
new_string: 'replacement',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call FixLLMEditWithInstruction for .ipynb files even when disableLLMCorrection is false', async () => {
|
||||
const filePath = path.join(rootDir, 'notebook.ipynb');
|
||||
fs.writeFileSync(filePath, '{"cells": []}', 'utf8');
|
||||
|
||||
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: filePath,
|
||||
instruction: 'Replace cell',
|
||||
old_string: 'nonexistent',
|
||||
new_string: 'replacement',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JIT context discovery', () => {
|
||||
|
||||
+120
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,7 +767,12 @@ class EditToolInvocation
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.getDisableLLMCorrection()) {
|
||||
const fileExt = path.extname(this.resolvedPath).toLowerCase();
|
||||
const isJsonOrIpynb = ['.json', '.ipynb', '.jsonc', '.json5'].includes(
|
||||
fileExt,
|
||||
);
|
||||
|
||||
if (this.config.getDisableLLMCorrection() || isJsonOrIpynb) {
|
||||
return {
|
||||
currentContent,
|
||||
newContent: currentContent,
|
||||
@@ -1094,28 +1125,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 +1198,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 +1265,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user