mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-12 01:46:27 -07:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f05769501 | |||
| 659c7aacd9 | |||
| 188e255bf5 | |||
| eef19f25c3 | |||
| cf22ac7e86 | |||
| 493113457b | |||
| cd5ac173cf | |||
| 1b53dfea2b | |||
| d419cb6b67 | |||
| afebb8702e | |||
| 6cb9f2e061 | |||
| 8cb94fe645 | |||
| d9b600b1c9 | |||
| 66708e3c4c | |||
| 2139b121bc | |||
| d5c9a97dc0 | |||
| 49d6b32f98 | |||
| 9f5f032b8e | |||
| 761f604c16 | |||
| 63c5b74770 | |||
| 348fc35f17 | |||
| 56f9688b30 | |||
| 6863148728 | |||
| bde504f250 | |||
| b6b41f79eb | |||
| 8b60087673 | |||
| ac42fb0a24 | |||
| f47d6c6f7a | |||
| d55e366f6a | |||
| dc859e8e48 | |||
| 4bb7e93c45 | |||
| 55a31ef909 | |||
| 3499c84f7b | |||
| d29268d360 | |||
| fccc043bd4 | |||
| c5622fec27 | |||
| e07280eb4e | |||
| bef6119500 | |||
| b94c9775b1 | |||
| 3818efbbfb | |||
| e2a5375d10 | |||
| 69b51f8fa2 | |||
| 3c1bb8c35d | |||
| d76d2d0742 | |||
| a96259c9e5 | |||
| 87f785192c | |||
| 1c21640f97 | |||
| 455d721a0c | |||
| 9681621c6b | |||
| f743ab5790 | |||
| c776c665b0 | |||
| acae7124bd | |||
| 69e0c2659e | |||
| aea9e5e8ed | |||
| 3ff5ba20fc | |||
| 1ae8ba6496 | |||
| fa975395bc | |||
| a345621404 | |||
| f5e58ff18b | |||
| 42ee2b74c7 | |||
| f354eebaf4 | |||
| a4c91ce191 | |||
| 9a023bbba0 | |||
| a8b115caa1 | |||
| 172ff92c34 | |||
| 70f4d573f2 | |||
| 132f38c3a4 | |||
| b31b755bbf | |||
| c988cbb1e3 | |||
| b7c61c9e3d | |||
| 27a3da3e88 | |||
| 15a9429b69 | |||
| 892b35fcfb | |||
| f7af4e5180 | |||
| ff00dacd9f | |||
| 7f00c5fe59 | |||
| b5fc06ee33 | |||
| ae0a3aa7b9 | |||
| b14416447e | |||
| 8cd5c0f71f | |||
| df997354c8 | |||
| 19ad71b903 | |||
| 3fbf93e26f | |||
| d845bc5d45 | |||
| 02c6c77324 | |||
| f8541cf7a2 | |||
| 6e0bd68e45 | |||
| d3ef6aca40 |
@@ -172,7 +172,7 @@ runs:
|
||||
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
|
||||
fi
|
||||
|
||||
- name: '🔗 Install latest core package'
|
||||
@@ -197,6 +197,29 @@ runs:
|
||||
run: |
|
||||
node ${{ github.workspace }}/scripts/prepare-npm-release.js
|
||||
|
||||
- name: '📦 Pack CLI for verification'
|
||||
if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
|
||||
# We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
|
||||
git checkout packages/cli/package.json
|
||||
env:
|
||||
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: 'Get CLI Token'
|
||||
uses: './.github/actions/npm-auth-token'
|
||||
id: 'cli-token'
|
||||
@@ -213,15 +236,22 @@ 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
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
|
||||
fi
|
||||
|
||||
- name: 'Get a2a-server Token'
|
||||
@@ -248,21 +278,9 @@ runs:
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
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 }}'
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
|
||||
fi
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
|
||||
@@ -19,6 +19,6 @@ runs:
|
||||
run: |-
|
||||
echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc
|
||||
echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com/"" >> ~/.npmrc
|
||||
echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc
|
||||
env:
|
||||
INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
|
||||
|
||||
@@ -74,7 +74,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
|
||||
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
|
||||
exit 1
|
||||
@@ -86,7 +86,7 @@ runs:
|
||||
- name: 'Install dependencies for integration tests'
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: 'npm ci'
|
||||
run: 'npm ci --ignore-scripts'
|
||||
|
||||
- name: '🔬 Run integration tests against NPM release'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
@@ -98,4 +98,6 @@ runs:
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
CI: 'false'
|
||||
shell: 'bash'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
run: |
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
|
||||
npm run test:integration:sandbox:none
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
|
||||
@@ -131,6 +131,19 @@ jobs:
|
||||
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
|
||||
return labelNames;
|
||||
|
||||
- name: 'Prepare Issue Data'
|
||||
id: 'prepare_issue_data'
|
||||
env:
|
||||
ISSUE_TITLE: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
ISSUE_BODY: >-
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Title: ${ISSUE_TITLE}" > issue_context.md
|
||||
echo "Body:" >> issue_context.md
|
||||
echo "${ISSUE_BODY}" >> issue_context.md
|
||||
|
||||
- name: 'Run Gemini Issue Analysis'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
id: 'gemini_issue_analysis'
|
||||
@@ -140,6 +153,7 @@ jobs:
|
||||
${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }}
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
upload_artifacts: 'true'
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
@@ -157,7 +171,10 @@ jobs:
|
||||
"target": "gcp"
|
||||
},
|
||||
"tools": {
|
||||
"core": []
|
||||
"core": [
|
||||
"run_shell_command(echo)",
|
||||
"read_file"
|
||||
]
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
@@ -165,15 +182,8 @@ jobs:
|
||||
|
||||
You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label based on the definitions provided.
|
||||
|
||||
## Issue Context
|
||||
Title: ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }}
|
||||
Body:
|
||||
--- START OF ISSUE BODY ---
|
||||
${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }}
|
||||
--- END OF ISSUE BODY ---
|
||||
|
||||
## Steps
|
||||
1. Analyze the issue context above.
|
||||
1. Use the read_file tool to read the file "issue_context.md" which contains the issue title and body.
|
||||
2. Review the available labels: ${{ env.AVAILABLE_LABELS }}.
|
||||
3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions.
|
||||
4. Fallback Logic:
|
||||
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
|
||||
@@ -176,6 +176,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
@@ -300,6 +301,7 @@ jobs:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
|
||||
@@ -145,8 +145,8 @@ jobs:
|
||||
skip-branch-cleanup: true
|
||||
force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}"
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }}"
|
||||
npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}"
|
||||
npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}"
|
||||
cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}"
|
||||
core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}"
|
||||
|
||||
@@ -106,7 +106,9 @@ jobs:
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
|
||||
PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)
|
||||
STABLE_SHA=$(git rev-parse "${PREVIOUS_PREVIEW_TAG}^{commit}")
|
||||
echo "STABLE_SHA=${STABLE_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
|
||||
@@ -82,7 +82,8 @@ jobs:
|
||||
ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT"
|
||||
ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")
|
||||
echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Change tag'
|
||||
if: "${{ github.event.inputs.rollback_destination != '' }}"
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1 @@
|
||||
@google:registry=https://wombat-dressing-room.appspot.com/
|
||||
@google:registry=https://wombat-dressing-room.appspot.com
|
||||
+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.
|
||||
|
||||
@@ -143,16 +143,6 @@ Integrate Gemini CLI directly into your GitHub workflows with
|
||||
- **Custom Workflows**: Build automated, scheduled and on-demand workflows
|
||||
tailored to your team's needs
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Security best practice for public repositories:** Never set
|
||||
> `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD workflows
|
||||
> that process untrusted public inputs (like issue titles/bodies or PR comments).
|
||||
> Doing so can expose dynamically generated runner secrets (such as GCP OIDC
|
||||
> service account credentials) to prompt injection attacks. See the
|
||||
> [Trusted Folders documentation](https://www.geminicli.com/docs/cli/trusted-folders)
|
||||
> for more information.
|
||||
|
||||
## 🔐 Authentication Options
|
||||
|
||||
Choose the authentication method that best fits your needs:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Behavioral Evaluations & EDK Guide
|
||||
|
||||
This guide introduces the **Eval Development Kit (EDK)** and details how to
|
||||
write, validate, run, and report on **behavioral evaluations** in the Gemini CLI
|
||||
codebase.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Behavioral evaluations are automated tests designed to assert on the
|
||||
**behavior** of the Gemini CLI agent (e.g., verifying which tools are called,
|
||||
checking call ordering, or avoiding destructive commands) rather than checking
|
||||
the final prose output.
|
||||
|
||||
Evaluating agent behavior is critical because:
|
||||
|
||||
1. Model responses are non-deterministic, making exact prose matching highly
|
||||
fragile.
|
||||
2. We must ensure the model utilizes the most efficient tools (e.g., batching
|
||||
files via `read_many_files` instead of sequential `read_file` calls).
|
||||
3. We must enforce safety boundaries (e.g., preventing execution of raw shell
|
||||
commands when safe alternatives exist).
|
||||
|
||||
All behavioral evaluations are stored under the `evals/` directory.
|
||||
|
||||
---
|
||||
|
||||
## EDK Developer Commands
|
||||
|
||||
The EDK provides CLI tools under `scripts/` to help contributors audit, check,
|
||||
and monitor evals.
|
||||
|
||||
### 1. `npm run eval:inventory`
|
||||
|
||||
Scans all eval files under `evals/`, statically parses them, and provides a
|
||||
structured overview of what exists in the repository.
|
||||
|
||||
- **Usage:**
|
||||
```bash
|
||||
npm run eval:inventory
|
||||
```
|
||||
- **JSON Output:** For CI integration or inventory indexing, generate a
|
||||
machine-readable JSON report:
|
||||
```bash
|
||||
npm run eval:inventory -- --json
|
||||
```
|
||||
- **Custom Root:** Run against another directory or repository:
|
||||
```bash
|
||||
npm run eval:inventory -- --root /path/to/other/repo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `npm run eval:validate`
|
||||
|
||||
A lint-like checker that validates eval source files against standard structural
|
||||
guidelines and best practices.
|
||||
|
||||
- **Usage:**
|
||||
```bash
|
||||
npm run eval:validate
|
||||
```
|
||||
- **Custom Scopes:** Validate a specific file:
|
||||
```bash
|
||||
npm run eval:validate -- evals/my-test.eval.ts
|
||||
```
|
||||
|
||||
#### Validation Rules & Severities
|
||||
|
||||
| Rule ID | Severity | Description |
|
||||
| :------------------- | :---------- | :--------------------------------------------------------------------------------------------------------------------- |
|
||||
| `file-naming` | **Error** | File must match `*.eval.ts` or `*.eval.tsx` naming conventions. |
|
||||
| `valid-policy` | **Error** | Policy must be one of `ALWAYS_PASSES`, `USUALLY_PASSES`, or `USUALLY_FAILS`. |
|
||||
| `suite-metadata` | **Error** | Both `suiteName` and `suiteType` must be present as static string literals. |
|
||||
| `prompt-presence` | **Error** | Every eval case must have a non-empty `prompt` string. |
|
||||
| `case-name-static` | **Error** | The case name must be a static string literal, not computed dynamically. |
|
||||
| `invalid-tool-refs` | **Error** | All tools referenced in assertions must match known built-in or legacy tools. |
|
||||
| `positive-assertion` | **Error** | Evaluation cases must assert on at least one tool call (e.g., check `waitForToolCall` has been invoked). |
|
||||
| `workspace-setup` | **Error** | Workspace behaviors (like file-system edits/reads) must set up a `files` object. |
|
||||
| `new-evals-policy` | **Warning** | New evals must not use `ALWAYS_PASSES` policy initially (they should be promoted after nightly data proves stability). |
|
||||
|
||||
Warnings (`new-evals-policy`) will be logged with `⚠` and will **not** cause
|
||||
the CLI process to exit with status `1`. Errors (`✗`) will block CI builds and
|
||||
return exit status `1`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `npm run eval:report`
|
||||
|
||||
Aggregates local vitest `report.json` artifacts, maps them against inventory
|
||||
policies, and summarizes the pass rates per model.
|
||||
|
||||
- **Usage:**
|
||||
```bash
|
||||
npm run eval:report
|
||||
```
|
||||
By default, it scans `evals/logs/` recursively for `report.json` files.
|
||||
- **Specifying Directory:**
|
||||
```bash
|
||||
npm run eval:report -- /path/to/logs
|
||||
```
|
||||
- **JSON Output:**
|
||||
```bash
|
||||
npm run eval:report -- --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributor Workflow
|
||||
|
||||
When writing a new behavioral evaluation, adhere to this workflow to ensure
|
||||
high-quality, non-flaky test runs.
|
||||
|
||||
### Step-by-Step Guide
|
||||
|
||||
1. **Identify the Target Behavior**: Determine which tool calls need
|
||||
verification (e.g., `web_fetch` must be called).
|
||||
2. **Author the Eval File**: Create your file under `evals/<name>.eval.ts`
|
||||
naming it properly.
|
||||
3. **Configure Workspace Files**: If the eval reads or edits files, define them
|
||||
inside the `files` metadata field.
|
||||
4. **Assert Behavior, Not Prose**: Ensure the `assert` block checks tool
|
||||
interactions using `rig.waitForToolCall` or similar. Do not check final
|
||||
prose.
|
||||
5. **Run Locally**:
|
||||
```bash
|
||||
RUN_EVALS=true npx vitest run evals/my-test.eval.ts
|
||||
```
|
||||
6. **Deflake**: Run your eval at least 3 times locally to verify it does not
|
||||
fail due to model variance.
|
||||
7. **Run Validation**: Run `npm run eval:validate` to ensure no linting errors
|
||||
are present.
|
||||
|
||||
### Acceptance Criteria Checklist
|
||||
|
||||
- [ ] **Naming**: File ends with `.eval.ts` or `.eval.tsx`.
|
||||
- [ ] **Policy**: New evals start as `USUALLY_PASSES`.
|
||||
- [ ] **Metadata**: Static `suiteName` and `suiteType` (e.g. `'behavioral'`) are
|
||||
specified.
|
||||
- [ ] **Assertions**: Uses `rig.waitForToolCall` or asserts tool arguments
|
||||
explicitly.
|
||||
- [ ] **Clean workspace**: Does not write to files outside `rig.testDir`.
|
||||
|
||||
### Common Anti-Patterns to Avoid
|
||||
|
||||
- **Restricting core tools**: Never override `settings.tools.core` to limit
|
||||
tools. Evals must run against the default toolset.
|
||||
- **Checking model prose**: Avoid `expect(result).toContain('something')` since
|
||||
model wording is non-deterministic.
|
||||
- **Integration-only testing**: Evals that only write files without checking
|
||||
realistic model prompts are integration tests and belong under
|
||||
`integration-tests/`.
|
||||
|
||||
---
|
||||
|
||||
## CI & Dashboard Integration
|
||||
|
||||
You can easily automate behavioral evaluations or compile dashboard data using
|
||||
EDK's JSON reporters.
|
||||
|
||||
### CI Validation Block
|
||||
|
||||
Add a step in your PR checks or GitHub workflows to automatically lint new evals
|
||||
and block pull requests containing validation errors:
|
||||
|
||||
```yaml
|
||||
- name: Run Eval Validator
|
||||
run: npm run eval:validate
|
||||
```
|
||||
|
||||
### Publishing to a Dashboard
|
||||
|
||||
To record nightly performance metrics across multiple models:
|
||||
|
||||
1. Configure your workflow to run evaluations with the JSON reporter:
|
||||
```bash
|
||||
cross-env GEMINI_MODEL=gemini-2.5-pro npx vitest run --config evals/vitest.config.ts --reporter=json --outputFile="evals/logs/eval-logs-gemini-2.5-pro/report.json"
|
||||
```
|
||||
2. Aggregate all test runs using the reporting tool:
|
||||
```bash
|
||||
npm run eval:report -- evals/logs --json > aggregated_report.json
|
||||
```
|
||||
3. Upload `aggregated_report.json` to your dashboard storage backend to
|
||||
visualize pass rates over time.
|
||||
@@ -18,6 +18,74 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.54.0 - 2026-08-06
|
||||
|
||||
- **PR Automation & Antigravity Agent:** Integrated the Antigravity agent runner
|
||||
with dual-locking Firestore concurrency controls to secure the PR generator
|
||||
([#28434](https://github.com/google-gemini/gemini-cli/pull/28434),
|
||||
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432) by
|
||||
@joneba-google).
|
||||
- **Caretaker Triaging & Security:** Enhanced caretaker triage to post comments
|
||||
prior to auto-closing issues and sanitized issue titles under untrusted
|
||||
context ([#28411](https://github.com/google-gemini/gemini-cli/pull/28411),
|
||||
[#28352](https://github.com/google-gemini/gemini-cli/pull/28352) by @chadd28).
|
||||
- **Security and Session Robustness:** Prevented cleartext credential leakage by
|
||||
enforcing HTTPS, rotated session IDs on model fallbacks, and skipped merged
|
||||
function-response turns in active loops
|
||||
([#28517](https://github.com/google-gemini/gemini-cli/pull/28517) by
|
||||
@amelidev, [#28565](https://github.com/google-gemini/gemini-cli/pull/28565) by
|
||||
@adamfweidman).
|
||||
|
||||
## Announcements: v0.53.0 - 2026-07-28
|
||||
|
||||
- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and
|
||||
container build setup
|
||||
([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by
|
||||
@chadd28).
|
||||
- **Eval Coverage Reporting:** Introduced a new command for generating
|
||||
evaluation coverage reports
|
||||
([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015).
|
||||
- **Security & Loop Mitigations:** Enforced workspace trust and task isolation
|
||||
in the A2A server, aligned macOS Seatbelt profiles with the deny-default
|
||||
model, and mitigated infinite ReAct/prompt injection loops
|
||||
([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by
|
||||
@luisfelipe-alt,
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by
|
||||
@ompatel-aiml).
|
||||
|
||||
## Announcements: v0.52.0 - 2026-07-22
|
||||
|
||||
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
|
||||
foundational modules, main worker execution loops, and egress action
|
||||
publishers alongside the octokit GitHub Action handler for egress services
|
||||
([#28163](https://github.com/google-gemini/gemini-cli/pull/28163),
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306) by @chadd28).
|
||||
- **Core Tool Enhancements:** Bypassed LLM correction for JSON and IPYNB files
|
||||
in `write_file` and `replace` tools, and simplified plan mode write policy to
|
||||
support relative paths
|
||||
([#28223](https://github.com/google-gemini/gemini-cli/pull/28223) by
|
||||
@amelidev, [#28398](https://github.com/google-gemini/gemini-cli/pull/28398) by
|
||||
@DavidAPierce).
|
||||
- **Auth & Privacy Improvements:** Displayed clear error messages when user
|
||||
account has no Code Assist tier, and bumped `google-auth-library` to version
|
||||
10.9.0 ([#28304](https://github.com/google-gemini/gemini-cli/pull/28304) by
|
||||
@ompatel-aiml,
|
||||
[#28385](https://github.com/google-gemini/gemini-cli/pull/28385) by
|
||||
@jerrylin3321).
|
||||
|
||||
## Announcements: v0.50.0 - 2026-07-08
|
||||
|
||||
- **Tool Registry Discovery:** Introduced tool registry discovery capabilities
|
||||
to automatically detect and register available tools
|
||||
([#28113](https://github.com/google-gemini/gemini-cli/pull/28113) by @ved015).
|
||||
- **Release Verification & CI Stability:** Enhanced release verification by
|
||||
ignoring scripts during verification, preventing workspace binary shadowing,
|
||||
and safeguarding against bad NPM releases
|
||||
([#28116](https://github.com/google-gemini/gemini-cli/pull/28116) by
|
||||
@rmedranollamas,
|
||||
[#28132](https://github.com/google-gemini/gemini-cli/pull/28132) by
|
||||
@galdawave).
|
||||
|
||||
## Announcements: v0.45.0 - 2026-06-03
|
||||
|
||||
- **Context Simplification:** Completed major architectural work to simplify the
|
||||
@@ -507,6 +575,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 +704,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 +744,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))
|
||||
|
||||
|
||||
+58
-47
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.45.0
|
||||
# Latest stable release: v0.54.0
|
||||
|
||||
Released: June 03, 2026
|
||||
Released: August 6, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,55 +11,66 @@ 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.
|
||||
- **PR Generation & Antigravity Agent:** Implemented Firestore concurrency
|
||||
dual-locking mechanisms in the database and introduced the Antigravity agent
|
||||
runner with comprehensive prompt templates.
|
||||
- **Caretaker Triaging & Issue Security:** Improved the caretaker triage loop to
|
||||
post a descriptive comment prior to auto-closing issues, and sanitized issue
|
||||
titles within an untrusted context to ensure secure processing.
|
||||
- **Enhanced Authentication & Security:** Enforced strict HTTPS validation for
|
||||
GoogleCredentialsAuthProvider to block cleartext leakage, and implemented tag
|
||||
length validation for the file keychain system.
|
||||
- **Model Fallback & History Filtering:** Resolved stateful API errors by
|
||||
rotating session IDs on model fallback, optimized conversation history
|
||||
retrieval by filtering out thought parts when context management is disabled,
|
||||
and correctly skipped merged function responses when tracking active loops.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
|
||||
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
|
||||
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
|
||||
- Changelog for v0.52.0 by @gemini-cli-robot in
|
||||
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
|
||||
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 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
|
||||
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
|
||||
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
|
||||
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
|
||||
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
|
||||
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
|
||||
- fix(core): rotate session ID on model fallback to prevent stateful API errors
|
||||
by @amelidev in
|
||||
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
|
||||
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
|
||||
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
|
||||
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
|
||||
cleartext leakage by @amelidev in
|
||||
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
|
||||
- fix(core): filter out thought parts from getHistoryTurns when context
|
||||
management is disabled by @DavidAPierce in
|
||||
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
|
||||
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
|
||||
@luisfelipe-alt in
|
||||
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
|
||||
- fix(core): enforce explicit tag length and validation in file keychain by
|
||||
@luisfelipe-alt in
|
||||
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
|
||||
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
|
||||
@gemini-cli-robot in
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
|
||||
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
|
||||
ingestion utilities by @joneba-google in
|
||||
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
|
||||
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
|
||||
templates … by @joneba-google in
|
||||
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
|
||||
- fix(core): skip merged function-response turns when finding the active loop by
|
||||
@adamfweidman in
|
||||
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
|
||||
- fix(patch): cherry-pick f47d6c6 to release/v0.54.0-preview.0-pr-28566 to patch
|
||||
version v0.54.0-preview.0 and create version 0.54.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#28609](https://github.com/google-gemini/gemini-cli/pull/28609)
|
||||
|
||||
**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.53.1...v0.54.0
|
||||
|
||||
+92
-49
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.48.0-preview.0
|
||||
# Preview release: v0.55.0-preview.1
|
||||
|
||||
Released: June 17, 2026
|
||||
Released: August 06, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,58 +13,101 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **GDC Service Identity Support**: Added support for GDC air-gapped Service
|
||||
Identity after a major auth library update.
|
||||
- **Standardised Tool Outputs**: Standardised tool output formatting to ensure
|
||||
consistency and readability across different CLI commands.
|
||||
- **Static Evaluation Analyzer**: Introduced a new static evaluation source
|
||||
analyzer to improve development and testing.
|
||||
- **Vulnerability Prevention**: Hardened CLI security by preventing path
|
||||
traversal vulnerabilities during the installation of Skills.
|
||||
- **Configuration & Error Hardening**: Migrated the `coreTools` configuration
|
||||
setting to `tools.core` and ensured zero-quota limits fail fast to prevent
|
||||
infinite retry loops.
|
||||
- **Antigravity Agent & PR Generator:** Integrated the Antigravity agent runner,
|
||||
Firestore dual-locking for concurrency, prompt templates, and ingestion
|
||||
testing utilities.
|
||||
- **Caretaker Triage & Issue Management:** Enhanced the issue triage workflow by
|
||||
automatically posting a comment before closing issues, and sanitizing and
|
||||
wrapping issue titles in `untrusted_context`.
|
||||
- **Core API & Session Stability:** Enforced HTTPS for
|
||||
GoogleCredentialsAuthProvider to prevent cleartext leakage, rotated session
|
||||
IDs on model fallback to prevent stateful API errors, and refined chat history
|
||||
by filtering out thought parts when context management is disabled.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.48.0-nightly.20260609.g3a13b8eeb by
|
||||
- chore(release): bump version to 0.55.0-nightly.20260728.gd29268d36 by
|
||||
@gemini-cli-robot in
|
||||
[#27779](https://github.com/google-gemini/gemini-cli/pull/27779)
|
||||
- ci(dependabot): enable cooldown period for npm packages by @ruomengz in
|
||||
[#27743](https://github.com/google-gemini/gemini-cli/pull/27743)
|
||||
- refactor(core): standardize tool output formatting by @galz10 in
|
||||
[#27772](https://github.com/google-gemini/gemini-cli/pull/27772)
|
||||
- ci: update workflow logging and policy configurations by @galz10 in
|
||||
[#27853](https://github.com/google-gemini/gemini-cli/pull/27853)
|
||||
- fix(core): Ensure zero-quota limits fail fast to prevent retry loop hang by
|
||||
[#28569](https://github.com/google-gemini/gemini-cli/pull/28569)
|
||||
- Changelog for v0.54.0-preview.0 by @gemini-cli-robot in
|
||||
[#28567](https://github.com/google-gemini/gemini-cli/pull/28567)
|
||||
- Changelog for v0.53.0 by @gemini-cli-robot in
|
||||
[#28568](https://github.com/google-gemini/gemini-cli/pull/28568)
|
||||
- chore/release: bump version to 0.55.0-nightly.20260729.g3499c84f7 by
|
||||
@gemini-cli-robot in
|
||||
[#28573](https://github.com/google-gemini/gemini-cli/pull/28573)
|
||||
- fix(core): classify capacity exhaustion as terminal to prevent retry hangs by
|
||||
@luisfelipe-alt in
|
||||
[#27698](https://github.com/google-gemini/gemini-cli/pull/27698)
|
||||
- fix(core): handle multi-line escaped quotes in stripShellWrapper by
|
||||
@sanchezcoraspe in
|
||||
[#27467](https://github.com/google-gemini/gemini-cli/pull/27467)
|
||||
- fix(cli): prevent path traversal vulnerabilities during skill install… by
|
||||
@ompatel-aiml in
|
||||
[#27767](https://github.com/google-gemini/gemini-cli/pull/27767)
|
||||
- Fix/pending tools and trust overrides by @jvargassanchez-dot in
|
||||
[#27854](https://github.com/google-gemini/gemini-cli/pull/27854)
|
||||
- ci: use internal environment for scheduled nightly releases (#27865) by
|
||||
@rmedranollamas in
|
||||
[#27939](https://github.com/google-gemini/gemini-cli/pull/27939)
|
||||
- feat(core): Support GDC air-gapped Service Identity after auth library update
|
||||
by @sidhantgoyal-droid in
|
||||
[#27956](https://github.com/google-gemini/gemini-cli/pull/27956)
|
||||
- fix(cli): handle tmux false positive background detection by @amelidev in
|
||||
[#27572](https://github.com/google-gemini/gemini-cli/pull/27572)
|
||||
- Add static eval source analyzer by @ved015 in
|
||||
[#27631](https://github.com/google-gemini/gemini-cli/pull/27631)
|
||||
- fix(config): migrate coreTools setting to tools.core by @galz10 in
|
||||
[#27947](https://github.com/google-gemini/gemini-cli/pull/27947)
|
||||
- fix(core-tools): resolve defensive path resolution for at-reference files by
|
||||
[#28599](https://github.com/google-gemini/gemini-cli/pull/28599)
|
||||
- fix(core,cli): propagate InvalidStreamError details to UI for specific empty
|
||||
response guidance by @DavidAPierce in
|
||||
[#28566](https://github.com/google-gemini/gemini-cli/pull/28566)
|
||||
- fix(cli): fall back to embedded macOS seatbelt profiles if missing by
|
||||
@amelidev in [#28551](https://github.com/google-gemini/gemini-cli/pull/28551)
|
||||
- feat(pr-generator-core): add environment config parser, command executor,
|
||||
GitHub R… by @joneba-google in
|
||||
[#28435](https://github.com/google-gemini/gemini-cli/pull/28435)
|
||||
- feat(pr-generator-orchestrator): implement iterative bug-fixing state machine
|
||||
and container worker entrypoint by @joneba-google in
|
||||
[#28433](https://github.com/google-gemini/gemini-cli/pull/28433)
|
||||
- feat(pr-generator-infra): configure Cloud Run job, Workflows definition, and
|
||||
Dockerfile by @joneba-google in
|
||||
[#28431](https://github.com/google-gemini/gemini-cli/pull/28431)
|
||||
- fix(release): handle npm dist-tag deletion failures on registries that forbid
|
||||
it by @DavidAPierce in
|
||||
[#28694](https://github.com/google-gemini/gemini-cli/pull/28694)
|
||||
- fix(core): stop a new user message fusing into an unanswered tool response by
|
||||
@adamfweidman in
|
||||
[#28700](https://github.com/google-gemini/gemini-cli/pull/28700)
|
||||
- fix(core,cli): repair /compress session reload and quota-fallback tool
|
||||
response loss by @adamfweidman in
|
||||
[#28672](https://github.com/google-gemini/gemini-cli/pull/28672)
|
||||
- fix(core): preserve functionCall thoughtSignature when stripping thought parts
|
||||
by @sarbojitrana in
|
||||
[#28607](https://github.com/google-gemini/gemini-cli/pull/28607)
|
||||
- fix(core): unwrap and parse nested gaxios streaming errors from cause message
|
||||
by @luisfelipe-alt in
|
||||
[#28689](https://github.com/google-gemini/gemini-cli/pull/28689)
|
||||
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
|
||||
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
|
||||
- Changelog for v0.52.0 by @gemini-cli-robot in
|
||||
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
|
||||
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 by
|
||||
@gemini-cli-robot in
|
||||
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
|
||||
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
|
||||
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
|
||||
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
|
||||
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
|
||||
- fix(core): rotate session ID on model fallback to prevent stateful API errors
|
||||
by @amelidev in
|
||||
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
|
||||
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
|
||||
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
|
||||
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
|
||||
cleartext leakage by @amelidev in
|
||||
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
|
||||
- fix(core): filter out thought parts from getHistoryTurns when context
|
||||
management is disabled by @DavidAPierce in
|
||||
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
|
||||
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
|
||||
@luisfelipe-alt in
|
||||
[#27943](https://github.com/google-gemini/gemini-cli/pull/27943)
|
||||
- Revert "fix(core-tools): resolve defensive path resolution for at-reference
|
||||
files" by @galz10 in
|
||||
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
|
||||
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
|
||||
- fix(core): enforce explicit tag length and validation in file keychain by
|
||||
@luisfelipe-alt in
|
||||
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
|
||||
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
|
||||
@gemini-cli-robot in
|
||||
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
|
||||
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
|
||||
ingestion utilities by @joneba-google in
|
||||
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
|
||||
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
|
||||
templates … by @joneba-google in
|
||||
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
|
||||
- fix(core): skip merged function-response turns when finding the active loop by
|
||||
@adamfweidman in
|
||||
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.48.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.55.0-preview.1
|
||||
|
||||
@@ -16,10 +16,12 @@ sends them to the model with every prompt. The CLI loads files in the following
|
||||
order:
|
||||
|
||||
1. **Global context file:**
|
||||
|
||||
- **Location:** `~/.gemini/GEMINI.md` (in your user home directory).
|
||||
- **Scope:** Provides default instructions for all your projects.
|
||||
|
||||
2. **Environment and workspace context files:**
|
||||
|
||||
- **Location:** The CLI searches for `GEMINI.md` files in your configured
|
||||
workspace directories and their parent directories.
|
||||
- **Scope:** Provides context relevant to the projects you are currently
|
||||
|
||||
@@ -64,6 +64,7 @@ Gemini CLI takes action.
|
||||
reach an informal agreement on the approach before proceeding.
|
||||
3. **Review the plan:** Once you've agreed on the strategy, Gemini CLI creates
|
||||
a detailed implementation plan as a Markdown file in your plans directory.
|
||||
|
||||
- **View:** You can open and read this file to understand the proposed
|
||||
changes.
|
||||
- **Edit:** Press `Ctrl+X` to open the plan directly in your configured
|
||||
|
||||
+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
|
||||
|
||||
@@ -117,16 +117,6 @@ the following methods:
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **Never set `GEMINI_CLI_TRUST_WORKSPACE=true` or use `--skip-trust` in CI/CD
|
||||
> workflows that process untrusted public inputs** (such as GitHub issues, pull
|
||||
> requests, or comments). Doing so allows a malicious contributor to commit a
|
||||
> crafted `.gemini/settings.json` file in their pull request, register
|
||||
> arbitrary tools (including shell execution), and exfiltrate dynamically
|
||||
> generated runner secrets (such as GCP service account credentials or AWS keys)
|
||||
> via prompt injection.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
@@ -56,6 +56,7 @@ creating a "discovery file."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths,
|
||||
delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for
|
||||
@@ -187,6 +188,7 @@ The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
- **Response (`CallToolResult`):** The tool **MUST** immediately return a
|
||||
`CallToolResult` to acknowledge the request and report whether the diff view
|
||||
was successfully opened.
|
||||
|
||||
- On Success: If the diff view was opened successfully, the response **MUST**
|
||||
contain empty content (that is, `content: []`).
|
||||
- On Failure: If an error prevented the diff view from opening, the response
|
||||
|
||||
@@ -27,6 +27,7 @@ AI-generated code changes directly within your editor.
|
||||
|
||||
- **Workspace context:** The CLI automatically gains awareness of your workspace
|
||||
to provide more relevant and accurate responses. This context includes:
|
||||
|
||||
- The **10 most recently accessed files** in your workspace.
|
||||
- Your active cursor position.
|
||||
- Any text you have selected (up to a 16KB limit; longer selections will be
|
||||
@@ -228,6 +229,7 @@ If you are using Gemini CLI within a sandbox, be aware of the following:
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||
|
||||
- **Cause:** Gemini CLI could not find the necessary environment variables
|
||||
(`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect
|
||||
to the IDE. This usually means the IDE companion extension is not running or
|
||||
@@ -270,6 +272,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||
|
||||
- **Cause:** The CLI's current working directory is outside the workspace you
|
||||
have open in your IDE.
|
||||
- **Solution:** `cd` into the same directory that is open in your IDE and
|
||||
@@ -284,6 +287,7 @@ to connect using the provided PID.
|
||||
|
||||
- **Message:**
|
||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
|
||||
|
||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is
|
||||
not a supported IDE.
|
||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
|
||||
|
||||
@@ -59,6 +59,7 @@ You can view traces in the Jaeger UI for local development.
|
||||
|
||||
This command configures your workspace for local telemetry and provides a
|
||||
link to the Jaeger UI (usually `http://localhost:16686`).
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector.log`
|
||||
|
||||
2. **Run Gemini CLI:**
|
||||
@@ -108,6 +109,7 @@ Trace for custom processing or routing.
|
||||
|
||||
The script outputs links to view traces, metrics, and logs in the Google
|
||||
Cloud Console.
|
||||
|
||||
- **Collector logs:** `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
|
||||
|
||||
3. **Run Gemini CLI:**
|
||||
|
||||
@@ -506,6 +506,7 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
@@ -519,6 +520,7 @@ At commands are used to include the content of files or directories as part of
|
||||
your prompt to Gemini. These commands include git-aware filtering.
|
||||
|
||||
- **`@<path_to_file_or_directory>`**
|
||||
|
||||
- **Description:** Inject the content of the specified file or files into your
|
||||
current prompt. This is useful for asking questions about specific code,
|
||||
text, or collections of files.
|
||||
@@ -565,6 +567,7 @@ The `!` prefix lets you interact with your system's shell directly from within
|
||||
Gemini CLI.
|
||||
|
||||
- **`!<shell_command>`**
|
||||
|
||||
- **Description:** Execute the given `<shell_command>` using `bash` on
|
||||
Linux/macOS or `powershell.exe -NoProfile -Command` on Windows (unless you
|
||||
override `ComSpec`). Any output or errors from the command are displayed in
|
||||
@@ -574,6 +577,7 @@ Gemini CLI.
|
||||
- `!git status` (executes `git status` and returns to Gemini CLI)
|
||||
|
||||
- **`!` (Toggle shell mode)**
|
||||
|
||||
- **Description:** Typing `!` on its own toggles shell mode.
|
||||
- **Entering shell mode:**
|
||||
- When active, shell mode uses a different coloring and a "Shell Mode
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ Before promoting a `preview` release to `stable`, a release manager must
|
||||
manually run through this checklist.
|
||||
|
||||
- **Setup:**
|
||||
|
||||
- [ ] Uninstall any existing global version:
|
||||
`npm uninstall -g @google/gemini-cli`
|
||||
- [ ] Clear npx cache (optional but recommended): `npm cache clean --force`
|
||||
@@ -77,24 +78,29 @@ manually run through this checklist.
|
||||
- [ ] Verify version: `gemini --version`
|
||||
|
||||
- **Authentication:**
|
||||
|
||||
- [ ] In interactive mode run `/auth` and verify all sign in flows work:
|
||||
- [ ] Sign in with Google
|
||||
- [ ] API Key
|
||||
- [ ] Vertex AI
|
||||
|
||||
- **Basic prompting:**
|
||||
|
||||
- [ ] Run `gemini "Tell me a joke"` and verify a sensible response.
|
||||
- [ ] Run in interactive mode: `gemini`. Ask a follow-up question to test
|
||||
context.
|
||||
|
||||
- **Piped input:**
|
||||
|
||||
- [ ] Run `echo "Summarize this" | gemini` and verify it processes stdin.
|
||||
|
||||
- **Context management:**
|
||||
|
||||
- [ ] In interactive mode, use `@file` to add a local file to context. Ask a
|
||||
question about it.
|
||||
|
||||
- **Settings:**
|
||||
|
||||
- [ ] In interactive mode run `/settings` and make modifications
|
||||
- [ ] Validate that setting is changed
|
||||
|
||||
|
||||
@@ -475,6 +475,7 @@ This stage happens _after_ the NPM publish and creates the single-file
|
||||
executable that enables `npx` usage directly from the GitHub repository.
|
||||
|
||||
1. **The JavaScript bundle is created:**
|
||||
|
||||
- **What happens:** The built JavaScript from both `packages/core/dist` and
|
||||
`packages/cli/dist`, along with all third-party JavaScript dependencies,
|
||||
are bundled by `esbuild` into a single, executable JavaScript file (for
|
||||
@@ -486,6 +487,7 @@ executable that enables `npx` usage directly from the GitHub repository.
|
||||
the `core` package) are included directly.
|
||||
|
||||
2. **The `bundle` directory is assembled:**
|
||||
|
||||
- **What happens:** A temporary `bundle` folder is created at the project
|
||||
root. The single `gemini.js` executable is placed inside it, along with
|
||||
other essential files.
|
||||
|
||||
@@ -127,6 +127,7 @@ Standard/Plus and AI Expanded, are not supported._
|
||||
license seats. For predictable costs, you can sign in with Google.
|
||||
|
||||
This includes the following request limits:
|
||||
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 maximum model requests / user / day
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
|
||||
@@ -12,6 +12,7 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`You must be a named user on your organization's Gemini Code Assist Standard edition subscription to use this service. Please contact your administrator to request an entitlement to Gemini Code Assist Standard edition.`**
|
||||
|
||||
- **Cause:** This error might occur if Gemini CLI detects the
|
||||
`GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` environment variable is
|
||||
defined. Setting these variables forces an organization subscription check.
|
||||
@@ -19,6 +20,7 @@ topics on:
|
||||
linked to an organizational subscription.
|
||||
|
||||
- **Solution:**
|
||||
|
||||
- **Individual Users:** Unset the `GOOGLE_CLOUD_PROJECT` and
|
||||
`GOOGLE_CLOUD_PROJECT_ID` environment variables. Check and remove these
|
||||
variables from your shell configuration files (for example, `.bashrc`,
|
||||
@@ -30,12 +32,14 @@ topics on:
|
||||
|
||||
- **Error:
|
||||
`Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`**
|
||||
|
||||
- **Cause:** Gemini CLI does not currently support your location. For a full
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
|
||||
- **Error: `Failed to sign in. Message: Request contains an invalid argument`**
|
||||
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
associated with their Gmail accounts may not be able to activate the free
|
||||
tier of the Google Code Assist plan.
|
||||
@@ -66,6 +70,7 @@ topics on:
|
||||
## Common error messages and solutions
|
||||
|
||||
- **Error: `EADDRINUSE` (Address already in use) when starting an MCP server.**
|
||||
|
||||
- **Cause:** Another process is already using the port that the MCP server is
|
||||
trying to bind to.
|
||||
- **Solution:** Either stop the other process that is using the port or
|
||||
@@ -73,6 +78,7 @@ topics on:
|
||||
|
||||
- **Error: Command not found (when attempting to run Gemini CLI with
|
||||
`gemini`).**
|
||||
|
||||
- **Cause:** Gemini CLI is not correctly installed or it is not in your
|
||||
system's `PATH`.
|
||||
- **Solution:** The update depends on how you installed Gemini CLI:
|
||||
@@ -85,6 +91,7 @@ topics on:
|
||||
then rebuild using the command `npm run build`.
|
||||
|
||||
- **Error: `MODULE_NOT_FOUND` or import errors.**
|
||||
|
||||
- **Cause:** Dependencies are not installed correctly, or the project hasn't
|
||||
been built.
|
||||
- **Solution:**
|
||||
@@ -93,6 +100,7 @@ topics on:
|
||||
3. Verify that the build completed successfully with `npm run start`.
|
||||
|
||||
- **Error: "Operation not permitted", "Permission denied", or similar.**
|
||||
|
||||
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations
|
||||
that are restricted by your sandbox configuration, such as writing outside
|
||||
the project directory or system temp directory.
|
||||
@@ -101,6 +109,7 @@ topics on:
|
||||
configuration.
|
||||
|
||||
- **Gemini CLI is not running in interactive mode in "CI" environments**
|
||||
|
||||
- **Issue:** Gemini CLI does not enter interactive mode (no prompt appears) if
|
||||
an environment variable starting with `CI_` (for example, `CI_TOKEN`) is
|
||||
set. This is because the `is-in-ci` package, used by the underlying UI
|
||||
@@ -116,6 +125,7 @@ topics on:
|
||||
`env -u CI_TOKEN gemini`
|
||||
|
||||
- **DEBUG mode not working from project .env file**
|
||||
|
||||
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable
|
||||
debug mode for gemini-cli.
|
||||
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded
|
||||
@@ -155,12 +165,14 @@ is especially useful for scripting and automation.
|
||||
## Debugging tips
|
||||
|
||||
- **CLI debugging:**
|
||||
|
||||
- Use the `--debug` flag for more detailed output. In interactive mode, press
|
||||
F12 to view the debug console.
|
||||
- Check the CLI logs, often found in a user-specific configuration or cache
|
||||
directory.
|
||||
|
||||
- **Core debugging:**
|
||||
|
||||
- Check the server console output for error messages or stack traces.
|
||||
- Increase log verbosity if configurable. For example, set the `DEBUG_MODE`
|
||||
environment variable to `true` or `1`.
|
||||
@@ -168,6 +180,7 @@ is especially useful for scripting and automation.
|
||||
step through server-side code.
|
||||
|
||||
- **Tool issues:**
|
||||
|
||||
- If a specific tool is failing, try to isolate the issue by running the
|
||||
simplest possible version of the command or operation the tool performs.
|
||||
- For `run_shell_command`, check that the command works directly in your shell
|
||||
|
||||
@@ -217,6 +217,10 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{
|
||||
"label": "Behavioral evaluations",
|
||||
"slug": "docs/behavioral-evals"
|
||||
},
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Generated
+94
-1455
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.56.0-preview.1",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.56.0-preview.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -33,6 +33,9 @@
|
||||
"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:report": "tsx ./scripts/eval-report-cli.ts",
|
||||
"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",
|
||||
@@ -94,8 +97,10 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"read-package-up": "11.0.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/minimatch": "5.1.2",
|
||||
@@ -120,6 +125,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"express": "5.1.0",
|
||||
"glob": "12.0.0",
|
||||
"globals": "16.0.0",
|
||||
"google-artifactregistry-auth": "3.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.56.0-preview.1",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,7 +14,24 @@ import type {
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { requestStorage } from '../http/requestStorage.js';
|
||||
|
||||
vi.mock('../utils/path_utils.js', () => ({
|
||||
validateWorkspacePath: vi
|
||||
.fn()
|
||||
.mockImplementation(async (path?: string) => path || process.cwd()),
|
||||
}));
|
||||
|
||||
// Mocks for constructor dependencies
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
GeminiEventType: {
|
||||
PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
|
||||
SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
|
||||
},
|
||||
SimpleExtensionLoader: vi.fn(),
|
||||
checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
|
||||
isHeadlessMode: vi.fn().mockReturnValue(true),
|
||||
resolveToRealPath: vi.fn().mockImplementation((p) => p),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
getSessionId: () => 'test-session',
|
||||
@@ -24,6 +41,10 @@ vi.mock('../config/config.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
setIsTrusted: vi.fn().mockReturnValue(false),
|
||||
setTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
envStorage: {
|
||||
run: (env: Record<string, string>, cb: () => unknown) => cb(),
|
||||
},
|
||||
cwdSymbol: Symbol('cwd'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', () => ({
|
||||
@@ -300,4 +321,125 @@ describe('CoderAgentExecutor', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('cancelTask should abort the active execution loop', async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
const taskId = 'test-task-to-cancel';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
// Don't await this, let it run in the background.
|
||||
let primaryError: Error | null = null;
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
primaryPromise.catch((err) => {
|
||||
primaryError = err as Error;
|
||||
});
|
||||
|
||||
// Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (primaryError) {
|
||||
throw new Error(`Primary execution failed early: ${primaryError}`);
|
||||
}
|
||||
if (attempts++ > 100) {
|
||||
// 100 * 5ms = 500ms timeout
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId);
|
||||
expect(wrapper).toBeDefined();
|
||||
const setTaskStateSpy = vi
|
||||
.spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
|
||||
.mockImplementation((newState) => {
|
||||
// Make the mock realistic: actually update the state when called.
|
||||
wrapper!.task.taskState = newState;
|
||||
});
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the abort method on the controller was called and state was updated.
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
expect(setTaskStateSpy).toHaveBeenCalledWith(
|
||||
'canceled',
|
||||
expect.any(Object),
|
||||
'Task canceled by user request.',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
// The abort call should have unblocked the acceptUserMessage generator.
|
||||
await primaryPromise;
|
||||
|
||||
// Verify task is evicted from cache
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
|
||||
const taskId = 'test-task-active-abort-save';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
|
||||
// Wait for task to be registered
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (attempts++ > 100) {
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId)!;
|
||||
const saveSpy = vi.spyOn(mockTaskStore, 'save');
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the task state was saved to TaskStore during cancelTask
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(wrapper.task.dispose).toHaveBeenCalled();
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
await primaryPromise;
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -752,4 +752,107 @@ describe('Task', () => {
|
||||
expect(changed3).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProposedContent (CRLF Line Ending Normalization)', () => {
|
||||
it('should successfully replace LF-based strings in CRLF-based files', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
getTargetDir: () => os.tmpdir(),
|
||||
validatePathAccess: () => null,
|
||||
});
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt');
|
||||
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
|
||||
fs.writeFileSync(tempFile, crlfContent, 'utf8');
|
||||
|
||||
try {
|
||||
const oldString = 'line2\n';
|
||||
const newString = 'line2-optimized\n';
|
||||
|
||||
const result = await task['getProposedContent'](
|
||||
tempFile,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
|
||||
expect(result).toContain('line2-optimized');
|
||||
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
|
||||
} finally {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
getTargetDir: () => os.tmpdir(),
|
||||
validatePathAccess: () => null,
|
||||
});
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const tempFile = path.resolve(
|
||||
os.tmpdir(),
|
||||
'crlf_test_file_crlf_inputs.txt',
|
||||
);
|
||||
const crlfContent = 'line1\r\nline2\r\nline3\r\n';
|
||||
fs.writeFileSync(tempFile, crlfContent, 'utf8');
|
||||
|
||||
try {
|
||||
const oldString = 'line2\r\n';
|
||||
const newString = 'line2-optimized\r\n';
|
||||
|
||||
const result = await task['getProposedContent'](
|
||||
tempFile,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
|
||||
expect(result).toContain('line2-optimized');
|
||||
expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings
|
||||
} finally {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,9 +131,11 @@ export class Task {
|
||||
this.autoExecute = autoExecute;
|
||||
this.config.setFallbackModelHandler(
|
||||
// For a2a-server, we want to automatically switch to the fallback model
|
||||
// for future requests without retrying the current one. The 'stop'
|
||||
// intent achieves this.
|
||||
async () => 'stop',
|
||||
// for future requests without retrying the current one.
|
||||
async (failedModel, fallbackModel) => {
|
||||
this.config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return 'stop';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -666,13 +668,18 @@ export class Task {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
return this._applyReplacement(
|
||||
const rawContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
const hasCrlf = rawContent.includes('\r\n');
|
||||
const currentContent = rawContent.replace(/\r\n/g, '\n');
|
||||
const normalizedOldString = old_string.replace(/\r\n/g, '\n');
|
||||
const normalizedNewString = new_string.replace(/\r\n/g, '\n');
|
||||
const proposedContent = this._applyReplacement(
|
||||
currentContent,
|
||||
old_string,
|
||||
new_string,
|
||||
old_string === '' && currentContent === '',
|
||||
normalizedOldString,
|
||||
normalizedNewString,
|
||||
normalizedOldString === '' && currentContent === '',
|
||||
);
|
||||
return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
|
||||
} catch (err) {
|
||||
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
@@ -1092,19 +1099,21 @@ export class Task {
|
||||
logger.info(
|
||||
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
|
||||
);
|
||||
const responsesToAdd = completedTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
for (const response of responsesToAdd) {
|
||||
let parts: genAiPart[];
|
||||
if (Array.isArray(response)) {
|
||||
parts = response;
|
||||
} else if (typeof response === 'string') {
|
||||
parts = [{ text: response }];
|
||||
} else {
|
||||
parts = [response];
|
||||
const parts: genAiPart[] = [];
|
||||
for (const toolCall of completedTools) {
|
||||
const response = toolCall.response?.responseParts;
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
parts.push(...response);
|
||||
} else if (typeof response === 'string') {
|
||||
parts.push({ text: response });
|
||||
} else {
|
||||
parts.push(response);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.geminiClient.addHistory({
|
||||
role: 'user',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
tmpdir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
@@ -28,28 +30,246 @@ import {
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
resolveToRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { type AgentSettings, CoderAgentEvent } from '../types.js';
|
||||
|
||||
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
|
||||
export const envStorage = new AsyncLocalStorage<TaskEnv>();
|
||||
|
||||
const deletedKeysSymbol = Symbol('deletedKeys');
|
||||
export const cwdSymbol = Symbol('cwd');
|
||||
|
||||
export interface TaskEnv extends Record<string, string | undefined> {
|
||||
[deletedKeysSymbol]?: Set<string>;
|
||||
[cwdSymbol]?: string;
|
||||
}
|
||||
|
||||
// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
|
||||
const originalEnv = process.env;
|
||||
const envProxy = new Proxy(originalEnv, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return taskEnv[prop];
|
||||
}
|
||||
}
|
||||
return target[prop];
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return target[prop as any];
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return prop in target;
|
||||
}
|
||||
return prop in target;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
target[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
target[prop as any] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
delete taskEnv[prop];
|
||||
(taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
|
||||
return true;
|
||||
}
|
||||
delete target[prop];
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return delete target[prop as any];
|
||||
},
|
||||
ownKeys(target) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const keys = new Set<string | symbol>([
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
...Object.keys(taskEnv),
|
||||
]);
|
||||
taskEnv[deletedKeysSymbol]?.forEach((key) => {
|
||||
keys.delete(key);
|
||||
});
|
||||
return Array.from(keys);
|
||||
}
|
||||
return [
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && typeof prop === 'string') {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return {
|
||||
value: taskEnv[prop],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
defineProperty(target, prop, descriptor) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] =
|
||||
descriptor.value !== undefined ? String(descriptor.value) : undefined;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.defineProperty(target, prop as any, descriptor);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'env', {
|
||||
value: envProxy,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
|
||||
// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
|
||||
// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
|
||||
// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
|
||||
// working directory of the process, NOT the JS-level process.cwd() function.
|
||||
// To prevent cross-task interference, all file paths in the core package must be resolved to
|
||||
// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
|
||||
// before being passed to native APIs.
|
||||
const originalCwd = process.cwd;
|
||||
process.cwd = function () {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && taskEnv[cwdSymbol]) {
|
||||
return taskEnv[cwdSymbol];
|
||||
}
|
||||
return originalCwd.call(process);
|
||||
};
|
||||
|
||||
const originalChdir = process.chdir;
|
||||
process.chdir = function (directory: string) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const resolved = path.resolve(process.cwd(), directory);
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
const err = new Error(
|
||||
"ENOTDIR: not a directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(err as NodeJS.ErrnoException).code = 'ENOTDIR';
|
||||
throw err;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const chdirErr = new Error(
|
||||
"ENOENT: no such file or directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw chdirErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
taskEnv[cwdSymbol] = resolved;
|
||||
return;
|
||||
}
|
||||
return originalChdir.call(process, directory);
|
||||
};
|
||||
|
||||
export function getEnv(key: string): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const envVars: Record<string, string> = { ...process.env } as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
Object.assign(envVars, workspaceEnv);
|
||||
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
|
||||
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
let checkpointing = getEnvLocal('CHECKPOINTING')
|
||||
? getEnvLocal('CHECKPOINTING') === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
@@ -62,7 +282,7 @@ export async function loadConfig(
|
||||
}
|
||||
|
||||
const approvalMode =
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
@@ -91,8 +311,9 @@ export async function loadConfig(
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
debugMode: getEnvLocal('DEBUG') === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
env: envVars,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
excludeTools: settings.tools?.exclude || undefined,
|
||||
@@ -107,7 +328,7 @@ export async function loadConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: settings.telemetry?.logPrompts,
|
||||
},
|
||||
@@ -119,8 +340,8 @@ export async function loadConfig(
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
|
||||
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
@@ -179,7 +400,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, 'Config');
|
||||
await refreshAuthentication(config, 'Config', envVars);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -187,16 +408,19 @@ export async function loadConfig(
|
||||
export function setIsTrusted(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): boolean {
|
||||
if (INITIAL_FOLDER_TRUST !== undefined) {
|
||||
return INITIAL_FOLDER_TRUST === 'true';
|
||||
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
|
||||
if (folderTrustEnv !== undefined) {
|
||||
return folderTrustEnv === 'true';
|
||||
}
|
||||
return !!agentSettings?.isTrusted;
|
||||
}
|
||||
|
||||
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
export async function setTargetDir(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): Promise<string> {
|
||||
const originalCWD = process.cwd();
|
||||
const targetDir =
|
||||
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
|
||||
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
|
||||
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
|
||||
? agentSettings.workspacePath
|
||||
: undefined);
|
||||
@@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
);
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(targetDir);
|
||||
process.chdir(resolvedPath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(targetDir);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const parentDir = path.dirname(path.resolve(targetDir));
|
||||
resolvedPath = path.join(
|
||||
resolveToRealPath(parentDir),
|
||||
path.basename(targetDir),
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const isTestEnv =
|
||||
process.env['VITEST'] === 'true' ||
|
||||
process.env['NODE_ENV'] === 'test' ||
|
||||
process.argv.some((arg) => arg.includes('vitest')) ||
|
||||
resolvedPath.startsWith(resolveToRealPath(tmpdir()));
|
||||
|
||||
const allowedRoot = resolveToRealPath(
|
||||
getEnv('CODER_AGENT_ALLOWED_ROOT') ||
|
||||
(isTestEnv ? path.parse(resolvedPath).root : homedir()),
|
||||
);
|
||||
const relative = path.relative(allowedRoot, resolvedPath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Workspace path ${resolvedPath} is outside the allowed root directory`,
|
||||
);
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
if (isTestEnv) {
|
||||
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} else {
|
||||
throw new Error(`Workspace path ${resolvedPath} does not exist`);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Workspace path ${resolvedPath} is not a directory`);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
|
||||
);
|
||||
return originalCWD;
|
||||
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
export async function loadEnvironment(
|
||||
isTrusted: boolean = false,
|
||||
workspacePath: string = process.cwd(),
|
||||
): Promise<Record<string, string>> {
|
||||
// For untrusted workspaces, we completely bypass workspace-level .env loading
|
||||
// and only load environment variables from the user's trusted home directory.
|
||||
let envFilePath: string | null = null;
|
||||
if (isTrusted) {
|
||||
envFilePath = await findEnvFile(workspacePath);
|
||||
} else {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
envFilePath = homeGeminiEnvPath;
|
||||
} catch {
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
envFilePath = homeEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
const envVars: Record<string, string> = {};
|
||||
if (envFilePath) {
|
||||
dotenv.config({ path: envFilePath, override: true });
|
||||
try {
|
||||
const content = await fs.promises.readFile(envFilePath, 'utf-8');
|
||||
const parsed = dotenv.parse(content);
|
||||
for (const key in parsed) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(parsed, key) &&
|
||||
key !== '__proto__' &&
|
||||
key !== 'constructor' &&
|
||||
key !== 'prototype'
|
||||
) {
|
||||
envVars[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
async function findEnvFile(startDir: string): Promise<string | null> {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
try {
|
||||
await fs.promises.access(geminiEnvPath);
|
||||
return geminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
try {
|
||||
await fs.promises.access(envPath);
|
||||
return envPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
return homeEnvPath;
|
||||
}
|
||||
return null;
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
return homeGeminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
return homeEnvPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
logPrefix: string,
|
||||
envVars: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
if (getEnvLocal('USE_CCPA')) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
|
||||
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||
@@ -276,7 +612,7 @@ async function refreshAuthentication(
|
||||
);
|
||||
|
||||
const useComputeAdc =
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
|
||||
if (isHeadless || useComputeAdc) {
|
||||
@@ -305,11 +641,14 @@ async function refreshAuthentication(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
} else if (getEnvLocal('GEMINI_API_KEY')) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
await config.refreshAuth(
|
||||
AuthType.USE_GEMINI,
|
||||
getEnvLocal('GEMINI_API_KEY'),
|
||||
);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
|
||||
@@ -36,9 +36,12 @@ interface ExtensionConfig {
|
||||
excludeTools?: string[];
|
||||
}
|
||||
|
||||
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
|
||||
export function loadExtensions(
|
||||
workspaceDir: string,
|
||||
isTrusted: boolean = false,
|
||||
): GeminiCLIExtension[] {
|
||||
const allExtensions = [
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
|
||||
...loadExtensionsFromDir(homedir()),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
let mockHomeDir = '';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
homedir: () => mockHomeDir,
|
||||
};
|
||||
});
|
||||
|
||||
import { loadEnvironment } from './config.js';
|
||||
|
||||
describe('Vulnerability Mitigation: b-519269096', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary home directory securely using mkdtempSync to ensure hermeticity
|
||||
mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-'));
|
||||
|
||||
// Create a temporary workspace directory representing an untrusted repo
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
|
||||
);
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
|
||||
// Mock process.cwd to return the untrusted workspace
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(mockHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: In a SECURE system, these variables should NOT be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not load any variables from untrusted workspaces', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempWorkspaceDir, '.env'),
|
||||
'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: No variables should be loaded from the untrusted workspace
|
||||
expect(envVars['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toBeFalsy();
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should load all variables in trusted workspaces with isolation', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Load environment variables
|
||||
const envVars = await loadEnvironment(true);
|
||||
|
||||
// Assert: In a trusted workspace, variables should be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
|
||||
|
||||
// Assert: Global process.env should NOT be polluted
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -197,8 +197,7 @@ async function handleExecuteCommand(
|
||||
export async function createApp() {
|
||||
try {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const workspaceRoot = await setTargetDir(undefined);
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
@@ -209,13 +208,35 @@ export async function createApp() {
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
|
||||
// Change the global working directory to the workspace root during startup
|
||||
process.chdir(workspaceRoot);
|
||||
|
||||
// Load environment globally for the server startup
|
||||
const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
|
||||
// Only assign safe server-config variables to process.env to prevent credential leakage
|
||||
const allowedServerKeys = [
|
||||
'CODER_AGENT_PORT',
|
||||
'CODER_AGENT_WORKSPACE_PATH',
|
||||
'GCS_BUCKET_NAME',
|
||||
'LOG_LEVEL',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GEMINI_CLI_USE_COMPUTE_ADC',
|
||||
];
|
||||
for (const key of allowedServerKeys) {
|
||||
if (globalEnv[key] !== undefined) {
|
||||
process.env[key] = globalEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
}
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
|
||||
const workDir = setTargetDir(agentSettings);
|
||||
const workDir = await setTargetDir(agentSettings);
|
||||
await fse.ensureDir(workDir);
|
||||
const workspaceFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Validates a workspace path to prevent path traversal attacks.
|
||||
*
|
||||
* @param workspacePath The path to validate.
|
||||
* @param allowedRoot The root directory the path must be within. Defaults to CWD.
|
||||
* @returns The resolved, safe path.
|
||||
* @throws An error if the path is invalid or outside the allowed root.
|
||||
*/
|
||||
export async function validateWorkspacePath(
|
||||
workspacePath?: string,
|
||||
allowedRoot: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const trimmedPath = workspacePath?.trim();
|
||||
if (!trimmedPath) {
|
||||
return resolveToRealPath(allowedRoot);
|
||||
}
|
||||
|
||||
if (trimmedPath.includes('\0')) {
|
||||
throw new Error('Security violation: Null byte detected in path.');
|
||||
}
|
||||
|
||||
try {
|
||||
const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
|
||||
const resolvedWorkspacePath = path.resolve(
|
||||
canonicalAllowedRoot,
|
||||
trimmedPath,
|
||||
);
|
||||
const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
|
||||
|
||||
// Check if the resolved path is within the allowed root directory
|
||||
if (
|
||||
canonicalWorkspacePath !== canonicalAllowedRoot &&
|
||||
!isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(canonicalWorkspacePath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`The path "${trimmedPath}" is not a directory.`);
|
||||
}
|
||||
|
||||
return canonicalWorkspacePath;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
throw new Error(`The path "${trimmedPath}" does not exist.`);
|
||||
}
|
||||
throw e; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.56.0-preview.1",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.56.0-preview.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
|
||||
@@ -319,6 +319,41 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: 'MAX_TOKENS_EXCEEDED', reason: 'MAX_TOKENS' },
|
||||
{ type: 'SAFETY_BLOCKED', reason: 'SAFETY' },
|
||||
{ type: 'RECITATION_BLOCKED', reason: 'RECITATION' },
|
||||
{ type: 'OTHER_BLOCKED', reason: 'OTHER' },
|
||||
{ type: 'THINKING_ONLY_RESPONSE', reason: 'STOP' },
|
||||
])(
|
||||
'should gracefully handle InvalidStreamError with type $type in ACP session',
|
||||
async ({ type, reason }) => {
|
||||
const error = new InvalidStreamError(
|
||||
`Stream failed with ${reason}`,
|
||||
type as InvalidStreamError['type'],
|
||||
);
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
async function* errorGen(): AsyncGenerator<
|
||||
ServerGeminiStreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
yield* [] as any;
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
});
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
|
||||
@@ -510,7 +510,12 @@ export class Session {
|
||||
(error.type === 'NO_RESPONSE_TEXT' ||
|
||||
error.type === 'NO_FINISH_REASON' ||
|
||||
error.type === 'MALFORMED_FUNCTION_CALL' ||
|
||||
error.type === 'UNEXPECTED_TOOL_CALL'))
|
||||
error.type === 'UNEXPECTED_TOOL_CALL' ||
|
||||
error.type === 'MAX_TOKENS_EXCEEDED' ||
|
||||
error.type === 'SAFETY_BLOCKED' ||
|
||||
error.type === 'RECITATION_BLOCKED' ||
|
||||
error.type === 'OTHER_BLOCKED' ||
|
||||
error.type === 'THINKING_ONLY_RESPONSE'))
|
||||
) {
|
||||
// The stream ended with an empty response or malformed tool call.
|
||||
// Treat this as a graceful end to the model's turn rather than a crash.
|
||||
|
||||
@@ -103,7 +103,10 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(installCommand)
|
||||
.fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -27,7 +27,10 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions validate command', () => {
|
||||
it('should fail if no path is provided', () => {
|
||||
const validationParser = yargs([]).command(validateCommand).fail(false);
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(validateCommand)
|
||||
.fail(false);
|
||||
expect(() => validationParser.parse('validate')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('mcp command', () => {
|
||||
});
|
||||
|
||||
it('should show help when no subcommand is provided', async () => {
|
||||
const yargsInstance = yargs();
|
||||
const yargsInstance = yargs().locale('en');
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
|
||||
|
||||
const parser = yargsInstance.command(mcpCommand).help();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
@@ -110,6 +112,7 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -165,6 +168,7 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -193,6 +197,7 @@ describe('runNonInteractive', () => {
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -1820,7 +1825,6 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
@@ -2298,7 +2302,13 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2312,7 +2322,7 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
|
||||
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -2325,7 +2335,13 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2341,9 +2357,7 @@ describe('runNonInteractive', () => {
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2355,7 +2369,13 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2371,8 +2391,33 @@ describe('runNonInteractive', () => {
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"error": {');
|
||||
expect(output).toContain('"type": "INVALID_STREAM"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Custom malformed function call message',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream malformed',
|
||||
prompt_id: 'prompt-id-invalid-malformed',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[ERROR] Custom malformed function call message\n',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
@@ -433,8 +439,31 @@ export async function runNonInteractive(
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
} else if (event.type === GeminiEventType.InvalidStream) {
|
||||
invalidStreamError =
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
const eventValue = event.value;
|
||||
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
|
||||
invalidStreamError = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
|
||||
invalidStreamError = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
|
||||
invalidStreamError = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
|
||||
invalidStreamError = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
|
||||
invalidStreamError = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'OTHER_BLOCKED') {
|
||||
invalidStreamError = OTHER_BLOCKED_MESSAGE;
|
||||
} else {
|
||||
invalidStreamError =
|
||||
eventValue?.message?.trim() ||
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
eventValue?.type || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
|
||||
@@ -78,6 +79,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession: original.LegacyAgentSession,
|
||||
geminiPartsToContentParts: original.geminiPartsToContentParts,
|
||||
@@ -199,6 +201,7 @@ describe('runNonInteractive', () => {
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -2457,6 +2460,126 @@ describe('runNonInteractive', () => {
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"error": {');
|
||||
expect(output).toContain('"type": "INVALID_STREAM"');
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Malformed call',
|
||||
},
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
geminiPartsToContentParts,
|
||||
displayContentToString,
|
||||
debugLogger,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
@@ -332,14 +338,17 @@ export async function runNonInteractive({
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
const emitFinalResult = (errorPayload?: {
|
||||
type: string;
|
||||
message: string;
|
||||
}): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
status: errorPayload ? 'error' : 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
@@ -350,7 +359,7 @@ export async function runNonInteractive({
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
errorPayload,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
@@ -545,6 +554,52 @@ export async function runNonInteractive({
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event._meta?.['code'] === 'INVALID_STREAM') {
|
||||
const errorTypeVal = event._meta?.['errorType'];
|
||||
const errorType =
|
||||
typeof errorTypeVal === 'string' ? errorTypeVal : undefined;
|
||||
|
||||
let errorMessage = event.message;
|
||||
if (errorType === 'NO_RESPONSE_TEXT') {
|
||||
errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (errorType === 'THINKING_ONLY_RESPONSE') {
|
||||
errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (errorType === 'MAX_TOKENS_EXCEEDED') {
|
||||
errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (errorType === 'SAFETY_BLOCKED') {
|
||||
errorMessage = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (errorType === 'RECITATION_BLOCKED') {
|
||||
errorMessage = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (errorType === 'OTHER_BLOCKED') {
|
||||
errorMessage = OTHER_BLOCKED_MESSAGE;
|
||||
}
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: errorMessage,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[ERROR] ${errorMessage}\n`);
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
errorType || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
// If it's a fatal stream error, we should terminate and output final results
|
||||
emitFinalResult({
|
||||
type: 'INVALID_STREAM',
|
||||
message: errorMessage,
|
||||
});
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
@@ -613,7 +668,7 @@ export async function runNonInteractive({
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
emitFinalResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -86,6 +86,7 @@ export const DialogManager = ({
|
||||
message={quotaState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={quotaState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!quotaState.proQuotaRequest.isModelNotFoundError}
|
||||
isCapacityExceeded={!!quotaState.proQuotaRequest.isCapacityExceeded}
|
||||
authType={quotaState.proQuotaRequest.authType}
|
||||
tierName={config?.getUserTierName()}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
|
||||
@@ -271,6 +271,40 @@ describe('ProQuotaDialog', () => {
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render keep trying, switch, and stop options even if isTerminalQuotaError is true when isCapacityExceeded is true', async () => {
|
||||
const { unmount } = await render(
|
||||
<ProQuotaDialog
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
message="capacity error"
|
||||
isTerminalQuotaError={true}
|
||||
isCapacityExceeded={true}
|
||||
isModelNotFoundError={false}
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once',
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: 'Switch to gemini-2.5-flash',
|
||||
value: 'retry_always',
|
||||
key: 'retry_always',
|
||||
},
|
||||
{ label: 'Stop', value: 'retry_later', key: 'retry_later' },
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it is a model not found error', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ProQuotaDialogProps {
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
isCapacityExceeded?: boolean;
|
||||
authType?: AuthType;
|
||||
tierName?: string;
|
||||
onChoice: (
|
||||
@@ -30,6 +31,7 @@ export function ProQuotaDialog({
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
isCapacityExceeded,
|
||||
authType,
|
||||
tierName,
|
||||
onChoice,
|
||||
@@ -49,6 +51,24 @@ export function ProQuotaDialog({
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isCapacityExceeded) {
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
value: 'retry_once' as const,
|
||||
key: 'retry_once',
|
||||
},
|
||||
{
|
||||
label: `Switch to ${fallbackModel}`,
|
||||
value: 'retry_always' as const,
|
||||
key: 'retry_always',
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
value: 'retry_later' as const,
|
||||
key: 'retry_later',
|
||||
},
|
||||
];
|
||||
} else if (isModelNotFoundError || isTerminalQuotaError) {
|
||||
const isUltra = isUltraTier(tierName);
|
||||
|
||||
@@ -75,7 +95,7 @@ export function ProQuotaDialog({
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// capacity error
|
||||
// capacity error or generic fallback
|
||||
items = [
|
||||
{
|
||||
label: 'Keep trying',
|
||||
|
||||
@@ -36,6 +36,18 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const errorsA = a.api.errorsByType || {};
|
||||
const errorsB = b.api.errorsByType || {};
|
||||
const keysA = Object.keys(errorsA);
|
||||
const keysB = Object.keys(errorsB);
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of keysA) {
|
||||
if (errorsA[key] !== errorsB[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
a.tokens.input !== b.tokens.input ||
|
||||
a.tokens.prompt !== b.tokens.prompt ||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ProQuotaDialogRequest {
|
||||
message: string;
|
||||
isTerminalQuotaError: boolean;
|
||||
isModelNotFoundError?: boolean;
|
||||
isCapacityExceeded?: boolean;
|
||||
authType?: AuthType;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
@@ -63,8 +63,10 @@ describe('handleAtCommand', () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
@@ -1467,8 +1469,8 @@ describe('handleAtCommand', () => {
|
||||
});
|
||||
|
||||
it('should resolve files in multiple workspace directories', async () => {
|
||||
const secondRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'second-root-'),
|
||||
const secondRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'second-root-')),
|
||||
);
|
||||
try {
|
||||
const fileContent = 'Second root content';
|
||||
@@ -1649,8 +1651,10 @@ describe('checkPermissions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
mockConfig = {
|
||||
|
||||
@@ -37,8 +37,8 @@ describe('handleAtCommand with Agents', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'agent-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -1045,6 +1046,107 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should record tool responses in history when the model was switched due to a quota error', async () => {
|
||||
// Regression test: returning early on a quota-triggered model switch
|
||||
// without recording the responses leaves the already-recorded
|
||||
// functionCall unpaired, which corrupts all subsequent requests.
|
||||
const responseParts: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'testTool',
|
||||
id: 'call1',
|
||||
response: { output: 'tool result' },
|
||||
},
|
||||
},
|
||||
];
|
||||
const completedToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'call1',
|
||||
name: 'testTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-quota',
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
responseSubmittedToGemini: false,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts,
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: { displayName: 'MockTool' },
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
} as TrackedCompletedToolCall,
|
||||
];
|
||||
|
||||
const client = new MockedGeminiClientClass(mockConfig);
|
||||
const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database');
|
||||
|
||||
let capturedOnComplete:
|
||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
mockUseToolScheduler.mockImplementation((onComplete) => {
|
||||
capturedOnComplete = onComplete;
|
||||
return [
|
||||
[],
|
||||
mockScheduleToolCalls,
|
||||
mockMarkToolsAsSubmitted,
|
||||
vi.fn(),
|
||||
mockCancelAllToolCalls,
|
||||
0,
|
||||
];
|
||||
});
|
||||
|
||||
await renderHookWithProviders(() =>
|
||||
useGeminiStream(
|
||||
client,
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
true, // modelSwitchedFromQuotaError
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
false,
|
||||
mockConsumeUserHint,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await capturedOnComplete(completedToolCalls);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
|
||||
// The tool response must be paired with its functionCall in history,
|
||||
// with no steering-hint text ahead of it...
|
||||
expect(client.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: responseParts,
|
||||
});
|
||||
// ...the turn must NOT auto-continue on the fallback model...
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||
// ...and the pending hint is left for the next real submit.
|
||||
expect(mockConsumeUserHint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT stop responding when only update_topic is called', async () => {
|
||||
const topicToolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
@@ -1772,6 +1874,120 @@ describe('useGeminiStream', () => {
|
||||
expect(mockCancelAllToolCalls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should transition to Idle state when cancelled while a tool call is in progress and completes', async () => {
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: { callId: 'call1', name: 'tool1', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
build: vi.fn().mockImplementation((_) => ({
|
||||
getDescription: () => `Mock description`,
|
||||
})),
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
liveOutput: '...',
|
||||
} as TrackedExecutingToolCall,
|
||||
];
|
||||
|
||||
const { result } = await renderTestHook(toolCalls);
|
||||
|
||||
// State is `Responding` because a tool is running
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// Try to cancel
|
||||
simulateEscapeKeyPress();
|
||||
|
||||
// Trigger the onComplete callback with the cancelled tool call
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([
|
||||
{
|
||||
...toolCalls[0],
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: [],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// The final state should be idle because the cancelled tool call was marked as submitted
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should append cancelled tool responses to history when cancelled while a tool call is in progress and completes with response parts', async () => {
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
{
|
||||
request: { callId: 'call1', name: 'tool1', args: {} },
|
||||
status: CoreToolCallStatus.Executing,
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
build: vi.fn().mockImplementation((_) => ({
|
||||
getDescription: () => `Mock description`,
|
||||
})),
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
},
|
||||
startTime: Date.now(),
|
||||
liveOutput: '...',
|
||||
} as TrackedExecutingToolCall,
|
||||
];
|
||||
|
||||
const { result, client } = await renderTestHook(toolCalls);
|
||||
|
||||
// State is `Responding` because a tool is running
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
|
||||
// Try to cancel
|
||||
simulateEscapeKeyPress();
|
||||
|
||||
const expectedResponseParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'tool1',
|
||||
id: 'call1',
|
||||
response: { error: 'cancelled' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Trigger the onComplete callback with the cancelled tool call having non-empty response parts
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([
|
||||
{
|
||||
...toolCalls[0],
|
||||
status: CoreToolCallStatus.Cancelled,
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: expectedResponseParts,
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that addHistory was called with the combined response parts
|
||||
expect(client.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: expectedResponseParts,
|
||||
});
|
||||
|
||||
// The final state should be idle because the cancelled tool call was marked as submitted
|
||||
expect(result.current.streamingState).toBe(StreamingState.Idle);
|
||||
});
|
||||
|
||||
it('should cancel a request when a tool is awaiting confirmation', async () => {
|
||||
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
const toolCalls: TrackedToolCall[] = [
|
||||
@@ -2306,6 +2522,68 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use TRUE_EMPTY_RESPONSE_MESSAGE when receiving an invalid stream event of type NO_RESPONSE_TEXT', async () => {
|
||||
mockSendMessageStream.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'empty response text',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the event message when receiving a non-NO_RESPONSE_TEXT invalid stream event', async () => {
|
||||
mockSendMessageStream.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'MALFORMED_FUNCTION_CALL',
|
||||
message: 'Custom malformed function call message',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = await renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('test query');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Custom malformed function call message',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApprovalModeChange', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
uiTelemetryService,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
@@ -45,6 +46,12 @@ import {
|
||||
buildToolVisibilityContext,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
THINKING_ONLY_COMPRESS_SUGGESTION,
|
||||
MAX_TOKENS_EXCEEDED_SUGGESTION,
|
||||
SAFETY_BLOCKED_MESSAGE,
|
||||
RECITATION_BLOCKED_MESSAGE,
|
||||
OTHER_BLOCKED_MESSAGE,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -54,6 +61,7 @@ import type {
|
||||
ServerGeminiContentEvent as ContentEvent,
|
||||
ServerGeminiFinishedEvent,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
ServerGeminiInvalidStreamEvent,
|
||||
ThoughtSummary,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
@@ -1229,6 +1237,61 @@ export const useGeminiStream = (
|
||||
],
|
||||
);
|
||||
|
||||
const handleInvalidStreamEvent = useCallback(
|
||||
(
|
||||
eventValue: ServerGeminiInvalidStreamEvent['value'],
|
||||
userMessageTimestamp: number,
|
||||
) => {
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
||||
|
||||
let text =
|
||||
eventValue?.message?.trim() || 'Invalid stream received from model';
|
||||
if (eventValue?.type === 'NO_RESPONSE_TEXT') {
|
||||
text = TRUE_EMPTY_RESPONSE_MESSAGE;
|
||||
} else if (eventValue?.type === 'THINKING_ONLY_RESPONSE') {
|
||||
text = THINKING_ONLY_COMPRESS_SUGGESTION;
|
||||
} else if (eventValue?.type === 'MAX_TOKENS_EXCEEDED') {
|
||||
text = MAX_TOKENS_EXCEEDED_SUGGESTION;
|
||||
} else if (eventValue?.type === 'SAFETY_BLOCKED') {
|
||||
text = SAFETY_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'RECITATION_BLOCKED') {
|
||||
text = RECITATION_BLOCKED_MESSAGE;
|
||||
} else if (eventValue?.type === 'OTHER_BLOCKED') {
|
||||
text = OTHER_BLOCKED_MESSAGE;
|
||||
}
|
||||
|
||||
// Log semantic error telemetry without double-counting requests
|
||||
uiTelemetryService.recordSemanticValidationError(
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel(),
|
||||
eventValue?.type || 'INVALID_STREAM',
|
||||
);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
||||
setThought(null); // Reset thought when there's an error
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
setThought,
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
config,
|
||||
geminiClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCitationEvent = useCallback(
|
||||
(text: string, userMessageTimestamp: number) => {
|
||||
if (!showCitations(settings)) {
|
||||
@@ -1541,8 +1604,10 @@ export const useGeminiStream = (
|
||||
loopDetectedRef.current = true;
|
||||
break;
|
||||
case ServerGeminiEventType.Retry:
|
||||
// Handled transparently by the backend stream retries.
|
||||
break;
|
||||
case ServerGeminiEventType.InvalidStream:
|
||||
// Will add the missing logic later
|
||||
handleInvalidStreamEvent(event.value, userMessageTimestamp);
|
||||
break;
|
||||
default: {
|
||||
// enforces exhaustive switch-case
|
||||
@@ -1575,6 +1640,7 @@ export const useGeminiStream = (
|
||||
handleChatModelEvent,
|
||||
handleAgentExecutionStoppedEvent,
|
||||
handleAgentExecutionBlockedEvent,
|
||||
handleInvalidStreamEvent,
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
@@ -1886,6 +1952,30 @@ export const useGeminiStream = (
|
||||
},
|
||||
);
|
||||
|
||||
if (turnCancelledRef.current) {
|
||||
setIsResponding(false);
|
||||
const geminiTools = completedAndReadyToSubmitTools.filter(
|
||||
(t) => !t.request.isClientInitiated,
|
||||
);
|
||||
if (geminiClient && geminiTools.length > 0) {
|
||||
const combinedParts = geminiTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
if (combinedParts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: combinedParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
const callIdsToMarkAsSubmitted = toolCalls.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize any client-initiated tools as soon as they are done.
|
||||
const clientTools = completedAndReadyToSubmitTools.filter(
|
||||
(t) => t.request.isClientInitiated,
|
||||
@@ -2020,6 +2110,27 @@ export const useGeminiStream = (
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
|
||||
// Don't continue if model was switched due to quota error, but still
|
||||
// record the responses: the matching functionCall is already in history,
|
||||
// and leaving it unpaired corrupts every subsequent request. Any pending
|
||||
// steering hint is deliberately left unconsumed so it rides along with
|
||||
// the next query the user actually submits.
|
||||
if (modelSwitchedFromQuotaError) {
|
||||
if (geminiClient && responsesToSend.length > 0) {
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: responsesToSend,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumeUserHint) {
|
||||
const userHint = consumeUserHint();
|
||||
if (userHint && userHint.trim().length > 0) {
|
||||
@@ -2030,21 +2141,10 @@ export const useGeminiStream = (
|
||||
}
|
||||
}
|
||||
|
||||
const callIdsToMarkAsSubmitted = geminiTools.map(
|
||||
(toolCall) => toolCall.request.callId,
|
||||
);
|
||||
|
||||
const prompt_ids = geminiTools.map(
|
||||
(toolCall) => toolCall.request.prompt_id,
|
||||
);
|
||||
|
||||
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
||||
|
||||
// Don't continue if model was switched due to quota error
|
||||
if (modelSwitchedFromQuotaError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
submitQuery(
|
||||
responsesToSend,
|
||||
@@ -2066,6 +2166,7 @@ export const useGeminiStream = (
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
setIsResponding,
|
||||
toolCalls,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('usePrivacySettings', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('should throw error when content generator is not a CodeAssistServer', async () => {
|
||||
it('should report tier unavailable when OAuth is not being used', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
@@ -58,7 +58,8 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('Oauth not being used');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle paid tier users correctly', async () => {
|
||||
@@ -79,7 +80,7 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when CodeAssistServer has no projectId', async () => {
|
||||
it('should report tier unavailable when CodeAssistServer has no projectId', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
userTier: UserTierId.FREE,
|
||||
} as unknown as CodeAssistServer);
|
||||
@@ -90,9 +91,63 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe(
|
||||
'CodeAssist server is missing a project ID',
|
||||
);
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the user has no tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: undefined,
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.isFreeTier).toBeUndefined();
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the backend reports no current tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('User does not have a current tier')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should surface unexpected errors while loading opt-in settings', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('network unavailable')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('network unavailable');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update data collection opt-in setting', async () => {
|
||||
|
||||
@@ -18,8 +18,22 @@ export interface PrivacyState {
|
||||
error?: string;
|
||||
isFreeTier?: boolean;
|
||||
dataCollectionOptIn?: boolean;
|
||||
/**
|
||||
* True when the signed-in account has no consumer Code Assist tier, so the
|
||||
* data-collection opt-in isn't applicable (e.g. Workspace/enterprise accounts,
|
||||
* or an OAuth login without a Google Cloud project). This is an expected state
|
||||
* rendered as a friendly, actionable notice rather than a raw backend `error`.
|
||||
*/
|
||||
isTierUnavailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the current account can't be mapped to a consumer Code Assist
|
||||
* tier, so the privacy opt-in can't be shown. Handled by rendering a friendly
|
||||
* notice instead of surfacing a raw backend error.
|
||||
*/
|
||||
class TierUnavailableError extends Error {}
|
||||
|
||||
export const usePrivacySettings = (config: Config) => {
|
||||
const [privacyState, setPrivacyState] = useState<PrivacyState>({
|
||||
isLoading: true,
|
||||
@@ -34,7 +48,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
const server = getCodeAssistServerOrFail(config);
|
||||
const tier = server.userTier;
|
||||
if (tier === undefined) {
|
||||
throw new Error('Could not determine user tier.');
|
||||
// The account has no resolved Code Assist tier (e.g. Workspace or an
|
||||
// incomplete OAuth). Show a friendly notice instead of a raw error.
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tier !== UserTierId.FREE) {
|
||||
// We don't need to fetch opt-out info since non-free tier
|
||||
@@ -53,6 +73,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: optIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -74,6 +101,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: updatedOptIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -92,13 +126,30 @@ export const usePrivacySettings = (config: Config) => {
|
||||
function getCodeAssistServerOrFail(config: Config): CodeAssistServer {
|
||||
const server = getCodeAssistServer(config);
|
||||
if (server === undefined) {
|
||||
throw new Error('Oauth not being used');
|
||||
throw new TierUnavailableError('Oauth not being used');
|
||||
} else if (server.projectId === undefined) {
|
||||
throw new Error('CodeAssist server is missing a project ID');
|
||||
throw new TierUnavailableError('CodeAssist server is missing a project ID');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an error means the account simply has no consumer Code
|
||||
* Assist tier, as opposed to an unexpected failure. Covers the local
|
||||
* {@link TierUnavailableError} as well as the Code Assist backend error (e.g.
|
||||
* "User does not have a current tier") returned for Workspace/enterprise
|
||||
* accounts.
|
||||
*/
|
||||
function isTierUnavailableError(error: unknown): boolean {
|
||||
if (error instanceof TierUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Match the specific Code Assist backend message rather than a broad substring
|
||||
// so an unrelated error that merely mentions "tier" isn't masked as a benign notice.
|
||||
return /does not have a current tier/i.test(message);
|
||||
}
|
||||
|
||||
async function getRemoteDataCollectionOptIn(
|
||||
server: CodeAssistServer,
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -222,6 +222,126 @@ describe('useQuotaAndFallback', () => {
|
||||
await promise!;
|
||||
});
|
||||
|
||||
it('should auto-retry terminal quota capacity failures in low verbosity mode', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
errorVerbosity: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const intent = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new TerminalQuotaError(
|
||||
'pro capacity exhausted',
|
||||
mockGoogleApiError,
|
||||
undefined,
|
||||
'MODEL_CAPACITY_EXHAUSTED',
|
||||
),
|
||||
);
|
||||
|
||||
expect(intent).toBe('retry_once');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-retry capacity failures matched by regex on message in low verbosity mode', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
errorVerbosity: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const intent = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('you have exhausted your capacity limit'),
|
||||
);
|
||||
|
||||
expect(intent).toBe('retry_once');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should auto-retry capacity failures thrown as raw string error in low verbosity mode', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
errorVerbosity: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const intent = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
'MODEL_CAPACITY_EXHAUSTED',
|
||||
);
|
||||
|
||||
expect(intent).toBe('retry_once');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should show high demand message for MODEL_CAPACITY_EXHAUSTED', async () => {
|
||||
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;
|
||||
|
||||
const error = new TerminalQuotaError(
|
||||
'pro capacity exhausted',
|
||||
mockGoogleApiError,
|
||||
undefined,
|
||||
'MODEL_CAPACITY_EXHAUSTED',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void handler('gemini-pro', 'gemini-flash', error);
|
||||
});
|
||||
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.isCapacityExceeded).toBe(true);
|
||||
expect(result.current.proQuotaRequest?.message).toContain(
|
||||
'We are currently experiencing high demand',
|
||||
);
|
||||
expect(result.current.proQuotaRequest?.message).not.toContain(
|
||||
'Usage limit reached',
|
||||
);
|
||||
});
|
||||
|
||||
describe('Interactive Fallback', () => {
|
||||
it('should set an interactive request for a terminal quota error', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
@@ -525,6 +645,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({
|
||||
@@ -944,7 +1160,7 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a special message when falling back from the preview model, but do not show periodical check message for flash model fallback', async () => {
|
||||
it('should show a special message when falling back from the preview model, but not show the periodical check message for flash model fallbacks', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
|
||||
@@ -45,6 +45,9 @@ interface UseQuotaAndFallbackArgs {
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
}
|
||||
|
||||
const isObject = (val: unknown): val is Record<string, unknown> =>
|
||||
typeof val === 'object' && val !== null;
|
||||
|
||||
export function useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
@@ -79,6 +82,28 @@ export function useQuotaAndFallback({
|
||||
let message: string;
|
||||
let isTerminalQuotaError = false;
|
||||
let isModelNotFoundError = false;
|
||||
|
||||
const errorObj = isObject(error) ? error : null;
|
||||
|
||||
const errorReasonValue = errorObj?.['reason'];
|
||||
const errorReason =
|
||||
typeof errorReasonValue === 'string' ? errorReasonValue : undefined;
|
||||
|
||||
const errorMessageValue = errorObj?.['message'];
|
||||
const errorMessage =
|
||||
typeof errorMessageValue === 'string' ? errorMessageValue : undefined;
|
||||
|
||||
const isCapacityExceeded =
|
||||
errorReason === 'MODEL_CAPACITY_EXHAUSTED' ||
|
||||
errorReason === 'MODEL_CAPACITY_EXCEEDED' ||
|
||||
(typeof errorMessage === 'string' &&
|
||||
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
|
||||
errorMessage,
|
||||
)) ||
|
||||
(typeof error === 'string' &&
|
||||
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
|
||||
error,
|
||||
));
|
||||
const usageLimitReachedModel = isProModel(failedModel)
|
||||
? 'all Pro models'
|
||||
: failedModel;
|
||||
@@ -121,21 +146,46 @@ export function useQuotaAndFallback({
|
||||
}
|
||||
|
||||
// Default: Show existing ProQuotaDialog (for overageStrategy: 'never' or non-G1 users)
|
||||
const messageLines = [
|
||||
`Usage limit reached for ${usageLimitReachedModel}.`,
|
||||
error.retryDelayMs
|
||||
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
|
||||
: null,
|
||||
`/stats model for usage details`,
|
||||
`/model to switch models.`,
|
||||
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
? `/auth to switch to API key.`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
message = messageLines.join('\n');
|
||||
if (isCapacityExceeded) {
|
||||
const messageLines = [
|
||||
`We are currently experiencing high demand for ${usageLimitReachedModel}.`,
|
||||
'We apologize and appreciate your patience.',
|
||||
error.retryDelayMs
|
||||
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
|
||||
: null,
|
||||
`/model to switch models.`,
|
||||
].filter(Boolean);
|
||||
message = messageLines.join('\n');
|
||||
} else {
|
||||
const messageLines = [
|
||||
`Usage limit reached for ${usageLimitReachedModel}.`,
|
||||
error.retryDelayMs
|
||||
? `Access resets at ${getResetTimeMessage(error.retryDelayMs)}.`
|
||||
: null,
|
||||
`/stats model for usage details`,
|
||||
`/model to switch models.`,
|
||||
contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
? `/auth to switch to API key.`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
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.`,
|
||||
@@ -161,7 +211,7 @@ export function useQuotaAndFallback({
|
||||
// without interrupting with a dialog.
|
||||
if (
|
||||
errorVerbosity === 'low' &&
|
||||
!isTerminalQuotaError &&
|
||||
(!isTerminalQuotaError || isCapacityExceeded) &&
|
||||
!isModelNotFoundError
|
||||
) {
|
||||
return 'retry_once';
|
||||
@@ -184,6 +234,7 @@ export function useQuotaAndFallback({
|
||||
message,
|
||||
isTerminalQuotaError,
|
||||
isModelNotFoundError,
|
||||
isCapacityExceeded,
|
||||
authType: contentGeneratorConfig?.authType,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -71,6 +71,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
expectedText: 'Gemini Code Assist Privacy Notice',
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
expectedText: 'GOOGLE_CLOUD_PROJECT',
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state',
|
||||
mockState: { isFreeTier: true },
|
||||
@@ -101,6 +106,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state (no selection)',
|
||||
mockState: { isFreeTier: true },
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CloudFreePrivacyNotice = ({
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
(privacyState.error || privacyState.isFreeTier === false) &&
|
||||
(privacyState.error ||
|
||||
privacyState.isFreeTier === false ||
|
||||
privacyState.isTierUnavailable) &&
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
@@ -53,6 +55,35 @@ export const CloudFreePrivacyNotice = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isTierUnavailable) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Gemini Code Assist Privacy Notice
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
The data collection opt-in isn't available for this account
|
||||
because it doesn't have a Gemini Code Assist for Individuals
|
||||
(free) tier.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
If you're on a Google Workspace or enterprise account, use the
|
||||
Vertex AI / Google Cloud path instead by setting the
|
||||
GOOGLE_CLOUD_PROJECT environment variable to your Google Cloud
|
||||
project.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
Learn more: https://geminicli.com/docs/get-started/authentication/
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.secondary}>Press Esc to exit.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isFreeTier === false) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-open: uses (deny default) and explicitly allows the operations the
|
||||
;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list
|
||||
;; minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest)
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +76,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -25,3 +88,48 @@
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-open reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic (local dev/test servers, the debugger on :9229,
|
||||
;; OAuth localhost callbacks)
|
||||
(allow network-inbound (local ip "*:*"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; allow all outbound network traffic
|
||||
(allow network-outbound)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-proxied: uses (deny default) and explicitly allows the operations
|
||||
;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the
|
||||
;; allow-list minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest).
|
||||
;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed
|
||||
;; for PTY support under deny default.
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +78,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -22,16 +87,52 @@
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; deny all inbound network traffic EXCEPT on debugger port
|
||||
(deny network-inbound)
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-proxied reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic on debugger port
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; deny all outbound network traffic EXCEPT through proxy on localhost:8877
|
||||
;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
|
||||
;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
|
||||
(deny network-outbound)
|
||||
(allow network-outbound (remote tcp "localhost:8877"))
|
||||
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Strip SBPL comments (`; ...` to end of line) so assertions run against the
|
||||
* actual sandbox rules rather than any keywords that happen to appear in the
|
||||
* explanatory comments.
|
||||
*/
|
||||
function readRules(profile: string): string {
|
||||
return readFileSync(path.join(utilsDir, profile), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const commentStart = line.indexOf(';');
|
||||
return commentStart === -1 ? line : line.slice(0, commentStart);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const PERMISSIVE_PROFILES = [
|
||||
'sandbox-macos-permissive-open.sb',
|
||||
'sandbox-macos-permissive-proxied.sb',
|
||||
];
|
||||
|
||||
// These two profiles are the default macOS Seatbelt profiles, so the invariants
|
||||
// below must never silently regress. Keep them deny-default and confirm the
|
||||
// reviewed allow-list stays in place.
|
||||
describe('macOS permissive Seatbelt profiles', () => {
|
||||
describe.each(PERMISSIVE_PROFILES)('%s', (profile) => {
|
||||
const rules = readRules(profile);
|
||||
|
||||
it('uses a deny-default foundation', () => {
|
||||
expect(rules).toContain('(deny default)');
|
||||
});
|
||||
|
||||
it('does not use an allow-default foundation', () => {
|
||||
expect(rules).not.toContain('(allow default)');
|
||||
});
|
||||
|
||||
it('does not permit filesystem (un)mounts', () => {
|
||||
expect(rules).not.toMatch(/file-mount/);
|
||||
expect(rules).not.toMatch(/file-unmount/);
|
||||
});
|
||||
|
||||
it('does not grant broad service lookups', () => {
|
||||
expect(rules).not.toMatch(/launchd/);
|
||||
expect(rules).not.toMatch(/launchservices/i);
|
||||
});
|
||||
|
||||
it('allows binding local ports for dev/test servers', () => {
|
||||
expect(rules).toContain('(allow network-bind (local ip "*:*"))');
|
||||
});
|
||||
});
|
||||
|
||||
it('permissive-open keeps broad inbound and outbound network', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-open.sb');
|
||||
expect(rules).toContain('(allow network-inbound (local ip "*:*"))');
|
||||
expect(rules).toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
|
||||
it('permissive-proxied confines outbound to the proxy', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-proxied.sb');
|
||||
expect(rules).toContain(
|
||||
'(allow network-outbound (remote tcp "localhost:8877"))',
|
||||
);
|
||||
// Proxied mode must never grant unrestricted outbound network.
|
||||
expect(rules).not.toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -292,6 +292,140 @@ describe('sandbox', () => {
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
|
||||
it('should fall back to embedded profile if the .sb file is missing on disk', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
);
|
||||
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
const onSpy = vi.spyOn(process, 'on');
|
||||
const offSpy = vi.spyOn(process, 'off');
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
|
||||
// Verify fs.writeFileSync was called with the temp profile file, content, and 0o600 permissions
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
expect.stringContaining('deny default'),
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify spawn was called with the temp profile file
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.arrayContaining([
|
||||
'-f',
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
]),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
|
||||
// Verify process on/off hooks were called for exit, SIGINT, and SIGTERM cleanups
|
||||
expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function));
|
||||
expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
|
||||
expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
|
||||
|
||||
// Verify fs.unlinkSync was called to clean up the temp file
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
'strict-open',
|
||||
'strict-proxied',
|
||||
])(
|
||||
'should fall back to embedded content successfully for profile "%s"',
|
||||
async (profile) => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
// Mock existsSync to return false for the profile file but true for temp directories
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes('gemini-sandbox-macos-'),
|
||||
);
|
||||
|
||||
vi.stubEnv('SEATBELT_PROFILE', profile);
|
||||
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
|
||||
// Verify fs.writeFileSync was called with the correct file mode and content for the profile
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`gemini-sandbox-macos-${profile}-`),
|
||||
expect.stringContaining('deny default'),
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle Docker execution', async () => {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
|
||||
+212
-149
@@ -39,6 +39,7 @@ import {
|
||||
SANDBOX_PROXY_NAME,
|
||||
BUILTIN_SEATBELT_PROFILES,
|
||||
} from './sandboxUtils.js';
|
||||
import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -56,6 +57,41 @@ export async function start_sandbox(
|
||||
patcher.patch();
|
||||
|
||||
let stopProxy: (() => void) | undefined = undefined;
|
||||
let tempProfileFile: string | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (tempProfileFile && fs.existsSync(tempProfileFile)) {
|
||||
try {
|
||||
fs.unlinkSync(tempProfileFile);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tempProfileFile = null;
|
||||
}
|
||||
if (stopProxy) {
|
||||
try {
|
||||
stopProxy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sigintHandler = () => {
|
||||
cleanup();
|
||||
process.off('SIGINT', sigintHandler);
|
||||
process.kill(process.pid, 'SIGINT');
|
||||
};
|
||||
|
||||
const sigtermHandler = () => {
|
||||
cleanup();
|
||||
process.off('SIGTERM', sigtermHandler);
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
};
|
||||
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', sigintHandler);
|
||||
process.on('SIGTERM', sigtermHandler);
|
||||
|
||||
try {
|
||||
if (config.command === 'sandbox-exec') {
|
||||
@@ -81,161 +117,193 @@ export async function start_sandbox(
|
||||
profileFile = fs.existsSync(userProfileFile)
|
||||
? userProfileFile
|
||||
: projectProfileFile;
|
||||
}
|
||||
if (!fs.existsSync(profileFile)) {
|
||||
throw new FatalSandboxError(
|
||||
`Missing macos seatbelt profile file '${profileFile}'`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
|
||||
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
||||
const nodeOptions = [
|
||||
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
||||
...nodeArgs,
|
||||
].join(' ');
|
||||
|
||||
const args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
||||
'-D',
|
||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
||||
'-D',
|
||||
`HOME_DIR=${fs.realpathSync(homedir())}`,
|
||||
'-D',
|
||||
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
|
||||
];
|
||||
|
||||
// Add included directories from the workspace context
|
||||
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
|
||||
const MAX_INCLUDE_DIRS = 5;
|
||||
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||
const includedDirs: string[] = [];
|
||||
|
||||
if (cliConfig) {
|
||||
const workspaceContext = cliConfig.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
|
||||
// Filter out TARGET_DIR
|
||||
for (const dir of directories) {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (realDir !== targetDir) {
|
||||
includedDirs.push(realDir);
|
||||
} else {
|
||||
// For builtin profiles, if the file doesn't exist on disk (e.g. bundled or bazel environments),
|
||||
// write the embedded profile content to a temporary file.
|
||||
if (!fs.existsSync(profileFile)) {
|
||||
const content = BUILTIN_SEATBELT_PROFILE_CONTENTS[profile];
|
||||
if (content) {
|
||||
try {
|
||||
const tempDir = fs.realpathSync(os.tmpdir());
|
||||
const rand = randomBytes(8).toString('hex');
|
||||
tempProfileFile = path.join(
|
||||
tempDir,
|
||||
`gemini-sandbox-macos-${profile}-${rand}.sb`,
|
||||
);
|
||||
fs.writeFileSync(tempProfileFile, content, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
profileFile = tempProfileFile;
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Failed to write temporary seatbelt profile: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom allowed paths from config
|
||||
if (config.allowedPaths) {
|
||||
for (const hostPath of config.allowedPaths) {
|
||||
if (
|
||||
hostPath &&
|
||||
path.isAbsolute(hostPath) &&
|
||||
fs.existsSync(hostPath)
|
||||
) {
|
||||
const realDir = fs.realpathSync(hostPath);
|
||||
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
|
||||
try {
|
||||
if (!fs.existsSync(profileFile)) {
|
||||
throw new FatalSandboxError(
|
||||
`Missing macos seatbelt profile file '${profileFile}'`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
|
||||
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
|
||||
const nodeOptions = [
|
||||
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
|
||||
...nodeArgs,
|
||||
].join(' ');
|
||||
|
||||
const args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
||||
'-D',
|
||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
||||
'-D',
|
||||
`HOME_DIR=${fs.realpathSync(homedir())}`,
|
||||
'-D',
|
||||
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
|
||||
];
|
||||
|
||||
// Add included directories from the workspace context
|
||||
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
|
||||
const MAX_INCLUDE_DIRS = 5;
|
||||
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||
const includedDirs: string[] = [];
|
||||
|
||||
if (cliConfig) {
|
||||
const workspaceContext = cliConfig.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
|
||||
// Filter out TARGET_DIR
|
||||
for (const dir of directories) {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (realDir !== targetDir) {
|
||||
includedDirs.push(realDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
|
||||
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
|
||||
|
||||
if (i < includedDirs.length) {
|
||||
dirPath = includedDirs[i];
|
||||
}
|
||||
|
||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
||||
}
|
||||
|
||||
const finalArgv = cliArgs;
|
||||
|
||||
args.push(
|
||||
'-f',
|
||||
profileFile,
|
||||
'sh',
|
||||
'-c',
|
||||
[
|
||||
`SANDBOX=sandbox-exec`,
|
||||
`NODE_OPTIONS="${nodeOptions}"`,
|
||||
...finalArgv.map((arg) => quote([arg])),
|
||||
].join(' '),
|
||||
);
|
||||
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
|
||||
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
|
||||
let proxyProcess: ChildProcess | undefined = undefined;
|
||||
let sandboxProcess: ChildProcess | undefined = undefined;
|
||||
const sandboxEnv = { ...process.env };
|
||||
if (proxyCommand) {
|
||||
const proxy =
|
||||
process.env['HTTPS_PROXY'] ||
|
||||
process.env['https_proxy'] ||
|
||||
process.env['HTTP_PROXY'] ||
|
||||
process.env['http_proxy'] ||
|
||||
'http://localhost:8877';
|
||||
sandboxEnv['HTTPS_PROXY'] = proxy;
|
||||
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
|
||||
sandboxEnv['HTTP_PROXY'] = proxy;
|
||||
sandboxEnv['http_proxy'] = proxy;
|
||||
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
|
||||
if (noProxy) {
|
||||
sandboxEnv['NO_PROXY'] = noProxy;
|
||||
sandboxEnv['no_proxy'] = noProxy;
|
||||
}
|
||||
proxyProcess = spawn(proxyCommand, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
stopProxy = () => {
|
||||
debugLogger.log('stopping proxy ...');
|
||||
if (proxyProcess?.pid) {
|
||||
try {
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
// Add custom allowed paths from config
|
||||
if (config.allowedPaths) {
|
||||
for (const hostPath of config.allowedPaths) {
|
||||
if (
|
||||
hostPath &&
|
||||
path.isAbsolute(hostPath) &&
|
||||
fs.existsSync(hostPath)
|
||||
) {
|
||||
const realDir = fs.realpathSync(hostPath);
|
||||
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
|
||||
includedDirs.push(realDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('exit', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
}
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
// proxyProcess.stdout?.on('data', (data) => {
|
||||
// console.info(data.toString());
|
||||
// });
|
||||
proxyProcess.stderr?.on('data', (data) => {
|
||||
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
|
||||
});
|
||||
proxyProcess.on('close', (code, signal) => {
|
||||
if (sandboxProcess?.pid) {
|
||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
|
||||
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
|
||||
|
||||
if (i < includedDirs.length) {
|
||||
dirPath = includedDirs[i];
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
});
|
||||
debugLogger.log('waiting for proxy to start ...');
|
||||
await execAsync(
|
||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
||||
|
||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
||||
}
|
||||
|
||||
const finalArgv = cliArgs;
|
||||
|
||||
args.push(
|
||||
'-f',
|
||||
profileFile,
|
||||
'sh',
|
||||
'-c',
|
||||
[
|
||||
`SANDBOX=sandbox-exec`,
|
||||
'NODE_OPTIONS=' + quote([nodeOptions]),
|
||||
...finalArgv.map((arg) => quote([arg])),
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
// spawn child and let it inherit stdio
|
||||
process.stdin.pause();
|
||||
sandboxProcess = spawn(config.command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
sandboxProcess?.on('error', reject);
|
||||
sandboxProcess?.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
resolve(code ?? 1);
|
||||
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
|
||||
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
|
||||
let proxyProcess: ChildProcess | undefined = undefined;
|
||||
let sandboxProcess: ChildProcess | undefined = undefined;
|
||||
const sandboxEnv = { ...process.env };
|
||||
if (proxyCommand) {
|
||||
const proxy =
|
||||
process.env['HTTPS_PROXY'] ||
|
||||
process.env['https_proxy'] ||
|
||||
process.env['HTTP_PROXY'] ||
|
||||
process.env['http_proxy'] ||
|
||||
'http://localhost:8877';
|
||||
sandboxEnv['HTTPS_PROXY'] = proxy;
|
||||
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
|
||||
sandboxEnv['HTTP_PROXY'] = proxy;
|
||||
sandboxEnv['http_proxy'] = proxy;
|
||||
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
|
||||
if (noProxy) {
|
||||
sandboxEnv['NO_PROXY'] = noProxy;
|
||||
sandboxEnv['no_proxy'] = noProxy;
|
||||
}
|
||||
proxyProcess = spawn(proxyCommand, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
stopProxy = () => {
|
||||
debugLogger.log('stopping proxy ...');
|
||||
if (proxyProcess?.pid) {
|
||||
try {
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
// proxyProcess.stdout?.on('data', (data) => {
|
||||
// console.info(data.toString());
|
||||
// });
|
||||
proxyProcess.stderr?.on('data', (data) => {
|
||||
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
|
||||
});
|
||||
proxyProcess.on('close', (code, signal) => {
|
||||
if (sandboxProcess?.pid) {
|
||||
process.kill(-sandboxProcess.pid, 'SIGTERM');
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
});
|
||||
debugLogger.log('waiting for proxy to start ...');
|
||||
await execAsync(
|
||||
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
|
||||
);
|
||||
}
|
||||
// spawn child and let it inherit stdio
|
||||
process.stdin.pause();
|
||||
sandboxProcess = spawn(config.command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
sandboxProcess?.on('error', (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
sandboxProcess?.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
cleanup();
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.command === 'lxc') {
|
||||
@@ -768,9 +836,6 @@ export async function start_sandbox(
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
process.on('exit', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
// proxyProcess.stdout?.on('data', (data) => {
|
||||
@@ -821,12 +886,10 @@ export async function start_sandbox(
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (stopProxy) {
|
||||
stopProxy();
|
||||
process.off('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
}
|
||||
process.off('exit', cleanup);
|
||||
process.off('SIGINT', sigintHandler);
|
||||
process.off('SIGTERM', sigtermHandler);
|
||||
cleanup();
|
||||
patcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const BUILTIN_SEATBELT_PROFILE_CONTENTS: Record<string, string> = {
|
||||
'permissive-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
(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")
|
||||
)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "*:*"))
|
||||
(allow network-bind (local ip "*:*"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'permissive-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
(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")
|
||||
)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-bind (local ip "*:*"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
|
||||
'restrictive-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(allow mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'restrictive-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(allow mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
|
||||
'strict-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*
|
||||
(literal "/")
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(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"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.nvm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.fnm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.node"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.config"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(subpath "/usr")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/Library")
|
||||
(subpath "/System")
|
||||
(subpath "/private")
|
||||
(subpath "/dev")
|
||||
(subpath "/etc")
|
||||
(subpath "/opt")
|
||||
(subpath "/Applications")
|
||||
)
|
||||
(allow file-read-metadata)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(allow mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'strict-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*
|
||||
(literal "/")
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(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"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.nvm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.fnm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.node"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.config"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(subpath "/usr")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/Library")
|
||||
(subpath "/System")
|
||||
(subpath "/private")
|
||||
(subpath "/dev")
|
||||
(subpath "/etc")
|
||||
(subpath "/opt")
|
||||
(subpath "/Applications")
|
||||
)
|
||||
(allow file-read-metadata)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
(subpath (param "INCLUDE_DIR_2"))
|
||||
(subpath (param "INCLUDE_DIR_3"))
|
||||
(subpath (param "INCLUDE_DIR_4"))
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(allow mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
};
|
||||
|
||||
// Map standard 'closed' profiles to their strict counterparts for backward compatibility and fallback support
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['permissive-closed'] =
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-open'];
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['restrictive-closed'] =
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-proxied'];
|
||||
@@ -15,8 +15,10 @@ export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
|
||||
export const BUILTIN_SEATBELT_PROFILES = [
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
'strict-open',
|
||||
'strict-proxied',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.56.0-preview.1",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"fdir": "6.4.6",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"google-auth-library": "9.11.0",
|
||||
"google-auth-library": "10.9.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
|
||||
@@ -516,10 +516,34 @@ describe('translateEvent', () => {
|
||||
});
|
||||
|
||||
describe('InvalidStream events', () => {
|
||||
it('emits fatal error', () => {
|
||||
it('emits fatal error with specific message from event', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
const err = result[0] as AgentEvent<'error'>;
|
||||
expect(err.status).toBe('INTERNAL');
|
||||
expect(err.message).toBe('Empty response');
|
||||
expect(err.fatal).toBe(true);
|
||||
expect(err._meta?.['code']).toBe('INVALID_STREAM');
|
||||
expect(err._meta?.['errorType']).toBe('NO_RESPONSE_TEXT');
|
||||
expect(err._meta?.['rawMessage']).toBe('Empty response');
|
||||
});
|
||||
|
||||
it('falls back to default message when message is missing', () => {
|
||||
state.streamStartEmitted = true;
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: '',
|
||||
},
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
@@ -222,8 +222,15 @@ export function translateEvent(
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message: 'Invalid stream received from model',
|
||||
message:
|
||||
event.value?.message?.trim() ||
|
||||
'Invalid stream received from model',
|
||||
fatal: true,
|
||||
_meta: {
|
||||
code: 'INVALID_STREAM',
|
||||
errorType: event.value?.type,
|
||||
rawMessage: event.value?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, FinishReason } from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
@@ -192,6 +192,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
let finishedReason: FinishReason | undefined = undefined;
|
||||
const responseStream = this._client.sendMessageStream(
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
@@ -220,10 +221,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
case GeminiEventType.Finished:
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream(mapFinishReason(event.value.reason));
|
||||
return;
|
||||
}
|
||||
finishedReason = event.value.reason;
|
||||
break;
|
||||
case GeminiEventType.AgentExecutionStopped:
|
||||
case GeminiEventType.UserCancelled:
|
||||
@@ -241,7 +239,11 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream('completed');
|
||||
if (finishedReason !== undefined) {
|
||||
this._finishStream(mapFinishReason(finishedReason));
|
||||
} else {
|
||||
this._finishStream('completed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
|
||||
import type { GoogleCredentialsAuthConfig } from './types.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
|
||||
vi.mock('google-auth-library', () => ({
|
||||
GoogleAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Credential Leak Prevention (RCA / PoC Verification)', () => {
|
||||
const mockConfig: GoogleCredentialsAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
|
||||
getClient: vi.fn().mockResolvedValue({
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: 'leaked-token' }),
|
||||
credentials: { expiry_date: Date.now() + 3600 * 1000 },
|
||||
}),
|
||||
getIdTokenClient: vi.fn().mockResolvedValue({
|
||||
idTokenProvider: {
|
||||
fetchIdToken: vi.fn().mockResolvedValue('leaked-id-token'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should FAIL (throw error) when trying to initialize with an untrusted arbitrary remote agent URL (reproducing vulnerability prevention)', () => {
|
||||
// This test simulates the reproduction scenario: registering a remote agent with an arbitrary external URL
|
||||
// e.g., http://127.0.0.1:1337 or https://malicious-agent.evil.com
|
||||
const untrustedUrls = [
|
||||
{
|
||||
url: 'http://127.0.0.1:1337/.well-known/agent.json',
|
||||
error: /requires HTTPS/,
|
||||
},
|
||||
{
|
||||
url: 'https://malicious-agent.evil.com/card',
|
||||
error: /is not an allowed host/,
|
||||
},
|
||||
{
|
||||
url: 'https://untrusted-third-party.com/agent',
|
||||
error: /is not an allowed host/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of untrustedUrls) {
|
||||
expect(() => {
|
||||
new GoogleCredentialsAuthProvider(mockConfig, item.url);
|
||||
}).toThrow(item.error);
|
||||
}
|
||||
});
|
||||
|
||||
it('should SUCCEED only for allowed Google Services (proving the allowlist constraint)', () => {
|
||||
const trustedUrls = [
|
||||
'https://language.googleapis.com/v1/models',
|
||||
'https://vertex-ai-agent.googleapis.com/agent',
|
||||
'https://my-secure-service-abc.run.app/card',
|
||||
];
|
||||
|
||||
for (const url of trustedUrls) {
|
||||
expect(() => {
|
||||
new GoogleCredentialsAuthProvider(mockConfig, url);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,24 @@ describe('GoogleCredentialsAuthProvider', () => {
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws if the protocol is not HTTPS', () => {
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'http://language.googleapis.com/v1/models',
|
||||
),
|
||||
).toThrow(/requires HTTPS/);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'http://my-cloud-run-service.run.app',
|
||||
),
|
||||
).toThrow(/requires HTTPS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Fetching', () => {
|
||||
|
||||
@@ -40,7 +40,14 @@ export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = new URL(targetUrl).hostname;
|
||||
const urlObj = new URL(targetUrl);
|
||||
if (urlObj.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`Protocol "${urlObj.protocol}" is not secure. Google Credential provider requires HTTPS.`,
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = urlObj.hostname;
|
||||
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
|
||||
|
||||
if (isRunAppHost) {
|
||||
|
||||
@@ -414,4 +414,86 @@ describe('Auto Routing Fallback Integration', () => {
|
||||
'Pro success',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rotate session ID on fallback and retry successfully with the Flash model', async () => {
|
||||
const originalSessionId = 'test-session-rotate-id';
|
||||
config = new Config({
|
||||
sessionId: originalSessionId,
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
|
||||
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
|
||||
|
||||
client = new BaseLlmClient(
|
||||
fakeGenerator,
|
||||
config,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
let attemptsPro = 0;
|
||||
let attemptsFlash = 0;
|
||||
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message:
|
||||
'Automatically switching from gemini-2.5-pro to gemini-2.5-flash for faster responses for the remainder of this session. Possible reasons for this are...',
|
||||
details: [],
|
||||
};
|
||||
|
||||
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
|
||||
async (params) => {
|
||||
if (params.model === PREVIEW_GEMINI_MODEL) {
|
||||
attemptsPro++;
|
||||
throw new RetryableQuotaError(
|
||||
'Quota exceeded for Pro',
|
||||
mockGoogleApiError,
|
||||
0,
|
||||
);
|
||||
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
|
||||
attemptsFlash++;
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Flash success after rotation' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
}
|
||||
throw new Error(`Unexpected model: ${params.model}`);
|
||||
},
|
||||
);
|
||||
|
||||
config.setFallbackModelHandler(
|
||||
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
|
||||
'retry_always', // Approve switch to Flash
|
||||
);
|
||||
|
||||
const promise = client.generateContent({
|
||||
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
|
||||
contents: [{ role: 'user', parts: [{ text: 'test query' }] }],
|
||||
abortSignal: new AbortController().signal,
|
||||
promptId: 'test-prompt',
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
// Verify it resolved to Flash success instead of failing with Please submit a new query
|
||||
expect(result.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
|
||||
'Flash success after rotation',
|
||||
);
|
||||
expect(attemptsPro).toBe(3);
|
||||
expect(attemptsFlash).toBe(1);
|
||||
|
||||
// Verify session ID has been rotated
|
||||
expect(config.getSessionId()).not.toBe(originalSessionId);
|
||||
expect(config.getSessionId()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -680,6 +680,64 @@ describe('oauth2', () => {
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials);
|
||||
expect(client).toBe(mockExternalAccountClient);
|
||||
});
|
||||
|
||||
it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => {
|
||||
// Setup default cached credentials that are expired/invalid
|
||||
const defaultCreds = { refresh_token: 'expired-token' };
|
||||
const defaultCredsPath = path.join(
|
||||
tempHomeDir,
|
||||
GEMINI_DIR,
|
||||
'oauth_creds.json',
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(defaultCredsPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
defaultCredsPath,
|
||||
JSON.stringify(defaultCreds),
|
||||
);
|
||||
|
||||
// Setup valid fallback credentials via environment variable
|
||||
const envCreds = { refresh_token: 'valid-env-token' };
|
||||
const envCredsPath = path.join(tempHomeDir, 'env_creds.json');
|
||||
await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds));
|
||||
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath);
|
||||
|
||||
let currentCredentials: Credentials | null = null;
|
||||
const mockClient = {
|
||||
setCredentials: vi.fn((creds) => {
|
||||
currentCredentials = creds as Credentials;
|
||||
}),
|
||||
getAccessToken: vi.fn(async () => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return { token: 'valid-token' };
|
||||
}),
|
||||
getTokenInfo: vi.fn(async (_token) => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(OAuth2Client).mockImplementation(
|
||||
() => mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
|
||||
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
|
||||
|
||||
// Assert that fallback envCreds were eventually loaded and used
|
||||
expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with GCP environment variables', () => {
|
||||
|
||||
@@ -113,46 +113,52 @@ function getUseEncryptedStorageFlag() {
|
||||
return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given credentials object represents ADC credentials.
|
||||
*/
|
||||
function isAdcCredentials(
|
||||
credentials: unknown,
|
||||
): credentials is JWTInput & { type: string } {
|
||||
if (credentials && typeof credentials === 'object' && 'type' in credentials) {
|
||||
const type = credentials.type;
|
||||
return typeof type === 'string' && type !== 'authorized_user';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function initOauthClient(
|
||||
authType: AuthType,
|
||||
config: Config,
|
||||
): Promise<AuthClient> {
|
||||
const credentials = await fetchCachedCredentials();
|
||||
function createBaseOAuth2Client(): OAuth2Client {
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
if (
|
||||
credentials &&
|
||||
typeof credentials === 'object' &&
|
||||
'type' in credentials &&
|
||||
(credentials.type === 'external_account_authorized_user' ||
|
||||
credentials.type === 'service_account')
|
||||
) {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
const byoidClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const token = await byoidClient.getAccessToken();
|
||||
if (token) {
|
||||
debugLogger.debug(`Created ${credentials.type} auth client.`);
|
||||
return byoidClient;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
|
||||
if (
|
||||
process.env['GOOGLE_GENAI_USE_GCA'] &&
|
||||
process.env['GOOGLE_CLOUD_ACCESS_TOKEN']
|
||||
) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials({
|
||||
access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'],
|
||||
});
|
||||
@@ -160,49 +166,70 @@ async function initOauthClient(
|
||||
return client;
|
||||
}
|
||||
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
const credentialsList = await fetchCachedCredentialsList();
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
|
||||
if (credentials) {
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
// 2. Iterate sequentially over the credentials list in their natural priority order
|
||||
for (const credentials of credentialsList) {
|
||||
if (isAdcCredentials(credentials)) {
|
||||
try {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
});
|
||||
const adcClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const response = await adcClient.getAccessToken();
|
||||
const token = response.token ?? null;
|
||||
if (token) {
|
||||
debugLogger.debug('Created ' + credentials.type + ' auth client.');
|
||||
return adcClient;
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(credentials as Credentials);
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'ADC credentials verification failed:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} else if (credentials) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(
|
||||
client.credentials || (credentials as Credentials),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Cached credentials are not valid:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Cached credentials are not valid:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createBaseOAuth2Client();
|
||||
|
||||
// In Google Compute Engine based environments (including Cloud Shell), we can
|
||||
// use Application Default Credentials (ADC) provided via its metadata server
|
||||
// to authenticate non-interactively using the identity of the logged-in user.
|
||||
@@ -663,16 +690,27 @@ export function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCachedCredentials(): Promise<
|
||||
Credentials | JWTInput | null
|
||||
async function fetchCachedCredentialsList(): Promise<
|
||||
Array<Credentials | JWTInput>
|
||||
> {
|
||||
const credentialsList: Array<Credentials | JWTInput> = [];
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
if (useEncryptedStorage) {
|
||||
return OAuthCredentialStorage.loadCredentials();
|
||||
try {
|
||||
const creds = await OAuthCredentialStorage.loadCredentials();
|
||||
if (creds) {
|
||||
credentialsList.push(creds);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Failed to load credentials from encrypted storage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pathsToTry = [
|
||||
Storage.getOAuthCredsPath(),
|
||||
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
|
||||
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
@@ -683,9 +721,10 @@ async function fetchCachedCredentials(): Promise<
|
||||
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
|
||||
typeof val === 'object' && val !== null;
|
||||
if (isOAuthCreds(parsed)) {
|
||||
return parsed;
|
||||
credentialsList.push(parsed);
|
||||
} else {
|
||||
throw new Error('Invalid credentials format');
|
||||
}
|
||||
throw new Error('Invalid credentials format');
|
||||
} catch (error) {
|
||||
// Log specific error for debugging, but continue trying other paths
|
||||
debugLogger.debug(
|
||||
@@ -695,7 +734,7 @@ async function fetchCachedCredentials(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return credentialsList;
|
||||
}
|
||||
|
||||
export function clearOauthClientCache() {
|
||||
|
||||
@@ -86,6 +86,10 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
getEffectiveSessionId(): string | undefined {
|
||||
return this.config?.getSessionId() ?? this.sessionId;
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
req: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
@@ -117,7 +121,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -153,7 +157,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
server.sessionId, // Use sessionId as trajectoryId
|
||||
server.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
@@ -204,7 +208,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -224,7 +228,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
this.sessionId, // Use sessionId as trajectoryId
|
||||
this.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user