mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f8541cf7a2 | |||
| 6e0bd68e45 | |||
| d3ef6aca40 | |||
| be7ba2c22a | |||
| c22137ea0a | |||
| 6613e129de | |||
| 93844dfa10 | |||
| c427d18fea | |||
| d5e25b9929 | |||
| 7ef3b4e1fc |
@@ -167,6 +167,7 @@ runs:
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm publish \
|
||||
--ignore-scripts \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
@@ -196,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'
|
||||
@@ -212,11 +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
|
||||
@@ -242,6 +274,7 @@ runs:
|
||||
# Tag staging for initial release
|
||||
run: |
|
||||
npm publish \
|
||||
--ignore-scripts \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
@@ -249,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
|
||||
@@ -86,7 +86,7 @@ runs:
|
||||
- name: 'Install dependencies for integration tests'
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: 'npm ci'
|
||||
run: 'npm ci --ignore-scripts'
|
||||
|
||||
- name: '🔬 Run integration tests against NPM release'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
@@ -98,4 +98,6 @@ runs:
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
CI: 'false'
|
||||
shell: 'bash'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
run: |
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
|
||||
npm run test:integration:sandbox:none
|
||||
|
||||
@@ -9,7 +9,9 @@ updates:
|
||||
reviewers:
|
||||
- 'joshualitt'
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
semver-minor-days: 14
|
||||
semver-patch-days: 14
|
||||
groups:
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
@@ -26,6 +28,10 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- 'joshualitt'
|
||||
cooldown:
|
||||
semver-major-days: 14
|
||||
semver-minor-days: 14
|
||||
semver-patch-days: 14
|
||||
groups:
|
||||
actions-dependencies:
|
||||
patterns:
|
||||
|
||||
@@ -145,12 +145,12 @@ jobs:
|
||||
skip-branch-cleanup: true
|
||||
force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}"
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}'
|
||||
npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}'
|
||||
npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}'
|
||||
cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}'
|
||||
core-package-name: '${{ vars.CORE_PACKAGE_NAME }}'
|
||||
a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}'
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}"
|
||||
cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}"
|
||||
core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}"
|
||||
a2a-package-name: "${{ vars.A2A_PACKAGE_NAME || '@google/gemini-cli-a2a-server' }}"
|
||||
|
||||
- name: 'Create and Merge Pull Request'
|
||||
if: "github.event.inputs.environment != 'dev'"
|
||||
|
||||
@@ -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,19 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## 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
|
||||
@@ -507,6 +520,7 @@ on GitHub.
|
||||
headlessly in notebook cells or interactively in the built-in terminal
|
||||
([pic](https://imgur.com/a/G0Tn7vi))
|
||||
- 🎉**Gemini CLI Extensions:**
|
||||
|
||||
- **Conductor:** Planning++, Gemini works with you to build out a detailed
|
||||
plan, pull in extra details as needed, ultimately to give the LLM guardrails
|
||||
with artifacts. Measure twice, implement once!
|
||||
@@ -635,6 +649,7 @@ on GitHub.
|
||||
- **Announcement:**
|
||||
[https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/)
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **Arize:** Seamlessly instrument AI applications with Arize AX and grant
|
||||
direct access to Arize support:
|
||||
|
||||
@@ -674,6 +689,7 @@ on GitHub.
|
||||

|
||||
|
||||
- **🎉 New partner extensions:**
|
||||
|
||||
- **🤗 Hugging Face extension:** Access the Hugging Face hub.
|
||||
([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg))
|
||||
|
||||
|
||||
+18
-49
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.45.0
|
||||
# Latest stable release: v0.50.0
|
||||
|
||||
Released: June 03, 2026
|
||||
Released: July 08, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,55 +11,24 @@ 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.
|
||||
- **Tool Registry Discovery:** Introduced tool registry discovery capabilities,
|
||||
enabling automatic detection and registration of tools to improve
|
||||
extensibility.
|
||||
- **Release Verification Improvements:** Enhanced release verification by
|
||||
ignoring scripts during `npm ci` and preventing workspace binary shadowing.
|
||||
- **CI Pipeline Safeguards:** Strengthened the CI pipeline to prevent bad NPM
|
||||
releases and ensure promote job failures are correctly surfaced.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be 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
|
||||
@gemini-cli-robot in
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
- fix/verify release npm ci ignore scripts by @rmedranollamas in
|
||||
[#28116](https://github.com/google-gemini/gemini-cli/pull/28116)
|
||||
- fix(ci): prevent workspace binary shadowing in release verification by
|
||||
@galdawave in [#28132](https://github.com/google-gemini/gemini-cli/pull/28132)
|
||||
- Feat/tool registry discovery by @ved015 in
|
||||
[#28113](https://github.com/google-gemini/gemini-cli/pull/28113)
|
||||
- fix(ci): prevent bad NPM releases and promote job crashes by @galdawave in
|
||||
[#28147](https://github.com/google-gemini/gemini-cli/pull/28147)
|
||||
|
||||
**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.49.0...v0.50.0
|
||||
|
||||
+47
-28
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.46.0-preview.0
|
||||
# Preview release: v0.51.0-preview.0
|
||||
|
||||
Released: June 3, 2026
|
||||
Released: July 8, 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,34 +13,53 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Model Update:** Added support for transitioning to the Flash GA model when
|
||||
the experimental flag is enabled, providing access to the latest model
|
||||
improvements.
|
||||
- **Improved Stability:** Hardened PTY resize logic to prevent native crashes,
|
||||
ensuring a more robust terminal experience.
|
||||
- **Bug Fix:** Resolved an issue where an invalid `preferredEditor`
|
||||
configuration could lead to a notification spam loop.
|
||||
- **CI Enhancements:** Optimized Pull Request labeling and introduced batch
|
||||
workflows to improve development efficiency.
|
||||
- **Caretaker Cloud Run Services**: Implemented a Cloud Run webhook ingestion
|
||||
service and egress service skeleton to support advanced caretaker features.
|
||||
- **Enhanced Security & Sandbox Hardening**: Enforced a case-insensitive
|
||||
sensitive path blocklist and VS Code human-in-the-loop (HITL) checks, resolved
|
||||
a directory escape vulnerability in the memory import processor, and marked
|
||||
`~/.gitconfig` as read-only within the macOS sandbox.
|
||||
- **Improved Thought Leakage and Escape Handling**: Resolved potential thought
|
||||
leakage by stripping thinking/thought processes from scrubbed history turns,
|
||||
and ensured escape sequences in string literals are correctly preserved for
|
||||
modern models.
|
||||
- **Robust Path & API Updates**: Enhanced defensive path resolution for
|
||||
at-reference files, and updated the Vertex AI base URL configuration to
|
||||
support the latest API updates.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(core): harden PTY resize against native crashes by @scidomino in
|
||||
[#27496](https://github.com/google-gemini/gemini-cli/pull/27496)
|
||||
- Changelog for v0.45.0-preview.0 by @gemini-cli-robot in
|
||||
[#27495](https://github.com/google-gemini/gemini-cli/pull/27495)
|
||||
- Changelog for v0.44.0 by @gemini-cli-robot in
|
||||
[#27569](https://github.com/google-gemini/gemini-cli/pull/27569)
|
||||
- fix(cli): prevent spam loop when preferredEditor is invalid by @Niralisj in
|
||||
[#25324](https://github.com/google-gemini/gemini-cli/pull/25324)
|
||||
- Adding quote by @scidomino in
|
||||
[#27571](https://github.com/google-gemini/gemini-cli/pull/27571)
|
||||
- Transition to flash GA model when experiment flag is present. by @DavidAPierce
|
||||
in [#27570](https://github.com/google-gemini/gemini-cli/pull/27570)
|
||||
- chore(ci): add optimized PR size labeler and batch workflows by @sripasg in
|
||||
[#27616](https://github.com/google-gemini/gemini-cli/pull/27616)
|
||||
- fix(ci): use pull_request_target trigger to grant write access on fork PRs by
|
||||
@sripasg in [#27637](https://github.com/google-gemini/gemini-cli/pull/27637)
|
||||
- Changelog for v0.50.0-preview.1 by @gemini-cli-robot in
|
||||
[#28150](https://github.com/google-gemini/gemini-cli/pull/28150)
|
||||
- Fix no_proxy test by @jerrylin3321 in
|
||||
[#28131](https://github.com/google-gemini/gemini-cli/pull/28131)
|
||||
- chore(release): bump version to 0.51.0-nightly.20260625.g3fbf93e26 by
|
||||
@gemini-cli-robot in
|
||||
[#28151](https://github.com/google-gemini/gemini-cli/pull/28151)
|
||||
- Vertex base url update by @DavidAPierce in
|
||||
[#28145](https://github.com/google-gemini/gemini-cli/pull/28145)
|
||||
- fix(security): enforce case-insensitive sensitive path blocklist and vscode
|
||||
hitl by @luisfelipe-alt in
|
||||
[#27966](https://github.com/google-gemini/gemini-cli/pull/27966)
|
||||
- fix(core-tools): resolve defensive path resolution for at-reference files and
|
||||
fix macOS tests by @luisfelipe-alt in
|
||||
[#28053](https://github.com/google-gemini/gemini-cli/pull/28053)
|
||||
- feat(caretaker): implement Cloud Run webhook ingestion service by @chadd28 in
|
||||
[#28015](https://github.com/google-gemini/gemini-cli/pull/28015)
|
||||
- fix(core): resolve symbolic link directory escape in memory import processor
|
||||
by @luisfelipe-alt in
|
||||
[#28233](https://github.com/google-gemini/gemini-cli/pull/28233)
|
||||
- feat(caretaker): egress cloud run service skeleton by @chadd28 in
|
||||
[#28167](https://github.com/google-gemini/gemini-cli/pull/28167)
|
||||
- fix(sandbox): make ~/.gitconfig read-only in the macOS sandbox by
|
||||
@ompatel-aiml in
|
||||
[#28221](https://github.com/google-gemini/gemini-cli/pull/28221)
|
||||
- fix(core): preserve escape sequences in string literals for modern models by
|
||||
@luisfelipe-alt in
|
||||
[#28299](https://github.com/google-gemini/gemini-cli/pull/28299)
|
||||
- fix(core): strip thoughts from scrubbed history turns and resolve thought
|
||||
leakage by @amelidev in
|
||||
[#27971](https://github.com/google-gemini/gemini-cli/pull/27971)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.45.0-preview.1...v0.46.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.50.0-preview.1...v0.51.0-preview.0
|
||||
|
||||
@@ -16,10 +16,12 @@ sends them to the model with every prompt. The CLI loads files in the following
|
||||
order:
|
||||
|
||||
1. **Global context file:**
|
||||
|
||||
- **Location:** `~/.gemini/GEMINI.md` (in your user home directory).
|
||||
- **Scope:** Provides default instructions for all your projects.
|
||||
|
||||
2. **Environment and workspace context files:**
|
||||
|
||||
- **Location:** The CLI searches for `GEMINI.md` files in your configured
|
||||
workspace directories and their parent directories.
|
||||
- **Scope:** Provides context relevant to the projects you are currently
|
||||
|
||||
@@ -64,6 +64,7 @@ Gemini CLI takes action.
|
||||
reach an informal agreement on the approach before proceeding.
|
||||
3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates
|
||||
a detailed implementation plan as a Markdown file in your plans directory.
|
||||
|
||||
- **View:** You can open and read this file to understand the proposed
|
||||
changes.
|
||||
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
|
||||
|
||||
+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):
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ becoming too large and expensive.
|
||||
exchanges) allowed in a single session. Set to `-1` for unlimited (default).
|
||||
|
||||
**Behavior when limit is reached:**
|
||||
|
||||
- **Interactive mode:** The CLI shows an informational message and stops
|
||||
sending requests to the model. You must manually start a new session.
|
||||
- **Non-interactive mode:** The CLI exits with an error.
|
||||
|
||||
@@ -27,11 +27,13 @@ via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.mdx#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
- The CLI reads `./.gemini/system.md` (relative to your current project
|
||||
directory).
|
||||
|
||||
- Use a custom file path:
|
||||
|
||||
- `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md`
|
||||
- Relative paths are supported and resolved from the current working
|
||||
directory.
|
||||
|
||||
@@ -64,6 +64,7 @@ and Cloud Logging.
|
||||
You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
|
||||
1. Set your Google Cloud project ID:
|
||||
|
||||
- To send telemetry to a separate project:
|
||||
|
||||
**macOS/Linux**
|
||||
@@ -93,8 +94,10 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```
|
||||
|
||||
2. Authenticate with Google Cloud using one of these methods:
|
||||
|
||||
- **Method A: Application Default Credentials (ADC)**: Use this method for
|
||||
service accounts or standard `gcloud` authentication.
|
||||
|
||||
- For user accounts:
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
@@ -112,6 +115,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
```powershell
|
||||
$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json"
|
||||
```
|
||||
|
||||
* **Method B: CLI Auth** (Direct export only): Simplest method for local
|
||||
users. Gemini CLI uses the same OAuth credentials you used for login. To
|
||||
enable this, set `useCliAuth: true` in your `.gemini/settings.json`:
|
||||
@@ -133,6 +137,7 @@ You must complete several setup steps before enabling Google Cloud telemetry.
|
||||
> telemetry will be disabled.
|
||||
|
||||
3. Ensure your account or service account has these IAM roles:
|
||||
|
||||
- Cloud Trace Agent
|
||||
- Monitoring Metric Writer
|
||||
- Logs Writer
|
||||
|
||||
@@ -56,6 +56,7 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
@@ -187,6 +188,7 @@ The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
- **Response (`CallToolResult`):** The tool **MUST** immediately return a
|
||||
`CallToolResult` to acknowledge the request and report whether the diff view
|
||||
was successfully opened.
|
||||
|
||||
- On Success: If the diff view was opened successfully, the response **MUST**
|
||||
contain empty content (that is, `content: []`).
|
||||
- On Failure: If an error prevented the diff view from opening, the response
|
||||
|
||||
@@ -27,6 +27,7 @@ AI-generated code changes directly within your editor.
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
|
||||
- The **10 most recently accessed files** in your workspace.
|
||||
- Your active cursor position.
|
||||
- Any text you have selected (up to a 16KB limit; longer selections will be
|
||||
@@ -228,6 +229,7 @@ If you are using Gemini CLI within a sandbox, be aware of the following:
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
|
||||
- **Cause:** Gemini CLI could not find the necessary environment variables
|
||||
(`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect
|
||||
to the IDE. This usually means the IDE companion extension is not running or
|
||||
@@ -270,6 +272,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
|
||||
- **Cause:** The CLI's current working directory is outside the workspace you
|
||||
have open in your IDE.
|
||||
- **Solution:** `cd` into the same directory that is open in your IDE and
|
||||
@@ -284,6 +287,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is
|
||||
not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
|
||||
|
||||
@@ -59,6 +59,7 @@ You can view traces in the Jaeger UI for local development.
|
||||
|
||||
This command configures your workspace for local telemetry and provides a
|
||||
link to the Jaeger UI (usually `http://localhost:16686`).
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector.log`
|
||||
|
||||
2. **Run Gemini CLI:**
|
||||
@@ -108,6 +109,7 @@ Trace for custom processing or routing.
|
||||
|
||||
The script outputs links to view traces, metrics, and logs in the Google
|
||||
Cloud Console.
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
|
||||
|
||||
3. **Run Gemini CLI:**
|
||||
|
||||
@@ -506,6 +506,7 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
@@ -519,6 +520,7 @@ At commands are used to include the content of files or directories as part of
|
||||
your prompt to Gemini. These commands include git-aware filtering.
|
||||
|
||||
- **`@<path_to_file_or_directory>`**
|
||||
|
||||
- **Description:** Inject the content of the specified file or files into your
|
||||
current prompt. This is useful for asking questions about specific code,
|
||||
text, or collections of files.
|
||||
@@ -565,6 +567,7 @@ The `!` prefix lets you interact with your system's shell directly from within
|
||||
Gemini CLI.
|
||||
|
||||
- **`!<shell_command>`**
|
||||
|
||||
- **Description:** Execute the given `<shell_command>` using `bash` on
|
||||
Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
|
||||
override `ComSpec`). Any output or errors from the command are displayed in
|
||||
@@ -574,6 +577,7 @@ Gemini CLI.
|
||||
- `!git status` (executes `git status` and returns to Gemini CLI)
|
||||
|
||||
- **`!` (Toggle shell mode)**
|
||||
|
||||
- **Description:** Typing `!` on its own toggles shell mode.
|
||||
- **Entering shell mode:**
|
||||
- When active, shell mode uses a different coloring and a "Shell Mode
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ Before promoting a `preview` release to `stable`, a release manager must
|
||||
manually run through this checklist.
|
||||
|
||||
- **Setup:**
|
||||
|
||||
- [ ] Uninstall any existing global version:
|
||||
`npm uninstall -g @google/gemini-cli`
|
||||
- [ ] Clear npx cache (optional but recommended): `npm cache clean --force`
|
||||
@@ -77,24 +78,29 @@ manually run through this checklist.
|
||||
- [ ] Verify version: `gemini --version`
|
||||
|
||||
- **Authentication:**
|
||||
|
||||
- [ ] In interactive mode run `/auth` and verify all sign in flows work:
|
||||
- [ ] Sign in with Google
|
||||
- [ ] API Key
|
||||
- [ ] Vertex AI
|
||||
|
||||
- **Basic prompting:**
|
||||
|
||||
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
|
||||
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
|
||||
context.
|
||||
|
||||
- **Piped input:**
|
||||
|
||||
- [ ] Run `echo "Summarize this" | gemini` and verify it processes stdin.
|
||||
|
||||
- **Context management:**
|
||||
|
||||
- [ ] In interactive mode, use `@file` to add a local file to context. Ask a
|
||||
question about it.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
- [ ] In interactive mode run `/settings` and make modifications
|
||||
- [ ] Validate that setting is changed
|
||||
|
||||
|
||||
@@ -475,6 +475,7 @@ This stage happens _after_ the NPM publish and creates the single-file
|
||||
executable that enables `npx` usage directly from the GitHub repository.
|
||||
|
||||
1. **The JavaScript bundle is created:**
|
||||
|
||||
- **What happens:** The built JavaScript from both `packages/core/dist` and
|
||||
`packages/cli/dist`, along with all third-party JavaScript dependencies,
|
||||
are bundled by `esbuild` into a single, executable JavaScript file (for
|
||||
@@ -486,6 +487,7 @@ executable that enables `npx` usage directly from the GitHub repository.
|
||||
the `core` package) are included directly.
|
||||
|
||||
2. **The `bundle` directory is assembled:**
|
||||
|
||||
- **What happens:** A temporary `bundle` folder is created at the project
|
||||
root. The single `gemini.js` executable is placed inside it, along with
|
||||
other essential files.
|
||||
|
||||
@@ -127,6 +127,7 @@ Standard/Plus and AI Expanded, are not supported._
|
||||
license seats. For predictable costs, you can sign in with Google.
|
||||
|
||||
This includes the following request limits:
|
||||
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 maximum model requests / user / day
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
|
||||
@@ -12,6 +12,7 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`**
|
||||
|
||||
- **Cause:** This error might occur if Gemini CLI detects the
|
||||
`GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is
|
||||
defined. Setting these variables forces an organization subscription check.
|
||||
@@ -19,6 +20,7 @@ topics on:
|
||||
linked to an organizational subscription.
|
||||
|
||||
- **Solution:**
|
||||
|
||||
- **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and
|
||||
`GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these
|
||||
variables from your shell configuration files (for example, `.bashrc`,
|
||||
@@ -30,12 +32,14 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`**
|
||||
|
||||
- **Cause:** Gemini CLI does not currently support your location. For a full
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
|
||||
- **Error: `Failed to sign in. Message: Request contains an invalid argument`**
|
||||
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
@@ -66,6 +70,7 @@ topics on:
|
||||
## Common error messages and solutions
|
||||
|
||||
- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.**
|
||||
|
||||
- **Cause:** Another process is already using the port that the MCP server is
|
||||
trying to bind to.
|
||||
- **Solution:** Either stop the other process that is using the port or
|
||||
@@ -73,6 +78,7 @@ topics on:
|
||||
|
||||
- **Error: Command not found (when attempting to run Gemini CLI with
|
||||
`gemini`).**
|
||||
|
||||
- **Cause:** Gemini CLI is not correctly installed or it is not in your
|
||||
system's `PATH`.
|
||||
- **Solution:** The update depends on how you installed Gemini CLI:
|
||||
@@ -85,6 +91,7 @@ topics on:
|
||||
then rebuild using the command `npm run build`.
|
||||
|
||||
- **Error: `MODULE_NOT_FOUND` or import errors.**
|
||||
|
||||
- **Cause:** Dependencies are not installed correctly, or the project hasn't
|
||||
been built.
|
||||
- **Solution:**
|
||||
@@ -93,6 +100,7 @@ topics on:
|
||||
3. Verify that the build completed successfully with `npm run start`.
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
|
||||
that are restricted by your sandbox configuration, such as writing outside
|
||||
the project directory or system temp directory.
|
||||
@@ -101,6 +109,7 @@ topics on:
|
||||
configuration.
|
||||
|
||||
- **Gemini CLI is not running in interactive mode in "CI" environments**
|
||||
|
||||
- **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if
|
||||
an environment variable starting with `CI_` (for example, `CI_TOKEN`) is
|
||||
set. This is because the `is-in-ci` package, used by the underlying UI
|
||||
@@ -116,6 +125,7 @@ topics on:
|
||||
`env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable
|
||||
debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded
|
||||
@@ -155,12 +165,14 @@ is especially useful for scripting and automation.
|
||||
## Debugging tips
|
||||
|
||||
- **CLI debugging:**
|
||||
|
||||
- Use the `--debug` flag for more detailed output. In interactive mode, press
|
||||
F12 to view the debug console.
|
||||
- Check the CLI logs, often found in a user-specific configuration or cache
|
||||
directory.
|
||||
|
||||
- **Core debugging:**
|
||||
|
||||
- Check the server console output for error messages or stack traces.
|
||||
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
|
||||
environment variable to `true` or `1`.
|
||||
@@ -168,6 +180,7 @@ is especially useful for scripting and automation.
|
||||
step through server-side code.
|
||||
|
||||
- **Tool issues:**
|
||||
|
||||
- If a specific tool is failing, try to isolate the issue by running the
|
||||
simplest possible version of the command or operation the tool performs.
|
||||
- For `run_shell_command`, check that the command works directly in your shell
|
||||
|
||||
@@ -11,6 +11,7 @@ confirmation.
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
@@ -30,6 +31,7 @@ confirmation.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
@@ -768,6 +768,7 @@ defaults:
|
||||
|
||||
- **Tool lists:** Tool lists are merged securely to ensure the most restrictive
|
||||
policy wins:
|
||||
|
||||
- **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either
|
||||
source blocks a tool, it remains disabled.
|
||||
- **Inclusions (`includeTools`):** Arrays are intersected. If both sources
|
||||
|
||||
+11
-3
@@ -56,6 +56,7 @@ export default tseslint.config(
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'tools/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
@@ -80,8 +81,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules for packages/*/src (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
// Rules for packages/*/src and tools/caretaker-agent (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}', 'tools/caretaker-agent/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -284,7 +285,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -410,6 +411,13 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Allow console logging for backend services (Cloud Logging)
|
||||
{
|
||||
files: ['tools/**/*.ts', 'tools/**/*.test.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\n \"reasoning\": \"Simple task.\",\n \"model_choice\": \"flash\"\n}"}]},"finishReason":"STOP","index":0}]}}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}}
|
||||
@@ -23,6 +23,7 @@ describe('Parallel Tool Execution Integration', () => {
|
||||
it('should execute [read, read, write, read, read] in correct waves with user approval', async () => {
|
||||
rig.setup('parallel-wave-execution', {
|
||||
fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'),
|
||||
fakeResponsesNonStrict: true,
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['read_file', 'write_file'],
|
||||
@@ -40,19 +41,24 @@ describe('Parallel Tool Execution Integration', () => {
|
||||
|
||||
const run = await rig.runInteractive({ approvalMode: 'default' });
|
||||
|
||||
// 1. Trigger the wave
|
||||
await run.type('ok');
|
||||
await run.type('\r');
|
||||
try {
|
||||
// 1. Trigger the wave
|
||||
await run.type('ok');
|
||||
await run.type('\r');
|
||||
|
||||
// 3. Wait for the write_file prompt.
|
||||
await run.expectText('Allow', 5000);
|
||||
// 3. Wait for the write_file prompt.
|
||||
await run.expectText('Allow', 10000);
|
||||
|
||||
// 4. Press Enter to approve the write_file.
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
// 4. Press Enter to approve the write_file.
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
// 5. Wait for the final model response
|
||||
await run.expectText('All waves completed successfully.', 5000);
|
||||
// 5. Wait for the final model response
|
||||
await run.expectText('All waves completed successfully.', 10000);
|
||||
} catch (err) {
|
||||
fs.writeFileSync('pty_output_failure.txt', run.output);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Verify all tool calls were made and succeeded in the logs
|
||||
await rig.expectToolCallSuccess(['write_file']);
|
||||
@@ -73,5 +79,5 @@ describe('Parallel Tool Execution Integration', () => {
|
||||
expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe(
|
||||
'wave2',
|
||||
);
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
Generated
+2622
-999
File diff suppressed because it is too large
Load Diff
+66
-60
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.48.0-nightly.20260609.g3a13b8eeb",
|
||||
"version": "0.52.0-nightly.20260715.gfa975395b",
|
||||
"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.48.0-nightly.20260609.g3a13b8eeb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0-nightly.20260715.gfa975395b"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -32,6 +32,9 @@
|
||||
"schema:settings": "tsx ./scripts/generate-settings-schema.ts",
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
|
||||
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
|
||||
"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",
|
||||
@@ -78,10 +81,10 @@
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
},
|
||||
"glob": "^12.0.0",
|
||||
"glob": "12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
"cross-spawn": "7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -92,73 +95,76 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"read-package-up": "^11.0.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"asciichart": "^1.5.25",
|
||||
"cross-env": "^7.0.3",
|
||||
"depcheck": "^1.4.7",
|
||||
"domexception": "^4.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-wasm": "^1.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-headers": "^1.3.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^12.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"mock-fs": "^5.5.0",
|
||||
"msw": "^2.10.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.5.3",
|
||||
"react-devtools-core": "^6.1.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"semver": "^7.7.2",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"ts-prune": "^0.10.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
"@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",
|
||||
"@types/mock-fs": "4.13.4",
|
||||
"@types/prompts": "2.4.9",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"@types/shell-quote": "1.7.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/eslint-plugin": "1.3.4",
|
||||
"asciichart": "1.5.25",
|
||||
"cross-env": "7.0.3",
|
||||
"depcheck": "1.4.7",
|
||||
"domexception": "4.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-wasm": "1.1.0",
|
||||
"eslint": "9.24.0",
|
||||
"eslint-config-prettier": "10.1.2",
|
||||
"eslint-plugin-headers": "1.3.3",
|
||||
"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",
|
||||
"husky": "9.1.7",
|
||||
"json": "11.0.0",
|
||||
"lint-staged": "16.1.6",
|
||||
"memfs": "4.42.0",
|
||||
"mnemonist": "0.40.3",
|
||||
"mock-fs": "5.5.0",
|
||||
"msw": "2.10.4",
|
||||
"npm-run-all": "4.1.5",
|
||||
"prettier": "3.5.3",
|
||||
"react-devtools-core": "6.1.2",
|
||||
"react-dom": "19.2.4",
|
||||
"semver": "7.7.2",
|
||||
"strip-ansi": "7.1.2",
|
||||
"ts-prune": "0.10.3",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.30.1",
|
||||
"vitest": "3.2.4",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"punycode": "^2.3.1",
|
||||
"simple-git": "^3.28.0"
|
||||
"latest-version": "9.0.0",
|
||||
"node-fetch-native": "1.6.7",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"punycode": "2.3.1",
|
||||
"simple-git": "3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@github/keytar": "7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"node-pty": "^1.0.0"
|
||||
"node-pty": "1.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.48.0-nightly.20260609.g3a13b8eeb",
|
||||
"version": "0.52.0-nightly.20260715.gfa975395b",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,25 +26,25 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@google-cloud/storage": "7.19.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.8",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.17.0"
|
||||
"express": "5.1.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"strip-json-comments": "3.1.1",
|
||||
"tar": "7.5.8",
|
||||
"uuid": "13.0.0",
|
||||
"winston": "3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/tar": "^6.1.13",
|
||||
"dotenv": "^16.4.5",
|
||||
"supertest": "^7.1.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
"@types/express": "5.0.3",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/tar": "6.1.13",
|
||||
"dotenv": "16.4.5",
|
||||
"supertest": "7.1.4",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -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
@@ -1092,19 +1092,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();
|
||||
});
|
||||
});
|
||||
@@ -157,14 +157,17 @@ export function loadSettings(
|
||||
|
||||
function resolveEnvVarsInString(value: string): string {
|
||||
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
|
||||
return value.replace(envVarRegex, (match, varName1, varName2) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const varName = varName1 || varName2;
|
||||
if (process && process.env && typeof process.env[varName] === 'string') {
|
||||
return process.env[varName];
|
||||
}
|
||||
return match;
|
||||
});
|
||||
return value.replace(
|
||||
envVarRegex,
|
||||
(match: string, varName1: string, varName2: string) => {
|
||||
const varName = varName1 || varName2;
|
||||
const envValue = process?.env?.[varName];
|
||||
if (typeof envValue === 'string') {
|
||||
return envValue;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+50
-50
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.48.0-nightly.20260609.g3a13b8eeb",
|
||||
"version": "0.52.0-nightly.20260715.gfa975395b",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,63 +27,63 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.48.0-nightly.20260609.g3a13b8eeb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0-nightly.20260715.gfa975395b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "~5.2.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"ansi-escapes": "7.3.0",
|
||||
"ansi-regex": "6.2.2",
|
||||
"chalk": "4.1.2",
|
||||
"cli-spinners": "2.9.2",
|
||||
"clipboardy": "5.2.0",
|
||||
"color-convert": "2.0.1",
|
||||
"command-exists": "1.2.9",
|
||||
"comment-json": "4.2.5",
|
||||
"diff": "8.0.3",
|
||||
"dotenv": "17.1.0",
|
||||
"extract-zip": "2.0.1",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"open": "^10.1.2",
|
||||
"prompts": "^2.4.2",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"string-width": "^8.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.8",
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
"lowlight": "3.3.0",
|
||||
"mnemonist": "0.40.3",
|
||||
"open": "10.1.2",
|
||||
"prompts": "2.4.2",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"react": "19.2.4",
|
||||
"shell-quote": "1.8.3",
|
||||
"simple-git": "3.28.0",
|
||||
"string-width": "8.1.0",
|
||||
"strip-ansi": "7.1.0",
|
||||
"strip-json-comments": "3.1.1",
|
||||
"tar": "7.5.8",
|
||||
"tinygradient": "1.1.5",
|
||||
"undici": "7.10.0",
|
||||
"ws": "8.16.0",
|
||||
"yargs": "17.7.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
"@types/command-exists": "1.2.3",
|
||||
"@types/hast": "3.0.4",
|
||||
"@types/node": "20.11.24",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/shell-quote": "1.7.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"@types/yargs": "17.0.32",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -278,7 +278,8 @@ describe('Session', () => {
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* [] as any;
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
@@ -303,7 +304,8 @@ describe('Session', () => {
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* [] as any;
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
@@ -473,7 +475,8 @@ describe('Session', () => {
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* [] as any;
|
||||
throw customError;
|
||||
}
|
||||
return errorGen();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"zod": "^3.22.4"
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ExtensionEnablementManager', () => {
|
||||
vi.spyOn(fs, 'writeFileSync').mockImplementation(
|
||||
(
|
||||
path: fs.PathOrFileDescriptor,
|
||||
data: string | ArrayBufferView<ArrayBufferLike>,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
) => {
|
||||
inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs
|
||||
},
|
||||
|
||||
@@ -156,25 +156,26 @@ export async function updateAllUpdatableExtensions(
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
enableExtensionReloading?: boolean,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name)?.status ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
extensionManager,
|
||||
extensionsState.get(extension.name)!.status,
|
||||
dispatch,
|
||||
enableExtensionReloading,
|
||||
),
|
||||
const results = await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name)?.status ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
extensionManager,
|
||||
extensionsState.get(extension.name)!.status,
|
||||
dispatch,
|
||||
enableExtensionReloading,
|
||||
),
|
||||
)
|
||||
).filter((updateInfo) => !!updateInfo);
|
||||
),
|
||||
);
|
||||
return results.filter(
|
||||
(updateInfo): updateInfo is ExtensionUpdateInfo => !!updateInfo,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
|
||||
@@ -52,9 +52,10 @@ export function validateVariables(
|
||||
export function hydrateString(str: string, context: VariableContext): string {
|
||||
validateVariables(context, VARIABLE_SCHEMA);
|
||||
const regex = /\${(.*?)}/g;
|
||||
return str.replace(regex, (match, key) =>
|
||||
context[key] == null ? match : context[key],
|
||||
);
|
||||
return str.replace(regex, (match, key) => {
|
||||
const val = context[key];
|
||||
return val == null ? match : String(val);
|
||||
});
|
||||
}
|
||||
|
||||
export function recursivelyHydrateStrings<T>(
|
||||
|
||||
@@ -657,7 +657,7 @@ export async function main() {
|
||||
// Register SessionEnd hook to fire on graceful exit
|
||||
// This runs before telemetry shutdown in runExitCleanup()
|
||||
registerCleanup(async () => {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
// Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
|
||||
|
||||
@@ -158,16 +158,15 @@ export class McpPromptLoader implements ICommandLoader {
|
||||
return [];
|
||||
}
|
||||
const indexOfFirstSpace = invocation.raw.indexOf(' ') + 1;
|
||||
let promptInputs =
|
||||
const parsedInputs =
|
||||
indexOfFirstSpace === 0
|
||||
? {}
|
||||
: this.parseArgs(
|
||||
invocation.raw.substring(indexOfFirstSpace),
|
||||
prompt.arguments,
|
||||
);
|
||||
if (promptInputs instanceof Error) {
|
||||
promptInputs = {};
|
||||
}
|
||||
const promptInputs =
|
||||
parsedInputs instanceof Error ? {} : parsedInputs;
|
||||
|
||||
const providedArgNames = Object.keys(promptInputs);
|
||||
const unusedArguments =
|
||||
|
||||
@@ -706,13 +706,13 @@ export const renderWithProviders = async (
|
||||
|
||||
const terminalWidth = width ?? baseState.terminalWidth;
|
||||
|
||||
if (!config) {
|
||||
config = makeFakeConfig({
|
||||
const finalConfig =
|
||||
config ||
|
||||
makeFakeConfig({
|
||||
useAlternateBuffer: settings.merged.ui?.useAlternateBuffer,
|
||||
showMemoryUsage: settings.merged.ui?.showMemoryUsage,
|
||||
accessibility: settings.merged.ui?.accessibility,
|
||||
});
|
||||
}
|
||||
|
||||
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
|
||||
|
||||
@@ -742,21 +742,23 @@ export const renderWithProviders = async (
|
||||
|
||||
const wrapWithProviders = (comp: React.ReactElement) => (
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<ConfigContext.Provider value={finalConfig}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<SessionStatsProvider
|
||||
sessionId={finalConfig.getSessionId()}
|
||||
>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
config={finalConfig}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
|
||||
@@ -491,12 +491,12 @@ describe('AppContainer State Management', () => {
|
||||
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false);
|
||||
|
||||
mockExtensionManager = vi.mockObject({
|
||||
mockExtensionManager = {
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
setRequestConsent: vi.fn(),
|
||||
setRequestSetting: vi.fn(),
|
||||
start: vi.fn(),
|
||||
} as unknown as ExtensionManager);
|
||||
} as unknown as MockedObject<ExtensionManager>;
|
||||
vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function AuthDialog({
|
||||
);
|
||||
}
|
||||
|
||||
let defaultAuthType = null;
|
||||
let defaultAuthType: AuthType | null = null;
|
||||
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthTypeEnv &&
|
||||
|
||||
@@ -439,7 +439,8 @@ describe('extensionsCommand', () => {
|
||||
}
|
||||
|
||||
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
|
||||
@@ -455,7 +456,8 @@ describe('extensionsCommand', () => {
|
||||
});
|
||||
|
||||
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry = true;
|
||||
mockContext.services.settings.merged.experimental.extensionRegistry =
|
||||
true;
|
||||
|
||||
const result = await exploreAction(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
|
||||
@@ -284,7 +284,8 @@ const listAction = async (
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: mcpServers,
|
||||
tools: mcpTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
serverName: (tool as unknown as { serverName: string }).serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
|
||||
@@ -30,7 +30,11 @@ const HistoryItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ToolCallDataSchema = getToolCallDataSchema(HistoryItemSchema);
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const ToolCallDataSchema = getToolCallDataSchema(
|
||||
HistoryItemSchema as unknown as Parameters<typeof getToolCallDataSchema>[0],
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
async function restoreAction(
|
||||
context: CommandContext,
|
||||
|
||||
@@ -126,10 +126,13 @@ async function downloadFiles({
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(30_000),
|
||||
abortController.signal,
|
||||
]),
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
signal: (
|
||||
AbortSignal as unknown as {
|
||||
any: (signals: AbortSignal[]) => AbortSignal;
|
||||
}
|
||||
).any([AbortSignal.timeout(30_000), abortController.signal]),
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
} as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -128,7 +128,7 @@ function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else if (isRecord(current[key])) {
|
||||
current[key] = { ...current[key] };
|
||||
current[key] = { ...(current[key] as object) };
|
||||
}
|
||||
|
||||
const next = current[key];
|
||||
|
||||
@@ -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();
|
||||
@@ -1427,7 +1429,8 @@ describe('handleAtCommand', () => {
|
||||
const query = `@${filePath}`;
|
||||
|
||||
// Simulate user cancellation
|
||||
const mockToolInstance = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockToolInstance: any = {
|
||||
buildAndExecute: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('User cancelled operation')),
|
||||
@@ -1466,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';
|
||||
@@ -1648,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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -44,8 +44,11 @@ export function resolveEnvVarsInString(
|
||||
if (customEnv && typeof customEnv[varName] === 'string') {
|
||||
return customEnv[varName];
|
||||
}
|
||||
if (process && process.env && typeof process.env[varName] === 'string') {
|
||||
return process.env[varName];
|
||||
if (process && process.env) {
|
||||
const val = process.env[varName];
|
||||
if (typeof val === 'string') {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue;
|
||||
|
||||
@@ -69,7 +69,13 @@ export const getLatestGitHubRelease = async (
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
signal: AbortSignal.any([AbortSignal.timeout(30_000), controller.signal]),
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
signal: (
|
||||
AbortSignal as unknown as {
|
||||
any: (signals: AbortSignal[]) => AbortSignal;
|
||||
}
|
||||
).any([AbortSignal.timeout(30_000), controller.signal]),
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
} as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -805,7 +805,7 @@ export async function start_sandbox(
|
||||
});
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
sandboxProcess.on('error', (err) => {
|
||||
sandboxProcess?.on('error', (err) => {
|
||||
coreEvents.emitFeedback('error', 'Sandbox process error', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
+76
-76
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.48.0-nightly.20260609.g3a13b8eeb",
|
||||
"version": "0.52.0-nightly.20260715.gfa975395b",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -24,95 +24,95 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||
"@bufbuild/protobuf": "2.11.0",
|
||||
"@google-cloud/logging": "11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "0.21.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "3.0.0",
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.218.0",
|
||||
"@opentelemetry/core": "^2.7.1",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "^0.218.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-logs": "^0.218.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@grpc/grpc-js": "1.14.3",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/api-logs": "0.218.0",
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/instrumentation-http": "0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.218.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-logs": "0.218.0",
|
||||
"@opentelemetry/sdk-metrics": "2.7.1",
|
||||
"@opentelemetry/sdk-node": "0.218.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.39.0",
|
||||
"@types/html-to-text": "9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"isbinaryfile": "^5.0.7",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
"ajv": "8.17.1",
|
||||
"ajv-formats": "3.0.1",
|
||||
"chokidar": "5.0.0",
|
||||
"command-exists": "1.2.9",
|
||||
"diff": "8.0.3",
|
||||
"dotenv": "17.2.4",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"execa": "9.6.1",
|
||||
"fast-levenshtein": "2.0.6",
|
||||
"fdir": "6.4.6",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.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",
|
||||
"ignore": "7.0.0",
|
||||
"ipaddr.js": "1.9.1",
|
||||
"isbinaryfile": "5.0.7",
|
||||
"js-yaml": "4.1.1",
|
||||
"json-stable-stringify": "1.3.0",
|
||||
"marked": "15.0.12",
|
||||
"mime": "4.0.7",
|
||||
"mnemonist": "^0.40.3",
|
||||
"open": "^10.1.2",
|
||||
"picomatch": "^4.0.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"puppeteer-core": "^24.0.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
"uuid": "^13.0.0",
|
||||
"web-tree-sitter": "^0.25.10",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
"mnemonist": "0.40.3",
|
||||
"open": "10.1.2",
|
||||
"picomatch": "4.0.1",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"puppeteer-core": "24.0.0",
|
||||
"read-package-up": "11.0.0",
|
||||
"shell-quote": "1.8.3",
|
||||
"simple-git": "3.28.0",
|
||||
"strip-ansi": "7.1.0",
|
||||
"strip-json-comments": "3.1.1",
|
||||
"systeminformation": "5.25.11",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"undici": "7.10.0",
|
||||
"uuid": "13.0.0",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"zod": "3.25.76",
|
||||
"zod-to-json-schema": "3.25.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@github/keytar": "7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"node-pty": "^1.0.0"
|
||||
"node-pty": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-stable-stringify": "^1.1.0",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"chrome-devtools-mcp": "^0.19.0",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
"@types/fast-levenshtein": "0.0.4",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/json-stable-stringify": "1.1.0",
|
||||
"@types/picomatch": "4.0.1",
|
||||
"chrome-devtools-mcp": "0.19.0",
|
||||
"msw": "2.3.4",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -22,7 +22,7 @@ const DEFAULT_HEADER_NAME = 'X-API-Key';
|
||||
* - A shell command (!command)
|
||||
*/
|
||||
export class ApiKeyAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type = 'apiKey' as const;
|
||||
readonly type = 'apiKey';
|
||||
|
||||
private resolvedKey: string | undefined;
|
||||
private readonly headerName: string;
|
||||
|
||||
@@ -20,7 +20,7 @@ const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, CLOUD_RUN_HOST_REGEX];
|
||||
* based on the target endpoint URL.
|
||||
*/
|
||||
export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type = 'google-credentials' as const;
|
||||
readonly type = 'google-credentials';
|
||||
|
||||
private readonly auth: GoogleAuth;
|
||||
private readonly useIdToken: boolean = false;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { debugLogger } from '../../utils/debugLogger.js';
|
||||
* Supports Bearer, Basic, and any IANA-registered scheme via raw value.
|
||||
*/
|
||||
export class HttpAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type = 'http' as const;
|
||||
readonly type = 'http';
|
||||
|
||||
private resolvedToken?: string;
|
||||
private resolvedUsername?: string;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Storage } from '../../config/storage.js';
|
||||
* and persists tokens via `MCPOAuthTokenStorage`.
|
||||
*/
|
||||
export class OAuth2AuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type = 'oauth2' as const;
|
||||
readonly type = 'oauth2';
|
||||
|
||||
private readonly tokenStorage: MCPOAuthTokenStorage;
|
||||
private cachedToken: OAuthToken | null = null;
|
||||
|
||||
@@ -470,10 +470,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
};
|
||||
|
||||
// We monitor both the external signal and our new grace period timeout
|
||||
const combinedSignal = AbortSignal.any([
|
||||
externalSignal,
|
||||
graceTimeoutController.signal,
|
||||
]);
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const combinedSignal = (
|
||||
AbortSignal as unknown as {
|
||||
any: (signals: AbortSignal[]) => AbortSignal;
|
||||
}
|
||||
).any([externalSignal, graceTimeoutController.signal]);
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
const turnResult = await this.executeTurn(
|
||||
chat,
|
||||
@@ -593,7 +596,11 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
};
|
||||
|
||||
// Combine the external signal with the internal timeout signal.
|
||||
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const combinedSignal = (
|
||||
AbortSignal as unknown as { any: (signals: AbortSignal[]) => AbortSignal }
|
||||
).any([signal, deadlineTimer.signal]);
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
logAgentStart(
|
||||
this.context.config,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ThinkingLevel,
|
||||
type Content,
|
||||
type GenerateContentResponse,
|
||||
type Part,
|
||||
} from '@google/genai';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type StreamEvent,
|
||||
stripToolCallIdPrefixes,
|
||||
type HistoryTurn,
|
||||
coalesceConsecutiveRoles,
|
||||
} from './geminiChat.js';
|
||||
import {
|
||||
type CompletedToolCall,
|
||||
@@ -2253,6 +2255,35 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('thought leakage in getHistoryTurns', () => {
|
||||
it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => {
|
||||
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
|
||||
|
||||
chat.setHistory([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'internal monologue', thought: true } as unknown as Part,
|
||||
{ text: 'actual conversational response' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const turns = chat.getHistoryTurns(true);
|
||||
|
||||
expect(turns).toHaveLength(2);
|
||||
const modelTurn = turns[1];
|
||||
expect(modelTurn.content.parts).toHaveLength(1);
|
||||
expect(modelTurn.content.parts![0]).toEqual({
|
||||
text: 'actual conversational response',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActiveLoopHasThoughtSignatures', () => {
|
||||
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
|
||||
const chat = new GeminiChat(mockConfig, '', [], []);
|
||||
@@ -3107,4 +3138,62 @@ describe('GeminiChat', () => {
|
||||
expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coalesceConsecutiveRoles', () => {
|
||||
it('should return empty history if empty array is passed', () => {
|
||||
expect(coalesceConsecutiveRoles([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not modify history when roles alternate correctly', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
|
||||
{
|
||||
id: '3',
|
||||
content: { role: 'user', parts: [{ text: 'how are you?' }] },
|
||||
},
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual(history);
|
||||
});
|
||||
|
||||
it('should coalesce consecutive user turns', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }, { text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle undefined or missing parts gracefully', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user' } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not coalesce turns if roles are undefined', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual(history);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -683,10 +683,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
|
||||
@@ -1472,3 +1475,31 @@ 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;
|
||||
}
|
||||
|
||||
@@ -251,8 +251,11 @@ export class IdeClient {
|
||||
const textPart = parsedResultData.content.find(
|
||||
(part) => part.type === 'text',
|
||||
);
|
||||
|
||||
const errorMessage =
|
||||
textPart?.text ?? `Tool 'openDiff' reported an error.`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(textPart as { text?: string })?.text ??
|
||||
`Tool 'openDiff' reported an error.`;
|
||||
logger.debug(
|
||||
`Request for openDiff ${filePath} failed with isError:`,
|
||||
errorMessage,
|
||||
@@ -332,7 +335,7 @@ export class IdeClient {
|
||||
if (resultData.isError) {
|
||||
const textPart = resultData.content.find(
|
||||
(part) => part.type === 'text',
|
||||
);
|
||||
) as { type: 'text'; text: string } | undefined;
|
||||
const errorMessage =
|
||||
textPart?.text ?? `Tool 'closeDiff' reported an error.`;
|
||||
logger.debug(
|
||||
@@ -342,7 +345,9 @@ export class IdeClient {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textPart = resultData.content.find((part) => part.type === 'text');
|
||||
const textPart = resultData.content.find(
|
||||
(part): part is { type: 'text'; text: string } => part.type === 'text',
|
||||
);
|
||||
|
||||
if (textPart?.text) {
|
||||
try {
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -732,7 +732,7 @@ export function validateMcpPolicyToolNames(
|
||||
if (discoveredToolNames.length === 0) continue;
|
||||
|
||||
const minDistance = Math.min(
|
||||
...discoveredToolNames.map((n) => levenshtein.get(toolPart, n)),
|
||||
...discoveredToolNames.map((n) => levenshtein.get(toolPart ?? '', n)),
|
||||
);
|
||||
|
||||
if (minDistance > MAX_TYPO_DISTANCE) continue;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user