mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-10 08:56:29 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a7ecba9c | |||
| 7f9b99bb64 | |||
| dd8dfd91f9 |
@@ -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 || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: '🔗 Install latest core package'
|
||||
@@ -251,7 +251,7 @@ runs:
|
||||
${PUBLISH_TARGET} \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
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)"
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: 'Get a2a-server Token'
|
||||
@@ -278,9 +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 || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)"
|
||||
fi
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: 'Testing: Tools (Python)'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
name: 'Python Tests'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Set up Python'
|
||||
uses: 'actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55' # ratchet:actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'tools/caretaker-agent/cloudrun/triage-worker/requirements.txt'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f tools/caretaker-agent/cloudrun/triage-worker/requirements.txt ]; then
|
||||
python -m pip install -r tools/caretaker-agent/cloudrun/triage-worker/requirements.txt
|
||||
fi
|
||||
|
||||
- name: 'Run unittest suite'
|
||||
run: |
|
||||
PYTHONPATH=tools/caretaker-agent/cloudrun/triage-worker python -m unittest discover -s tools/caretaker-agent/cloudrun/triage-worker/tests -t tools/caretaker-agent/cloudrun/triage-worker
|
||||
+3
-3
@@ -421,9 +421,9 @@ To debug the CLI's React-based UI, you can use React DevTools.
|
||||
|
||||
On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open`
|
||||
profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that
|
||||
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
|
||||
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
|
||||
`packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads
|
||||
and writes to the working directory while allowing outbound network traffic by
|
||||
setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file.
|
||||
|
||||
@@ -18,74 +18,6 @@ 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
|
||||
|
||||
+47
-58
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.54.0
|
||||
# Latest stable release: v0.45.0
|
||||
|
||||
Released: August 6, 2026
|
||||
Released: June 03, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,66 +11,55 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- 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
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be 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
|
||||
[#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
|
||||
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
|
||||
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
|
||||
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
|
||||
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
|
||||
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
|
||||
- feat(context): Complete simplification work. by @joshualitt in
|
||||
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
|
||||
- fix(core): force update_topic tool to execute sequentially by
|
||||
@jvargassanchez-dot in
|
||||
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
|
||||
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
|
||||
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
|
||||
- Changelog for v0.43.0 by @gemini-cli-robot in
|
||||
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
|
||||
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
|
||||
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
|
||||
- fix(cli): filter internal session context from history during resumption by
|
||||
@rmedranollamas in
|
||||
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
|
||||
- Update default auto routing by @DavidAPierce in
|
||||
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
|
||||
- fix(core): bypass routing classifiers to prevent orphaned function response
|
||||
errors by @danielweis in
|
||||
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
|
||||
- fix(core): suppress PTY resize EBADF errors by @scidomino in
|
||||
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
|
||||
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
|
||||
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
|
||||
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
|
||||
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
|
||||
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
|
||||
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#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)
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.53.1...v0.54.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.0
|
||||
|
||||
+57
-92
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.55.0-preview.1
|
||||
# Preview release: v0.50.0-preview.1
|
||||
|
||||
Released: August 06, 2026
|
||||
Released: June 25, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,101 +13,66 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.55.0-nightly.20260728.gd29268d36 by
|
||||
- fix/verify release npm ci ignore scripts by @rmedranollamas in
|
||||
[#28116](https://github.com/google-gemini/gemini-cli/pull/28116)
|
||||
- fix(ci): prevent workspace binary shadowing in release verification by @galz10
|
||||
in [#28132](https://github.com/google-gemini/gemini-cli/pull/28132)
|
||||
- Feat/tool registry discovery by @ved015 in
|
||||
[#28113](https://github.com/google-gemini/gemini-cli/pull/28113)
|
||||
- fix(ci): prevent bad NPM releases and promote job crashes by @galz10 in
|
||||
[#28147](https://github.com/google-gemini/gemini-cli/pull/28147)
|
||||
- chore(release): bump version to 0.48.0-nightly.20260609.g3a13b8eeb by
|
||||
@gemini-cli-robot in
|
||||
[#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
|
||||
[#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
|
||||
@luisfelipe-alt in
|
||||
[#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
|
||||
[#27698](https://github.com/google-gemini/gemini-cli/pull/27698)
|
||||
- fix(core): handle multi-line escaped quotes in stripShellWrapper by
|
||||
@sanchezcoraspe in
|
||||
[#27467](https://github.com/google-gemini/gemini-cli/pull/27467)
|
||||
- fix(cli): prevent path traversal vulnerabilities during skill install… by
|
||||
@ompatel-aiml in
|
||||
[#27767](https://github.com/google-gemini/gemini-cli/pull/27767)
|
||||
- Fix/pending tools and trust overrides by @jvargassanchez-dot in
|
||||
[#27854](https://github.com/google-gemini/gemini-cli/pull/27854)
|
||||
- ci: use internal environment for scheduled nightly releases (#27865) by
|
||||
@rmedranollamas in
|
||||
[#27939](https://github.com/google-gemini/gemini-cli/pull/27939)
|
||||
- feat(core): Support GDC air-gapped Service Identity after auth library update
|
||||
by @sidhantgoyal-droid in
|
||||
[#27956](https://github.com/google-gemini/gemini-cli/pull/27956)
|
||||
- fix(cli): handle tmux false positive background detection by @amelidev in
|
||||
[#27572](https://github.com/google-gemini/gemini-cli/pull/27572)
|
||||
- Add static eval source analyzer by @ved015 in
|
||||
[#27631](https://github.com/google-gemini/gemini-cli/pull/27631)
|
||||
- fix(config): migrate coreTools setting to tools.core by @galz10 in
|
||||
[#27947](https://github.com/google-gemini/gemini-cli/pull/27947)
|
||||
- fix(core-tools): resolve defensive path resolution for at-reference files by
|
||||
@luisfelipe-alt in
|
||||
[#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)
|
||||
[#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)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.55.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.50.0-preview.1
|
||||
|
||||
+2
-3
@@ -87,9 +87,8 @@ preferred container solution.
|
||||
|
||||
Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
|
||||
**Default profile**: `permissive-open` - denies operations by default; confines
|
||||
writes to the project directory while allowing broad file reads and network
|
||||
access.
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
|
||||
@@ -2736,10 +2736,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Run the CLI once with this set to generate the file.
|
||||
- **`SEATBELT_PROFILE`** (macOS specific):
|
||||
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
|
||||
- `permissive-open`: (Default) Denies operations by default, confining writes
|
||||
to the project folder (and a few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing
|
||||
broad file reads and network access.
|
||||
- `permissive-open`: (Default) Restricts writes to the project folder (and a
|
||||
few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other
|
||||
operations.
|
||||
- `restrictive-open`: Declines operations by default, allows network.
|
||||
- `strict-open`: Restricts both reads and writes to the working directory,
|
||||
allows network.
|
||||
|
||||
@@ -216,4 +216,109 @@ describe('evalTest reliability logic', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should append tool call chain to assertion failure error messages', async () => {
|
||||
const mockRig = {
|
||||
setup: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
readToolLogs: vi.fn().mockReturnValue([]),
|
||||
_lastRunStderr: '',
|
||||
} as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
mockRig.run.mockResolvedValue('Success');
|
||||
mockRig.readToolLogs.mockReturnValue([
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'grep_search',
|
||||
args: '{"query":"TODO"}',
|
||||
success: true,
|
||||
duration_ms: 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'read_file',
|
||||
args: '{"path":"/src/foo.ts"}',
|
||||
success: false,
|
||||
duration_ms: 15,
|
||||
error: 'File not found',
|
||||
error_type: 'ENOENT',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const assertionError = new Error('Expected tool to be called');
|
||||
|
||||
try {
|
||||
await internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-tool-chain',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
throw assertionError;
|
||||
},
|
||||
});
|
||||
expect.unreachable('Expected internalEvalTest to throw');
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const msg = (error as Error).message;
|
||||
expect(msg).toContain('Expected tool to be called');
|
||||
expect(msg).toContain('Tool Call Chain (2 calls)');
|
||||
expect(msg).toContain('grep_search');
|
||||
expect(msg).toContain('read_file');
|
||||
expect(msg).toContain('[ENOENT] File not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not crash when error.message is read-only (frozen error)', async () => {
|
||||
const mockRig = {
|
||||
setup: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
readToolLogs: vi.fn(),
|
||||
_lastRunStderr: '',
|
||||
} as any;
|
||||
(TestRig as any).mockReturnValue(mockRig);
|
||||
|
||||
mockRig.run.mockResolvedValue('Success');
|
||||
mockRig.readToolLogs.mockReturnValue([
|
||||
{
|
||||
toolRequest: {
|
||||
name: 'read_file',
|
||||
args: '{"path":"/foo.ts"}',
|
||||
success: true,
|
||||
duration_ms: 10,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Simulate a frozen error whose message property cannot be mutated
|
||||
const frozenError = Object.freeze(new Error('Frozen assertion error'));
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-frozen-error',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
throw frozenError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Frozen assertion error');
|
||||
|
||||
// Should have warned that the message could not be mutated
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not append tool call chain'),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { TestRig } from '@google/gemini-cli-test-utils';
|
||||
import { formatToolLogChain } from '../scripts/utils/tool-log-formatter.js';
|
||||
import {
|
||||
createUnauthorizedToolError,
|
||||
parseAgentMarkdown,
|
||||
@@ -186,6 +187,23 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
|
||||
await evalCase.assert(rig, result);
|
||||
isSuccess = true;
|
||||
} catch (error: unknown) {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
if (toolLogs && toolLogs.length > 0) {
|
||||
const summary = formatToolLogChain(toolLogs);
|
||||
if (error instanceof Error) {
|
||||
try {
|
||||
error.message = `${error.message}\n\nTool Call Chain (${toolLogs.length} calls):\n${summary}`;
|
||||
} catch {
|
||||
// Error object may be frozen or have a read-only message property.
|
||||
// The original error is still re-thrown, so no failure is hidden.
|
||||
console.warn(
|
||||
`[eval] Could not append tool call chain to error message (${toolLogs.length} calls)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
await fs.promises.unlink(activityLogFile).catch((err) => {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"@google/gemini-cli": ["../packages/cli/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"include": ["**/*.ts", "../scripts/utils/tool-log-formatter.ts"],
|
||||
"exclude": ["logs"],
|
||||
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
|
||||
}
|
||||
|
||||
@@ -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} using the read_file tool`;
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await run.type(readPrompt);
|
||||
await run.type('\r');
|
||||
|
||||
|
||||
Generated
+1442
-91
File diff suppressed because it is too large
Load Diff
+2
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.56.0-nightly.20260806.g761f604c1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.56.0-nightly.20260806.g761f604c1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.51.0-nightly.20260625.g3fbf93e26"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -34,7 +34,6 @@
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
|
||||
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
|
||||
"eval:coverage": "tsx ./scripts/eval-coverage-cli.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
@@ -96,10 +95,8 @@
|
||||
],
|
||||
"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",
|
||||
@@ -124,7 +121,6 @@
|
||||
"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.56.0-nightly.20260806.g761f604c1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,24 +14,7 @@ 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',
|
||||
@@ -41,10 +24,6 @@ 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', () => ({
|
||||
@@ -321,125 +300,4 @@ 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,107 +752,4 @@ 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,11 +131,9 @@ 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.
|
||||
async (failedModel, fallbackModel) => {
|
||||
this.config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return 'stop';
|
||||
},
|
||||
// for future requests without retrying the current one. The 'stop'
|
||||
// intent achieves this.
|
||||
async () => 'stop',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -668,18 +666,13 @@ export class Task {
|
||||
}
|
||||
|
||||
try {
|
||||
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(
|
||||
const currentContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
return this._applyReplacement(
|
||||
currentContent,
|
||||
normalizedOldString,
|
||||
normalizedNewString,
|
||||
normalizedOldString === '' && currentContent === '',
|
||||
old_string,
|
||||
new_string,
|
||||
old_string === '' && currentContent === '',
|
||||
);
|
||||
return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent;
|
||||
} catch (err) {
|
||||
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
@@ -1099,21 +1092,19 @@ export class Task {
|
||||
logger.info(
|
||||
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
|
||||
);
|
||||
const parts: genAiPart[] = [];
|
||||
for (const toolCall of completedTools) {
|
||||
const response = toolCall.response?.responseParts;
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
const responsesToAdd = completedTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
for (const response of responsesToAdd) {
|
||||
let parts: genAiPart[];
|
||||
if (Array.isArray(response)) {
|
||||
parts.push(...response);
|
||||
parts = response;
|
||||
} else if (typeof response === 'string') {
|
||||
parts.push({ text: response });
|
||||
parts = [{ text: response }];
|
||||
} else {
|
||||
parts.push(response);
|
||||
parts = [response];
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.geminiClient.addHistory({
|
||||
role: 'user',
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
tmpdir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
@@ -30,246 +28,28 @@ 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';
|
||||
|
||||
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];
|
||||
}
|
||||
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
|
||||
|
||||
export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Promise<Config> {
|
||||
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 workspaceDir = process.cwd();
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
|
||||
let checkpointing = getEnvLocal('CHECKPOINTING')
|
||||
? getEnvLocal('CHECKPOINTING') === 'true'
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
@@ -282,7 +62,7 @@ export async function loadConfig(
|
||||
}
|
||||
|
||||
const approvalMode =
|
||||
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
@@ -311,9 +91,8 @@ 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: getEnvLocal('DEBUG') === 'true' || false,
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
env: envVars,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
excludeTools: settings.tools?.exclude || undefined,
|
||||
@@ -328,7 +107,7 @@ export async function loadConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: settings.telemetry?.logPrompts,
|
||||
},
|
||||
@@ -340,8 +119,8 @@ export async function loadConfig(
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
|
||||
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
@@ -400,7 +179,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, 'Config', envVars);
|
||||
await refreshAuthentication(config, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -408,19 +187,16 @@ export async function loadConfig(
|
||||
export function setIsTrusted(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): boolean {
|
||||
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
|
||||
if (folderTrustEnv !== undefined) {
|
||||
return folderTrustEnv === 'true';
|
||||
if (INITIAL_FOLDER_TRUST !== undefined) {
|
||||
return INITIAL_FOLDER_TRUST === 'true';
|
||||
}
|
||||
return !!agentSettings?.isTrusted;
|
||||
}
|
||||
|
||||
export async function setTargetDir(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): Promise<string> {
|
||||
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
const originalCWD = process.cwd();
|
||||
const targetDir =
|
||||
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
|
||||
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
|
||||
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
|
||||
? agentSettings.workspacePath
|
||||
: undefined);
|
||||
@@ -434,170 +210,58 @@ export async function setTargetDir(
|
||||
);
|
||||
|
||||
try {
|
||||
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`);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(targetDir);
|
||||
process.chdir(resolvedPath);
|
||||
return resolvedPath;
|
||||
} catch (e) {
|
||||
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
|
||||
throw e;
|
||||
logger.error(
|
||||
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
|
||||
);
|
||||
return originalCWD;
|
||||
}
|
||||
}
|
||||
|
||||
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> = {};
|
||||
export function loadEnvironment(): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
if (envFilePath) {
|
||||
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
|
||||
}
|
||||
dotenv.config({ path: envFilePath, override: true });
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
async function findEnvFile(startDir: string): Promise<string | null> {
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(geminiEnvPath);
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
try {
|
||||
await fs.promises.access(envPath);
|
||||
if (fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
break;
|
||||
// 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;
|
||||
}
|
||||
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> {
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
if (getEnvLocal('USE_CCPA')) {
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
|
||||
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||
@@ -612,7 +276,7 @@ async function refreshAuthentication(
|
||||
);
|
||||
|
||||
const useComputeAdc =
|
||||
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
|
||||
if (isHeadless || useComputeAdc) {
|
||||
@@ -641,14 +305,11 @@ async function refreshAuthentication(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
} else if (getEnvLocal('GEMINI_API_KEY')) {
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(
|
||||
AuthType.USE_GEMINI,
|
||||
getEnvLocal('GEMINI_API_KEY'),
|
||||
);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
|
||||
@@ -36,12 +36,9 @@ interface ExtensionConfig {
|
||||
excludeTools?: string[];
|
||||
}
|
||||
|
||||
export function loadExtensions(
|
||||
workspaceDir: string,
|
||||
isTrusted: boolean = false,
|
||||
): GeminiCLIExtension[] {
|
||||
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
|
||||
const allExtensions = [
|
||||
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...loadExtensionsFromDir(homedir()),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* @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,7 +197,8 @@ async function handleExecuteCommand(
|
||||
export async function createApp() {
|
||||
try {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = await setTargetDir(undefined);
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
@@ -208,35 +209,13 @@ 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, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
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 = await setTargetDir(agentSettings);
|
||||
const workDir = setTargetDir(agentSettings);
|
||||
await fse.ensureDir(workDir);
|
||||
const workspaceFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* @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.56.0-nightly.20260806.g761f604c1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.56.0-nightly.20260806.g761f604c1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.51.0-nightly.20260625.g3fbf93e26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
|
||||
@@ -319,41 +319,6 @@ 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,12 +510,7 @@ 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 === 'MAX_TOKENS_EXCEEDED' ||
|
||||
error.type === 'SAFETY_BLOCKED' ||
|
||||
error.type === 'RECITATION_BLOCKED' ||
|
||||
error.type === 'OTHER_BLOCKED' ||
|
||||
error.type === 'THINKING_ONLY_RESPONSE'))
|
||||
error.type === 'UNEXPECTED_TOOL_CALL'))
|
||||
) {
|
||||
// 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,10 +103,7 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(installCommand)
|
||||
.fail(false);
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -27,10 +27,7 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions validate command', () => {
|
||||
it('should fail if no path is provided', () => {
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(validateCommand)
|
||||
.fail(false);
|
||||
const validationParser = yargs([]).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().locale('en');
|
||||
const yargsInstance = yargs();
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
|
||||
|
||||
const parser = yargsInstance.command(mcpCommand).help();
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
@@ -79,7 +78,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
@@ -112,7 +110,6 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -168,7 +165,6 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -197,7 +193,6 @@ 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 = {
|
||||
@@ -1825,6 +1820,7 @@ 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');
|
||||
@@ -2302,13 +2298,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2322,7 +2312,7 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
`[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`,
|
||||
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -2335,13 +2325,7 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2357,7 +2341,9 @@ describe('runNonInteractive', () => {
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE);
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2369,13 +2355,7 @@ describe('runNonInteractive', () => {
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_RESPONSE_TEXT',
|
||||
message: 'Empty response',
|
||||
},
|
||||
},
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
@@ -2391,33 +2371,8 @@ describe('runNonInteractive', () => {
|
||||
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: '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(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -30,12 +30,6 @@ 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';
|
||||
@@ -439,31 +433,8 @@ export async function runNonInteractive(
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
} else if (event.type === GeminiEventType.InvalidStream) {
|
||||
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',
|
||||
);
|
||||
|
||||
invalidStreamError =
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
TRUE_EMPTY_RESPONSE_MESSAGE,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
|
||||
@@ -79,7 +78,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
recordSemanticValidationError: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession: original.LegacyAgentSession,
|
||||
geminiPartsToContentParts: original.geminiPartsToContentParts,
|
||||
@@ -201,7 +199,6 @@ 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 = {
|
||||
@@ -2460,126 +2457,6 @@ 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,12 +39,6 @@ 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';
|
||||
@@ -338,17 +332,14 @@ export async function runNonInteractive({
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalResult = (errorPayload?: {
|
||||
type: string;
|
||||
message: string;
|
||||
}): void => {
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: errorPayload ? 'error' : 'success',
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
@@ -359,7 +350,7 @@ export async function runNonInteractive({
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
errorPayload,
|
||||
undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
@@ -554,52 +545,6 @@ 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);
|
||||
}
|
||||
@@ -668,7 +613,7 @@ export async function runNonInteractive({
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalResult();
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -36,18 +36,6 @@ 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 ||
|
||||
|
||||
@@ -54,7 +54,6 @@ 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';
|
||||
@@ -1046,107 +1045,6 @@ 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[] = [
|
||||
{
|
||||
@@ -1874,120 +1772,6 @@ 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[] = [
|
||||
@@ -2522,68 +2306,6 @@ 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,7 +14,6 @@ import {
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
uiTelemetryService,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
@@ -46,12 +45,6 @@ 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,
|
||||
@@ -61,7 +54,6 @@ import type {
|
||||
ServerGeminiContentEvent as ContentEvent,
|
||||
ServerGeminiFinishedEvent,
|
||||
ServerGeminiStreamEvent as GeminiEvent,
|
||||
ServerGeminiInvalidStreamEvent,
|
||||
ThoughtSummary,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
@@ -1237,61 +1229,6 @@ 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)) {
|
||||
@@ -1604,10 +1541,8 @@ export const useGeminiStream = (
|
||||
loopDetectedRef.current = true;
|
||||
break;
|
||||
case ServerGeminiEventType.Retry:
|
||||
// Handled transparently by the backend stream retries.
|
||||
break;
|
||||
case ServerGeminiEventType.InvalidStream:
|
||||
handleInvalidStreamEvent(event.value, userMessageTimestamp);
|
||||
// Will add the missing logic later
|
||||
break;
|
||||
default: {
|
||||
// enforces exhaustive switch-case
|
||||
@@ -1640,7 +1575,6 @@ export const useGeminiStream = (
|
||||
handleChatModelEvent,
|
||||
handleAgentExecutionStoppedEvent,
|
||||
handleAgentExecutionBlockedEvent,
|
||||
handleInvalidStreamEvent,
|
||||
addItem,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
@@ -1952,30 +1886,6 @@ 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,
|
||||
@@ -2110,27 +2020,6 @@ 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) {
|
||||
@@ -2141,10 +2030,21 @@ 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,
|
||||
@@ -2166,7 +2066,6 @@ export const useGeminiStream = (
|
||||
maybeAddSuppressedToolErrorNote,
|
||||
maybeAddLowVerbosityFailureNote,
|
||||
setIsResponding,
|
||||
toolCalls,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('usePrivacySettings', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('should report tier unavailable when OAuth is not being used', async () => {
|
||||
it('should throw error when content generator is not a CodeAssistServer', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
@@ -58,8 +58,7 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
expect(result.current.privacyState.error).toBe('Oauth not being used');
|
||||
});
|
||||
|
||||
it('should handle paid tier users correctly', async () => {
|
||||
@@ -80,7 +79,7 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when CodeAssistServer has no projectId', async () => {
|
||||
it('should throw error when CodeAssistServer has no projectId', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
userTier: UserTierId.FREE,
|
||||
} as unknown as CodeAssistServer);
|
||||
@@ -91,63 +90,9 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(result.current.privacyState.error).toBe(
|
||||
'CodeAssist server is missing a project ID',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update data collection opt-in setting', async () => {
|
||||
|
||||
@@ -18,22 +18,8 @@ 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,
|
||||
@@ -48,13 +34,7 @@ export const usePrivacySettings = (config: Config) => {
|
||||
const server = getCodeAssistServerOrFail(config);
|
||||
const tier = server.userTier;
|
||||
if (tier === undefined) {
|
||||
// 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;
|
||||
throw new Error('Could not determine user tier.');
|
||||
}
|
||||
if (tier !== UserTierId.FREE) {
|
||||
// We don't need to fetch opt-out info since non-free tier
|
||||
@@ -73,13 +53,6 @@ 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),
|
||||
@@ -101,13 +74,6 @@ 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),
|
||||
@@ -126,30 +92,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
function getCodeAssistServerOrFail(config: Config): CodeAssistServer {
|
||||
const server = getCodeAssistServer(config);
|
||||
if (server === undefined) {
|
||||
throw new TierUnavailableError('Oauth not being used');
|
||||
throw new Error('Oauth not being used');
|
||||
} else if (server.projectId === undefined) {
|
||||
throw new TierUnavailableError('CodeAssist server is missing a project ID');
|
||||
throw new Error('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> {
|
||||
|
||||
@@ -71,11 +71,6 @@ 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 },
|
||||
@@ -106,11 +101,6 @@ 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,9 +27,7 @@ export const CloudFreePrivacyNotice = ({
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
(privacyState.error ||
|
||||
privacyState.isFreeTier === false ||
|
||||
privacyState.isTierUnavailable) &&
|
||||
(privacyState.error || privacyState.isFreeTier === false) &&
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
@@ -55,35 +53,6 @@ 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,74 +1,10 @@
|
||||
(version 1)
|
||||
|
||||
;; 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)
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
|
||||
;; 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)
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -76,6 +12,7 @@
|
||||
(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"))
|
||||
@@ -88,48 +25,3 @@
|
||||
(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,76 +1,10 @@
|
||||
(version 1)
|
||||
|
||||
;; 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)
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
|
||||
;; 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.
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -78,6 +12,7 @@
|
||||
(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"))
|
||||
@@ -87,52 +22,16 @@
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(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-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
|
||||
;; deny all inbound network traffic EXCEPT on debugger port
|
||||
(deny network-inbound)
|
||||
(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 "*:*"))
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* @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,6 +70,7 @@
|
||||
(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,6 +70,7 @@
|
||||
(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,6 +105,7 @@
|
||||
(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,6 +105,7 @@
|
||||
(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,140 +292,6 @@ 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',
|
||||
|
||||
+155
-218
@@ -39,7 +39,6 @@ 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);
|
||||
@@ -57,41 +56,6 @@ 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') {
|
||||
@@ -117,193 +81,161 @@ export async function start_sandbox(
|
||||
profileFile = fs.existsSync(userProfileFile)
|
||||
? userProfileFile
|
||||
: projectProfileFile;
|
||||
} 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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=' + quote([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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
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');
|
||||
}
|
||||
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', reject);
|
||||
sandboxProcess?.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (config.command === 'lxc') {
|
||||
@@ -836,6 +768,9 @@ 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) => {
|
||||
@@ -886,10 +821,12 @@ export async function start_sandbox(
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
process.off('exit', cleanup);
|
||||
process.off('SIGINT', sigintHandler);
|
||||
process.off('SIGTERM', sigtermHandler);
|
||||
cleanup();
|
||||
if (stopProxy) {
|
||||
stopProxy();
|
||||
process.off('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
}
|
||||
patcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
/**
|
||||
* @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,10 +15,8 @@ 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.56.0-nightly.20260806.g761f604c1",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"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": "10.9.0",
|
||||
"google-auth-library": "9.11.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
|
||||
@@ -516,34 +516,10 @@ describe('translateEvent', () => {
|
||||
});
|
||||
|
||||
describe('InvalidStream events', () => {
|
||||
it('emits fatal error with specific message from event', () => {
|
||||
it('emits fatal error', () => {
|
||||
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,15 +222,8 @@ export function translateEvent(
|
||||
out.push(
|
||||
makeEvent('error', state, {
|
||||
status: 'INTERNAL',
|
||||
message:
|
||||
event.value?.message?.trim() ||
|
||||
'Invalid stream received from model',
|
||||
message: '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, FinishReason } from '@google/genai';
|
||||
import type { Part } 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,7 +192,6 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
let finishedReason: FinishReason | undefined = undefined;
|
||||
const responseStream = this._client.sendMessageStream(
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
@@ -221,7 +220,10 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
case GeminiEventType.Finished:
|
||||
finishedReason = event.value.reason;
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream(mapFinishReason(event.value.reason));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case GeminiEventType.AgentExecutionStopped:
|
||||
case GeminiEventType.UserCancelled:
|
||||
@@ -239,11 +241,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
if (finishedReason !== undefined) {
|
||||
this._finishStream(mapFinishReason(finishedReason));
|
||||
} else {
|
||||
this._finishStream('completed');
|
||||
}
|
||||
this._finishStream('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* @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,24 +82,6 @@ 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,14 +40,7 @@ export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
||||
);
|
||||
}
|
||||
|
||||
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 hostname = new URL(targetUrl).hostname;
|
||||
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
|
||||
|
||||
if (isRunAppHost) {
|
||||
|
||||
@@ -414,86 +414,4 @@ 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,64 +680,6 @@ 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,52 +113,46 @@ 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> {
|
||||
function createBaseOAuth2Client(): OAuth2Client {
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
const credentials = await fetchCachedCredentials();
|
||||
|
||||
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,
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return client;
|
||||
const token = await byoidClient.getAccessToken();
|
||||
if (token) {
|
||||
debugLogger.debug(`Created ${credentials.type} auth client.`);
|
||||
return byoidClient;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
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'],
|
||||
});
|
||||
@@ -166,70 +160,49 @@ async function initOauthClient(
|
||||
return client;
|
||||
}
|
||||
|
||||
const credentialsList = await fetchCachedCredentialsList();
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
} 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);
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
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),
|
||||
);
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(credentials as Credentials);
|
||||
|
||||
return client;
|
||||
}
|
||||
} 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.
|
||||
@@ -690,27 +663,16 @@ export function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCachedCredentialsList(): Promise<
|
||||
Array<Credentials | JWTInput>
|
||||
async function fetchCachedCredentials(): Promise<
|
||||
Credentials | JWTInput | null
|
||||
> {
|
||||
const credentialsList: Array<Credentials | JWTInput> = [];
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
if (useEncryptedStorage) {
|
||||
try {
|
||||
const creds = await OAuthCredentialStorage.loadCredentials();
|
||||
if (creds) {
|
||||
credentialsList.push(creds);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Failed to load credentials from encrypted storage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return OAuthCredentialStorage.loadCredentials();
|
||||
}
|
||||
|
||||
const pathsToTry = [
|
||||
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
|
||||
Storage.getOAuthCredsPath(),
|
||||
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
@@ -721,10 +683,9 @@ async function fetchCachedCredentialsList(): Promise<
|
||||
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
|
||||
typeof val === 'object' && val !== null;
|
||||
if (isOAuthCreds(parsed)) {
|
||||
credentialsList.push(parsed);
|
||||
} else {
|
||||
throw new Error('Invalid credentials format');
|
||||
return parsed;
|
||||
}
|
||||
throw new Error('Invalid credentials format');
|
||||
} catch (error) {
|
||||
// Log specific error for debugging, but continue trying other paths
|
||||
debugLogger.debug(
|
||||
@@ -734,7 +695,7 @@ async function fetchCachedCredentialsList(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return credentialsList;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearOauthClientCache() {
|
||||
|
||||
@@ -86,10 +86,6 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
getEffectiveSessionId(): string | undefined {
|
||||
return this.config?.getSessionId() ?? this.sessionId;
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
req: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
@@ -121,7 +117,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.getEffectiveSessionId(),
|
||||
this.sessionId,
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -157,7 +153,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
server.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
server.sessionId, // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
@@ -208,7 +204,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.getEffectiveSessionId(),
|
||||
this.sessionId,
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -228,7 +224,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
this.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
this.sessionId, // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
|
||||
@@ -678,7 +678,6 @@ export interface ConfigParameters {
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
env?: Record<string, string>;
|
||||
workspacePoliciesDir?: string;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
directWebFetch?: boolean;
|
||||
@@ -897,7 +896,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly useTerminalBuffer: boolean;
|
||||
private readonly useRenderProcess: boolean;
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
readonly env?: Record<string, string>;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
@@ -1121,7 +1119,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.checkpointing = params.checkpointing ?? false;
|
||||
this.proxy = params.proxy;
|
||||
this.cwd = params.cwd ?? process.cwd();
|
||||
this.env = params.env;
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
@@ -1861,10 +1858,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
|
||||
rotateSessionId(sessionId: string): void {
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
resetNewSessionState(sessionId: string): void {
|
||||
this.setSessionId(sessionId);
|
||||
}
|
||||
@@ -1938,9 +1931,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
activateFallbackMode(model: string, failedModel?: string): void {
|
||||
debugLogger.log(
|
||||
`Model fallback activated: switching from ${failedModel ?? 'unknown'} to ${model}`,
|
||||
);
|
||||
if (this.getActiveModel() !== model) {
|
||||
this.setModel(model, true);
|
||||
}
|
||||
|
||||
@@ -135,29 +135,6 @@ describe('AgentHistoryProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use unambiguous label in fallback summary to avoid LLM confusion', async () => {
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
generateContentMock.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15);
|
||||
// The fallback summary should use clear and unambiguous phrasing
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'Previous User Intent (Truncated):',
|
||||
);
|
||||
expect(result[0].parts![0].text).not.toContain('Last User Intent:');
|
||||
});
|
||||
|
||||
it('should pass the contextual bridge to the summarizer', async () => {
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
|
||||
@@ -267,9 +267,7 @@ export class AgentHistoryProvider {
|
||||
];
|
||||
|
||||
if (lastUserText) {
|
||||
summaryParts.push(
|
||||
`- **Previous User Intent (Truncated):** "${lastUserText}"`,
|
||||
);
|
||||
summaryParts.push(`- **Last User Intent:** "${lastUserText}"`);
|
||||
}
|
||||
|
||||
if (actionPath) {
|
||||
|
||||
@@ -165,16 +165,6 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -371,16 +361,6 @@ An approved plan is available for this task at \`../plans/feature-x.md\`.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -691,16 +671,6 @@ ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal app
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -875,16 +845,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1045,16 +1005,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1198,16 +1148,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -1897,16 +1837,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2081,16 +2011,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2269,16 +2189,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2457,16 +2367,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2641,16 +2541,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2819,16 +2709,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -2971,16 +2851,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3155,16 +3025,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3485,16 +3345,6 @@ You are operating with a persistent file-based task tracking system located at \
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -3924,16 +3774,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -4108,16 +3948,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -4411,16 +4241,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
@@ -4595,16 +4415,6 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. \`replace\`, \`write_file\`), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AgentChatHistory, type HistoryTurn } from './agentChatHistory.js';
|
||||
|
||||
describe('AgentChatHistory', () => {
|
||||
const dummyTurns: HistoryTurn[] = [
|
||||
{
|
||||
id: 'turn-1',
|
||||
content: { role: 'user', parts: [{ text: 'Hello' }] },
|
||||
},
|
||||
{
|
||||
id: 'turn-2',
|
||||
content: { role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
},
|
||||
{
|
||||
id: 'turn-3',
|
||||
content: { role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
},
|
||||
];
|
||||
|
||||
it('should initialize with empty history by default', () => {
|
||||
const history = new AgentChatHistory();
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with provided turns', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should push new turns', () => {
|
||||
const history = new AgentChatHistory();
|
||||
history.push(dummyTurns[0]);
|
||||
expect(history.length).toBe(1);
|
||||
expect(history.get()[0]).toEqual(dummyTurns[0]);
|
||||
});
|
||||
|
||||
it('should set and overwrite history turns', () => {
|
||||
const history = new AgentChatHistory(dummyTurns.slice(0, 1));
|
||||
expect(history.length).toBe(1);
|
||||
history.set(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should clear history', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.length).toBe(3);
|
||||
history.clear();
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('rollback', () => {
|
||||
it('should roll back history to a specified length', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(1);
|
||||
expect(history.length).toBe(1);
|
||||
expect(history.get()).toEqual([dummyTurns[0]]);
|
||||
});
|
||||
|
||||
it('should roll back to 0', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(0);
|
||||
expect(history.length).toBe(0);
|
||||
expect(history.get()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should do nothing if rollback length is out of bounds (negative)', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(-1);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
|
||||
it('should do nothing if rollback length is out of bounds (greater than current history length)', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
history.rollback(5);
|
||||
expect(history.length).toBe(3);
|
||||
expect(history.get()).toEqual(dummyTurns);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return raw Content array via getContents()', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
expect(history.getContents()).toEqual(
|
||||
dummyTurns.map((turn) => turn.content),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support mapping and flatMapping operations', () => {
|
||||
const history = new AgentChatHistory(dummyTurns);
|
||||
const mappedIds = history.map((turn) => turn.id);
|
||||
expect(mappedIds).toEqual(['turn-1', 'turn-2', 'turn-3']);
|
||||
|
||||
const flatMappedParts = history.flatMap((turn) => turn.content.parts || []);
|
||||
expect(flatMappedParts).toEqual([
|
||||
{ text: 'Hello' },
|
||||
{ text: 'Hi there' },
|
||||
{ text: 'How are you?' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -46,16 +46,6 @@ export class AgentChatHistory {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls back the history to a specified length.
|
||||
* Useful when a stream fails and we need to remove the un-responded turn(s).
|
||||
*/
|
||||
rollback(length: number) {
|
||||
if (length >= 0 && length <= this.history.length) {
|
||||
this.history = this.history.slice(0, length);
|
||||
}
|
||||
}
|
||||
|
||||
get(): readonly HistoryTurn[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockReturnValue(contentGeneratorConfig),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getModel: vi.fn().mockReturnValue('gemini-1.5-pro'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getUserTier: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
|
||||
getApiKey: vi.fn().mockReturnValue('test-key'),
|
||||
|
||||
@@ -154,13 +154,6 @@ export async function createContentGeneratorConfig(
|
||||
vertexAiRouting,
|
||||
};
|
||||
|
||||
const getEnv = (key: string) => {
|
||||
if (config?.env && config.env[key] !== undefined) {
|
||||
return config.env[key];
|
||||
}
|
||||
return process.env[key];
|
||||
};
|
||||
|
||||
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
|
||||
// Return before touching the API-key keychain: on Linux without a Secret Service
|
||||
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
|
||||
@@ -172,13 +165,16 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
const geminiApiKey =
|
||||
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
|
||||
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
|
||||
const googleCloudProject =
|
||||
getEnv('GOOGLE_CLOUD_PROJECT') ||
|
||||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
|
||||
apiKey ||
|
||||
process.env['GEMINI_API_KEY'] ||
|
||||
(await loadApiKey()) ||
|
||||
undefined;
|
||||
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
|
||||
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
||||
const googleCloudProject =
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
|
||||
undefined;
|
||||
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
|
||||
|
||||
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
|
||||
contentGeneratorConfig.apiKey = geminiApiKey;
|
||||
@@ -198,7 +194,8 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
if (authType === AuthType.GATEWAY) {
|
||||
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
|
||||
contentGeneratorConfig.apiKey =
|
||||
apiKey || process.env['GEMINI_API_KEY'] || '';
|
||||
contentGeneratorConfig.vertexai = false;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,11 +30,7 @@ import {
|
||||
getRetryErrorType,
|
||||
} from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import {
|
||||
resolveModel,
|
||||
supportsModernFeatures,
|
||||
isGemini2Model,
|
||||
} from '../config/models.js';
|
||||
import { resolveModel, supportsModernFeatures } from '../config/models.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import type { CompletedToolCall } from '../scheduler/types.js';
|
||||
@@ -108,13 +104,6 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
|
||||
|
||||
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
/**
|
||||
* Stands in for a model turn that never arrived because the stream failed
|
||||
* after a tool response was already committed to history.
|
||||
*/
|
||||
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
|
||||
'[The previous response was interrupted before it completed.]';
|
||||
|
||||
/**
|
||||
* Internal interface for parts that carry the magic 'callIndex' property
|
||||
* used during model response consolidation.
|
||||
@@ -232,12 +221,7 @@ export class InvalidStreamError extends Error {
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE';
|
||||
| 'UNEXPECTED_TOOL_CALL';
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -245,12 +229,7 @@ export class InvalidStreamError extends Error {
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE',
|
||||
| 'UNEXPECTED_TOOL_CALL',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InvalidStreamError';
|
||||
@@ -404,9 +383,6 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<StreamEvent>> {
|
||||
await this.sendPromise;
|
||||
|
||||
const historyLengthBefore = this.agentHistory.length;
|
||||
const baselinePromptTokenCount = this.lastPromptTokenCount;
|
||||
|
||||
let streamDoneResolver: () => void;
|
||||
const streamDonePromise = new Promise<void>((resolve) => {
|
||||
streamDoneResolver = resolve;
|
||||
@@ -414,17 +390,6 @@ export class GeminiChat {
|
||||
this.sendPromise = streamDonePromise;
|
||||
|
||||
let userContent = createUserContent(message);
|
||||
const isOriginalFunctionResponse = isFunctionResponse(userContent);
|
||||
|
||||
// A turn can end leaving history on an unanswered tool response: a stream
|
||||
// error after the response was committed, or a cancelled tool call. Close
|
||||
// it before recording a genuinely new user message, otherwise the two user
|
||||
// turns are coalesced into one and the model continues the trailing text
|
||||
// instead of answering it.
|
||||
if (!isOriginalFunctionResponse) {
|
||||
this.closeUnansweredToolResponseTurn();
|
||||
}
|
||||
|
||||
const { model } =
|
||||
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
|
||||
@@ -433,7 +398,7 @@ export class GeminiChat {
|
||||
|
||||
// Record user input - capture complete message with all parts (text, files, images, etc.)
|
||||
// but skip recording function responses (tool call results) as they should be stored in tool call records
|
||||
if (!isOriginalFunctionResponse) {
|
||||
if (!isFunctionResponse(userContent)) {
|
||||
const userMessageParts = userContent.parts || [];
|
||||
const userMessageContent = partListUnionToString(userMessageParts);
|
||||
|
||||
@@ -550,7 +515,6 @@ export class GeminiChat {
|
||||
): AsyncGenerator<StreamEvent, void, void> {
|
||||
try {
|
||||
const maxAttempts = this.context.config.getMaxAttempts();
|
||||
let lastStreamError: unknown = undefined;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
let isConnectionPhase = true;
|
||||
@@ -562,7 +526,7 @@ export class GeminiChat {
|
||||
// If this is a retry, update the key with the new context.
|
||||
const currentConfigKey =
|
||||
attempt > 0
|
||||
? { ...modelConfigKey, isRetry: true, lastStreamError }
|
||||
? { ...modelConfigKey, isRetry: true }
|
||||
: modelConfigKey;
|
||||
|
||||
isConnectionPhase = true;
|
||||
@@ -581,10 +545,6 @@ export class GeminiChat {
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStreamError) {
|
||||
lastStreamError = error;
|
||||
}
|
||||
|
||||
if (error instanceof AgentExecutionStoppedError) {
|
||||
yield {
|
||||
type: StreamEventType.AGENT_EXECUTION_STOPPED,
|
||||
@@ -621,7 +581,8 @@ export class GeminiChat {
|
||||
);
|
||||
|
||||
const isContentError = error instanceof InvalidStreamError;
|
||||
const isRetryableContentError = isContentError;
|
||||
const isRetryableContentError =
|
||||
isContentError && error.type !== 'NO_RESPONSE_TEXT';
|
||||
const errorType = isContentError
|
||||
? error.type
|
||||
: getRetryErrorType(error);
|
||||
@@ -683,15 +644,6 @@ export class GeminiChat {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isOriginalFunctionResponse) {
|
||||
this.agentHistory.rollback(historyLengthBefore);
|
||||
this.chatRecordingService.updateMessagesFromHistory(
|
||||
this.agentHistory.get(),
|
||||
);
|
||||
this.lastPromptTokenCount = baselinePromptTokenCount;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
streamDoneResolver!();
|
||||
}
|
||||
@@ -700,28 +652,6 @@ export class GeminiChat {
|
||||
return streamWithRetries.call(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a closing model turn when history ends with an unanswered tool
|
||||
* response, so the next user message stays a turn of its own.
|
||||
*/
|
||||
private closeUnansweredToolResponseTurn(): void {
|
||||
const turns = this.agentHistory.get();
|
||||
const last = turns[turns.length - 1];
|
||||
if (
|
||||
last?.content.role !== 'user' ||
|
||||
!last.content.parts?.some((part) => !!part.functionResponse)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.agentHistory.push({
|
||||
id: randomUUID(),
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private extractBinaryInjections(
|
||||
parts: Part[] | undefined,
|
||||
): Part[] | undefined {
|
||||
@@ -753,13 +683,10 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
|
||||
// before sending to the Gemini API. This whitelists only standard Gemini fields.
|
||||
let scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
const scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
? scrubHistory([...requestHistory])
|
||||
: [...requestHistory];
|
||||
|
||||
// Always coalesce consecutive roles to prevent 400 Bad Request errors
|
||||
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
|
||||
|
||||
const scrubbedContents = scrubbedHistory.map((h) => h.content);
|
||||
|
||||
const requestContents = apiHistoryOverride
|
||||
@@ -836,34 +763,9 @@ export class GeminiChat {
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
// Apply Context-Aware Retries (On-Retry Nudging) to guide the model out of silent loops
|
||||
if (
|
||||
modelConfigKey.isRetry &&
|
||||
modelConfigKey.lastStreamError instanceof InvalidStreamError
|
||||
) {
|
||||
const lastError = modelConfigKey.lastStreamError;
|
||||
let nudgeMessage = '';
|
||||
if (lastError.type === 'THINKING_ONLY_RESPONSE') {
|
||||
nudgeMessage =
|
||||
'\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]';
|
||||
} else if (lastError.type === 'NO_RESPONSE_TEXT') {
|
||||
nudgeMessage =
|
||||
'\n[System: You previously returned an empty response with no text or thoughts. Please ensure you provide your final answer or call a tool now.]';
|
||||
}
|
||||
|
||||
if (nudgeMessage) {
|
||||
if (typeof config.systemInstruction === 'string') {
|
||||
config.systemInstruction += nudgeMessage;
|
||||
} else if (config.systemInstruction === undefined) {
|
||||
config.systemInstruction = nudgeMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let contentsToUse: Content[] =
|
||||
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
@@ -905,10 +807,9 @@ export class GeminiChat {
|
||||
);
|
||||
lastModelToUse = modelToUse;
|
||||
// Re-evaluate contentsToUse based on the new model's feature support
|
||||
contentsToUse =
|
||||
supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
contentsToUse = supportsModernFeatures(modelToUse)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
}
|
||||
if (beforeModelResult.modifiedConfig) {
|
||||
Object.assign(config, beforeModelResult.modifiedConfig);
|
||||
@@ -1052,16 +953,9 @@ export class GeminiChat {
|
||||
? extractCuratedHistory(this.agentHistory.get())
|
||||
: [...this.agentHistory.get()];
|
||||
|
||||
if (this.context.config.isContextManagementEnabled()) {
|
||||
return scrubHistory(history);
|
||||
}
|
||||
|
||||
const model = this.context.config.getModel();
|
||||
if (isGemini2Model(model) || supportsModernFeatures(model)) {
|
||||
return coalesceConsecutiveRoles(stripThoughts(history));
|
||||
}
|
||||
|
||||
return history;
|
||||
return this.context.config.isContextManagementEnabled()
|
||||
? scrubHistory(history)
|
||||
: history;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1134,19 +1028,11 @@ export class GeminiChat {
|
||||
requestContents: readonly Content[],
|
||||
): readonly Content[] {
|
||||
// First, find the start of the active loop by finding the last user turn
|
||||
// with a text message, i.e. that is not a function response. Testing for
|
||||
// text alone is not enough: `coalesceConsecutiveRoles` can merge a function
|
||||
// response turn with the prompt that follows it, and starting the loop at
|
||||
// such a turn starts it later than the API starts the turn, leaving earlier
|
||||
// function calls unsigned but still validated.
|
||||
// with a text message, i.e. that is not a function response.
|
||||
let activeLoopStartIndex = -1;
|
||||
for (let i = requestContents.length - 1; i >= 0; i--) {
|
||||
const content = requestContents[i];
|
||||
if (
|
||||
content.role === 'user' &&
|
||||
content.parts?.some((part) => part.text) &&
|
||||
!content.parts?.some((part) => part.functionResponse)
|
||||
) {
|
||||
if (content.role === 'user' && content.parts?.some((part) => part.text)) {
|
||||
activeLoopStartIndex = i;
|
||||
break;
|
||||
}
|
||||
@@ -1233,13 +1119,6 @@ export class GeminiChat {
|
||||
let hasThoughts = false;
|
||||
let finishReason: FinishReason | undefined;
|
||||
|
||||
// Buffers to prevent failed stream attempts from polluting telemetry and logs
|
||||
const bufferedThoughts: Array<{ subject: string; description: string }> =
|
||||
[];
|
||||
let bufferedUsageMetadata:
|
||||
| GenerateContentResponse['usageMetadata']
|
||||
| undefined = undefined;
|
||||
|
||||
// The SDK provides fully assembled FunctionCall objects in chunk.functionCalls
|
||||
// We use a Map to ensure we only keep the latest version of each call (by ID)
|
||||
const finalFunctionCallsMap = new Map<string, FunctionCall>();
|
||||
@@ -1295,10 +1174,7 @@ export class GeminiChat {
|
||||
if (content.parts.some((part) => part.thought)) {
|
||||
// Record thoughts
|
||||
hasThoughts = true;
|
||||
const thought = this.extractThoughtFromContent(content);
|
||||
if (thought) {
|
||||
bufferedThoughts.push(thought);
|
||||
}
|
||||
this.recordThoughtFromContent(content);
|
||||
}
|
||||
if (content.parts.some((part) => part.functionCall)) {
|
||||
hasToolCall = true;
|
||||
@@ -1326,9 +1202,12 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer token usage if this chunk has usageMetadata
|
||||
// Record token usage if this chunk has usageMetadata
|
||||
if (chunk.usageMetadata) {
|
||||
bufferedUsageMetadata = chunk.usageMetadata;
|
||||
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
|
||||
if (chunk.usageMetadata.promptTokenCount !== undefined) {
|
||||
this.lastPromptTokenCount = chunk.usageMetadata.promptTokenCount;
|
||||
}
|
||||
}
|
||||
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
@@ -1415,22 +1294,29 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
const rawResponseText = consolidatedParts
|
||||
const responseText = consolidatedParts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
// Clean zero-width/invisible characters and HTML comments to determine actual printable/visible content
|
||||
let responseText = rawResponseText.replace(
|
||||
/[\u200B-\u200D\uFEFF\u200E\u200F]/g,
|
||||
'',
|
||||
);
|
||||
let previous: string;
|
||||
do {
|
||||
previous = responseText;
|
||||
responseText = responseText.replace(/<!--[\s\S]*?-->/g, '');
|
||||
} while (responseText !== previous);
|
||||
responseText = responseText.trim();
|
||||
let id: string;
|
||||
// Record model response text from the collected parts.
|
||||
// Also flush when there are thoughts or a tool call (even with no text)
|
||||
// so that BeforeTool hooks always see the latest transcript state.
|
||||
if (responseText || hasThoughts || hasToolCall) {
|
||||
id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'gemini',
|
||||
content: responseText,
|
||||
});
|
||||
} else {
|
||||
// Still need a durable ID even if response is empty (e.g. only tool calls)
|
||||
id = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
consolidatedParts,
|
||||
);
|
||||
}
|
||||
|
||||
// Stream validation logic: A stream is considered successful if:
|
||||
// 1. There's a tool call OR
|
||||
@@ -1460,36 +1346,6 @@ export class GeminiChat {
|
||||
);
|
||||
}
|
||||
if (!responseText) {
|
||||
if (finishReason === FinishReason.MAX_TOKENS) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to token limit exhaustion (MAX_TOKENS) with empty response text.',
|
||||
'MAX_TOKENS_EXCEEDED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.SAFETY) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to safety settings (SAFETY) with empty response text.',
|
||||
'SAFETY_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.RECITATION) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to recitation settings (RECITATION) with empty response text.',
|
||||
'RECITATION_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (finishReason === FinishReason.OTHER) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended due to other settings (OTHER) with empty response text.',
|
||||
'OTHER_BLOCKED',
|
||||
);
|
||||
}
|
||||
if (hasThoughts) {
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended with empty response text but contained reasoning thoughts.',
|
||||
'THINKING_ONLY_RESPONSE',
|
||||
);
|
||||
}
|
||||
throw new InvalidStreamError(
|
||||
'Model stream ended with empty response text.',
|
||||
'NO_RESPONSE_TEXT',
|
||||
@@ -1497,37 +1353,6 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush buffered thoughts from the successful attempt
|
||||
for (const thought of bufferedThoughts) {
|
||||
this.chatRecordingService.recordThought(thought);
|
||||
}
|
||||
|
||||
// Flush buffered usage metadata and token counts from the successful attempt
|
||||
if (bufferedUsageMetadata) {
|
||||
this.chatRecordingService.recordMessageTokens(bufferedUsageMetadata);
|
||||
if (bufferedUsageMetadata.promptTokenCount !== undefined) {
|
||||
this.lastPromptTokenCount = bufferedUsageMetadata.promptTokenCount;
|
||||
}
|
||||
}
|
||||
|
||||
let id: string;
|
||||
// Record model response text from the collected parts.
|
||||
// Also flush when there are thoughts or a tool call (even with no text)
|
||||
// so that BeforeTool hooks always see the latest transcript state.
|
||||
if (responseText || hasThoughts || hasToolCall) {
|
||||
id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'gemini',
|
||||
content: responseText,
|
||||
});
|
||||
} else {
|
||||
// Still need a durable ID even if response is empty (e.g. only tool calls)
|
||||
id = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
consolidatedParts,
|
||||
);
|
||||
}
|
||||
|
||||
this.agentHistory.push({
|
||||
id,
|
||||
content: { role: 'model', parts: consolidatedParts },
|
||||
@@ -1582,13 +1407,11 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts thought from thought content.
|
||||
* Extracts and records thought from thought content.
|
||||
*/
|
||||
private extractThoughtFromContent(
|
||||
content: Content,
|
||||
): { subject: string; description: string } | undefined {
|
||||
private recordThoughtFromContent(content: Content): void {
|
||||
if (!content.parts || content.parts.length === 0) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const thoughtPart = content.parts[0];
|
||||
@@ -1601,12 +1424,11 @@ export class GeminiChat {
|
||||
: '';
|
||||
const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
|
||||
|
||||
return {
|
||||
this.chatRecordingService.recordThought({
|
||||
subject,
|
||||
description,
|
||||
};
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1650,73 +1472,3 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function coalesceConsecutiveRoles(
|
||||
history: HistoryTurn[],
|
||||
): HistoryTurn[] {
|
||||
const result: HistoryTurn[] = [];
|
||||
for (const turn of history) {
|
||||
const lastIdx = result.length - 1;
|
||||
const last = result[lastIdx];
|
||||
if (last && last.content.role && last.content.role === turn.content.role) {
|
||||
const hasParts = last.content.parts || turn.content.parts;
|
||||
result[lastIdx] = {
|
||||
id: last.id,
|
||||
content: {
|
||||
...last.content,
|
||||
parts: hasParts
|
||||
? [...(last.content.parts || []), ...(turn.content.parts || [])]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
result.push({
|
||||
id: turn.id,
|
||||
content: { ...turn.content },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
|
||||
return history
|
||||
.map((turn) => {
|
||||
if (!turn.content.parts) return turn;
|
||||
const hasThought = turn.content.parts.some((p) => p && p.thought);
|
||||
if (!hasThought) return turn;
|
||||
|
||||
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
|
||||
|
||||
// The thoughtSignature the API requires on the first functionCall of a
|
||||
// model turn is sometimes only carried by the thought part we just
|
||||
// removed, not by the functionCall part itself. Without it, replaying
|
||||
// this turn in a later request gets rejected with a 400 "missing
|
||||
// thought_signature" error, so inject a synthetic one if needed.
|
||||
let patchedFirstCall = false;
|
||||
const finalParts =
|
||||
turn.content.role === 'model'
|
||||
? nonThoughtParts.map((p) => {
|
||||
if (!patchedFirstCall && p.functionCall) {
|
||||
patchedFirstCall = true;
|
||||
if (!p.thoughtSignature) {
|
||||
return {
|
||||
...p,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
return p;
|
||||
})
|
||||
: nonThoughtParts;
|
||||
|
||||
return {
|
||||
...turn,
|
||||
content: {
|
||||
...turn.content,
|
||||
parts: finalParts,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((turn) => !turn.content.parts || turn.content.parts.length > 0);
|
||||
}
|
||||
|
||||
@@ -254,15 +254,7 @@ describe('Turn', () => {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: 'NO_FINISH_REASON',
|
||||
message: 'Test invalid stream',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
|
||||
expect(turn.getDebugResponses().length).toBe(0);
|
||||
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
|
||||
});
|
||||
|
||||
@@ -105,19 +105,6 @@ export type ServerGeminiContextWindowWillOverflowEvent = {
|
||||
|
||||
export type ServerGeminiInvalidStreamEvent = {
|
||||
type: GeminiEventType.InvalidStream;
|
||||
value: {
|
||||
type:
|
||||
| 'NO_FINISH_REASON'
|
||||
| 'NO_RESPONSE_TEXT'
|
||||
| 'MALFORMED_FUNCTION_CALL'
|
||||
| 'UNEXPECTED_TOOL_CALL'
|
||||
| 'MAX_TOKENS_EXCEEDED'
|
||||
| 'SAFETY_BLOCKED'
|
||||
| 'RECITATION_BLOCKED'
|
||||
| 'OTHER_BLOCKED'
|
||||
| 'THINKING_ONLY_RESPONSE';
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServerGeminiModelInfoEvent = {
|
||||
@@ -421,13 +408,7 @@ export class Turn {
|
||||
}
|
||||
|
||||
if (e instanceof InvalidStreamError) {
|
||||
yield {
|
||||
type: GeminiEventType.InvalidStream,
|
||||
value: {
|
||||
type: e.type,
|
||||
message: e.message,
|
||||
},
|
||||
};
|
||||
yield { type: GeminiEventType.InvalidStream };
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setActiveModel: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
activateFallbackMode: vi.fn(),
|
||||
rotateSessionId: vi.fn(),
|
||||
getModelAvailabilityService: vi.fn(() =>
|
||||
createAvailabilityServiceMock({
|
||||
selectedModel: FALLBACK_MODEL,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { createSessionId } from '../utils/session.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
@@ -162,9 +161,8 @@ async function processIntent(
|
||||
): Promise<boolean> {
|
||||
switch (intent) {
|
||||
case 'retry_always':
|
||||
// Rotate the session ID to ensure the backend treats the retried request
|
||||
// as a brand-new session, preventing stateful model-switching errors.
|
||||
config.rotateSessionId(createSessionId());
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return true;
|
||||
|
||||
|
||||
@@ -109,90 +109,23 @@ priority = 50
|
||||
modes = ["plan"]
|
||||
interactive = true
|
||||
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform).
|
||||
# This rule employs split, traversal-safe, and ReDoS-safe patterns to provide defense-in-depth:
|
||||
# - Absolute paths must strictly be inside the designated plans directory under `.gemini/tmp/`
|
||||
# - Relative paths must be clean (no path traversal `..` and no absolute prefixes)
|
||||
|
||||
# 1. Absolute paths with session ID
|
||||
# Allow write_file and replace for .md files in the plans directory (cross-platform)
|
||||
# We split this into two rules to avoid ReDoS checker issues with nested optional segments.
|
||||
# This rule handles the case where there is a session ID in the plan file path
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 2. Absolute paths without session ID
|
||||
# This rule handles the case where there isn't a session ID in the plan file path
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[^\\\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 3. Relative paths starting with .gemini (with session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 4. Relative paths starting with .gemini (without session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 5. Relative paths starting with ./.gemini (with session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 6. Relative paths starting with ./.gemini (without session ID)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 7. Clean relative filename (no directories, e.g. plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 8. Clean relative path starting with ./ (no directories, e.g. ./plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 9. Clean relative path under plans directory (e.g. plans/plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# 10. Clean relative path under plans directory starting with ./ (e.g. ./plans/plan.md)
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
argsPattern = "\\x00\"file_path\":\"\\.[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00"
|
||||
|
||||
# Explicitly Deny other write operations in Plan mode with a clear message.
|
||||
[[rule]]
|
||||
|
||||
@@ -413,16 +413,6 @@ export function renderOperationalGuidelines(
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Tool Execution Response Rules:**
|
||||
1. After receiving a \`functionResponse\`, you MUST ALWAYS execute one of the following two actions:
|
||||
a) Call another tool to proceed with the task.
|
||||
b) Provide a user-facing text response explaining the tool output, your analysis, and next steps.
|
||||
2. You MUST NEVER return an empty response with no text and no tool calls.
|
||||
- **Post-Edit Response Rules:**
|
||||
1. After an edit tool execution (e.g. ${formatToolName(EDIT_TOOL_NAME)}, ${formatToolName(WRITE_FILE_TOOL_NAME)}), you MUST ALWAYS generate a user-facing text response summarizing:
|
||||
- What changes were made to the file.
|
||||
- Your verification plan or next steps (e.g. running tests).
|
||||
2. You MUST NEVER return an empty response with 0 text tokens after completing an edit.
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the ${formatToolName(EDIT_TOOL_NAME)} tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
|
||||
|
||||
@@ -34,10 +34,6 @@ describe('CheckerRunner', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockContextBuilder = new ContextBuilder({} as Config);
|
||||
vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({
|
||||
env: {},
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
} as unknown as Config);
|
||||
mockRegistry = new CheckerRegistry('/mock/dist');
|
||||
CheckerRegistry.prototype.resolveInProcess = vi.fn();
|
||||
|
||||
|
||||
@@ -168,8 +168,6 @@ export class CheckerRunner {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(checkerPath, [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: this.contextBuilder.config.getWorkingDir(),
|
||||
env: { ...process.env, ...this.contextBuilder.config.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
|
||||
@@ -15,10 +15,6 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
export class ContextBuilder {
|
||||
constructor(private readonly context: AgentLoopContext) {}
|
||||
|
||||
get config() {
|
||||
return this.context.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full context object with all available data.
|
||||
*/
|
||||
|
||||
@@ -630,8 +630,8 @@ describe('Scheduler (Orchestrator)', () => {
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
// finalizeCall is called synchronously by cancelAll to ensure completedBatch is populated and isActive is updated immediately
|
||||
expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
// finalizeCall is handled by the processing loop, not synchronously by cancelAll
|
||||
// expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1');
|
||||
expect(mockStateManager.cancelAllQueued).toHaveBeenCalledWith(
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
|
||||
@@ -278,7 +278,6 @@ export class Scheduler {
|
||||
CoreToolCallStatus.Cancelled,
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
this.state.finalizeCall(activeCall.request.callId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,14 +438,6 @@ export class Scheduler {
|
||||
*/
|
||||
private async _processNextItem(signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted || this.isCancelling) {
|
||||
// Finalize active calls that are terminal
|
||||
const activeCalls = this.state.allActiveCalls;
|
||||
for (const call of activeCalls) {
|
||||
if (this.isTerminal(call.status)) {
|
||||
this.state.finalizeCall(call.request.callId);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.cancelAllQueued('Operation cancelled');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -308,125 +308,6 @@ describe('ChatRecordingService', () => {
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.sessionId).toBe('old-session-id');
|
||||
});
|
||||
|
||||
it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => {
|
||||
// Regression test for the `/compress` "Failed to load resumed session
|
||||
// data from file" bug: when resuming with a filePath that cannot be
|
||||
// loaded from disk, initialize must NOT throw. It should adopt the
|
||||
// in-memory conversation it was handed and rewrite a clean file.
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const missingFile = path.join(chatsDir, 'missing-session.jsonl');
|
||||
expect(fs.existsSync(missingFile)).toBe(false);
|
||||
|
||||
const inMemoryConversation = {
|
||||
sessionId: 'resumed-session-id',
|
||||
projectHash: 'resumed-project-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: 'hello from memory',
|
||||
},
|
||||
],
|
||||
} as unknown as ConversationRecord;
|
||||
|
||||
await expect(
|
||||
chatRecordingService.initialize({
|
||||
filePath: missingFile,
|
||||
conversation: inMemoryConversation,
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The in-memory conversation is adopted.
|
||||
expect(chatRecordingService.getConversation()?.sessionId).toBe(
|
||||
'resumed-session-id',
|
||||
);
|
||||
|
||||
// A clean, loadable file is rewritten from the in-memory copy so future
|
||||
// loads and appends succeed.
|
||||
const reloaded = (await loadConversationRecord(
|
||||
missingFile,
|
||||
)) as ConversationRecord;
|
||||
expect(reloaded).not.toBeNull();
|
||||
expect(reloaded.sessionId).toBe('resumed-session-id');
|
||||
expect(reloaded.projectHash).toBe('resumed-project-hash');
|
||||
expect(reloaded.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should preserve an unreadable session file instead of destroying it', async () => {
|
||||
// The reload may have failed only transiently, so the original bytes
|
||||
// must survive the recovery rewrite.
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'unreadable.jsonl');
|
||||
|
||||
// No usable metadata line => loadConversationRecord() returns null.
|
||||
const originalBytes = '{"not":"a valid metadata line"}\n';
|
||||
fs.writeFileSync(sessionFile, originalBytes);
|
||||
|
||||
await chatRecordingService.initialize({
|
||||
filePath: sessionFile,
|
||||
conversation: {
|
||||
sessionId: 'recovered-session-id',
|
||||
projectHash: 'recovered-project-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [],
|
||||
} as unknown as ConversationRecord,
|
||||
});
|
||||
|
||||
// The rewritten file is loadable again...
|
||||
const reloaded = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(reloaded.sessionId).toBe('recovered-session-id');
|
||||
|
||||
// ...and the original bytes were kept alongside it.
|
||||
const preserved = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.startsWith('unreadable.jsonl.unreadable-'));
|
||||
expect(preserved).toHaveLength(1);
|
||||
expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe(
|
||||
originalBytes,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not leave a temp file behind when the rewrite fails', async () => {
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl');
|
||||
|
||||
// Fail the rename that publishes the temp file, leaving it orphaned.
|
||||
const realRename = fs.renameSync;
|
||||
vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
|
||||
if (String(from).includes('.tmp-')) {
|
||||
throw new Error('simulated rename failure');
|
||||
}
|
||||
return realRename(from, to);
|
||||
});
|
||||
|
||||
await expect(
|
||||
chatRecordingService.initialize({
|
||||
filePath: sessionFile,
|
||||
conversation: {
|
||||
sessionId: 'temp-cleanup-session',
|
||||
projectHash: 'temp-cleanup-hash',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages: [],
|
||||
} as unknown as ConversationRecord,
|
||||
}),
|
||||
).rejects.toThrow('simulated rename failure');
|
||||
|
||||
const leftovers = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.includes('.tmp-'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordMessage', () => {
|
||||
|
||||
@@ -462,16 +462,7 @@ export class ChatRecordingService {
|
||||
// Update the session ID in the existing file
|
||||
this.updateMetadata({ sessionId: this.sessionId });
|
||||
} else {
|
||||
// The file could not be reloaded (missing, corrupt metadata, or an
|
||||
// I/O error). Fall back to the in-memory conversation we were handed
|
||||
// rather than failing the caller, and rewrite a clean file from it.
|
||||
debugLogger.warn(
|
||||
'Failed to reload resumed session data from file; falling back ' +
|
||||
'to the in-memory conversation.',
|
||||
);
|
||||
this.cachedConversation = resumedSessionData.conversation;
|
||||
this.projectHash = this.cachedConversation.projectHash;
|
||||
this.rewriteConversationFile(this.cachedConversation);
|
||||
throw new Error('Failed to load resumed session data from file');
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
@@ -572,73 +563,6 @@ export class ChatRecordingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the session file from an in-memory record. Any existing
|
||||
* (unreadable) file is preserved alongside rather than destroyed, and the
|
||||
* new file is written atomically (temp file + rename).
|
||||
*/
|
||||
private rewriteConversationFile(conversation: ConversationRecord): void {
|
||||
if (!this.conversationFile) return;
|
||||
|
||||
// Normalize legacy `.json` paths to the `.jsonl` format we write.
|
||||
if (this.conversationFile.endsWith('.json')) {
|
||||
this.conversationFile = this.conversationFile + 'l';
|
||||
}
|
||||
|
||||
const { messages, memoryScratchpad, ...metadata } = conversation;
|
||||
const lines: string[] = [JSON.stringify(metadata)];
|
||||
for (const msg of messages) {
|
||||
lines.push(JSON.stringify(msg));
|
||||
}
|
||||
if (memoryScratchpad) {
|
||||
lines.push(JSON.stringify({ $set: { memoryScratchpad } }));
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
|
||||
|
||||
// The existing file was unreadable, but it may have been only
|
||||
// transiently so (a lock or I/O blip) rather than truly corrupt. Keep
|
||||
// its bytes rather than destroying them.
|
||||
if (fs.existsSync(this.conversationFile)) {
|
||||
const backup = `${this.conversationFile}.unreadable-${Date.now()}`;
|
||||
try {
|
||||
fs.renameSync(this.conversationFile, backup);
|
||||
debugLogger.warn(
|
||||
`Preserved the unreadable session file at ${backup}.`,
|
||||
);
|
||||
} catch (backupError) {
|
||||
debugLogger.error(
|
||||
'Failed to preserve the unreadable session file.',
|
||||
backupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tempFile = `${this.conversationFile}.tmp-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(tempFile, content);
|
||||
fs.renameSync(tempFile, this.conversationFile);
|
||||
} catch (error) {
|
||||
// The rename did not complete, so the temp file would be left behind.
|
||||
try {
|
||||
fs.unlinkSync(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors so the original failure still surfaces.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOSPC') {
|
||||
this.conversationFile = null;
|
||||
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateMetadata(updates: Partial<ConversationRecord>): void {
|
||||
if (!this.cachedConversation) return;
|
||||
Object.assign(this.cachedConversation, updates);
|
||||
|
||||
@@ -27,15 +27,8 @@ export class FileKeychain implements Keychain {
|
||||
}
|
||||
|
||||
private encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-gcm',
|
||||
this.encryptionKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength: 16,
|
||||
},
|
||||
);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
@@ -55,19 +48,10 @@ export class FileKeychain implements Keychain {
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
if (iv.length !== 12 && iv.length !== 16) {
|
||||
throw new Error('Invalid IV length: Must be 12 or 16 bytes');
|
||||
}
|
||||
|
||||
if (authTag.length !== 16) {
|
||||
throw new Error('Invalid authentication tag length: Must be 16 bytes');
|
||||
}
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.encryptionKey,
|
||||
iv,
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
@@ -83,27 +67,29 @@ export class FileKeychain implements Keychain {
|
||||
}
|
||||
|
||||
private async loadData(): Promise<Record<string, Record<string, string>>> {
|
||||
let data: string;
|
||||
try {
|
||||
data = await fs.readFile(this.tokenFilePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
|
||||
const decrypted = this.decrypt(data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const err = error as NodeJS.ErrnoException & { message?: string };
|
||||
if (err.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
err.message?.includes('Invalid encrypted data format') ||
|
||||
err.message?.includes(
|
||||
'Unsupported state or unable to authenticate data',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { FileKeychain } from './fileKeychain.js';
|
||||
|
||||
describe('AES-GCM Tag Length Verification', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temporary directory for test isolation
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-keychain-'));
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
// Clean up the temporary directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should use a secure 128-bit (16-byte) AES-GCM authentication tag and standard 12-byte IV', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
|
||||
// 4. Verify the lengths of the components
|
||||
const ivBuffer = Buffer.from(ivHex, 'hex');
|
||||
const authTagBuffer = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
// IV should be exactly 12 bytes (96 bits) by default
|
||||
expect(ivBuffer.length).toBe(12);
|
||||
expect(ivHex.length).toBe(24);
|
||||
|
||||
// Authentication Tag should be exactly 16 bytes (128 bits)
|
||||
expect(authTagBuffer.length).toBe(16);
|
||||
expect(authTagHex.length).toBe(32); // 32 hex characters
|
||||
|
||||
// Assert that the tag is NOT truncated to 4 bytes (32 bits)
|
||||
expect(authTagBuffer.length).not.toBe(4);
|
||||
expect(authTagHex.length).not.toBe(8); // 8 hex characters
|
||||
|
||||
// 5. Verify that decryption works correctly with the 16-byte tag
|
||||
const decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
});
|
||||
|
||||
it('should support both 12-byte and 16-byte IVs for backward compatibility', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write (generates 12-byte IV)
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Verify 12-byte IV decryption works
|
||||
let decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
|
||||
// 3. Manually simulate a legacy 16-byte IV credentials file
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const legacyIv = crypto.randomBytes(16);
|
||||
const encryptionKey = (keychain as unknown as { encryptionKey: Buffer })
|
||||
.encryptionKey;
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-gcm',
|
||||
encryptionKey,
|
||||
legacyIv,
|
||||
{
|
||||
authTagLength: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify({ [service]: { [account]: password } }),
|
||||
'utf8',
|
||||
'hex',
|
||||
);
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const legacyPayload =
|
||||
legacyIv.toString('hex') +
|
||||
':' +
|
||||
authTag.toString('hex') +
|
||||
':' +
|
||||
encrypted;
|
||||
await fs.writeFile(credentialsFilePath, legacyPayload, 'utf-8');
|
||||
|
||||
// 4. Verify 16-byte IV decryption works successfully (backward compatibility)
|
||||
decryptedPassword = await keychain.getPassword(service, account);
|
||||
expect(decryptedPassword).toBe(password);
|
||||
});
|
||||
|
||||
it('should reject decryption of a credentials file with a truncated tag', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
const encryptedHex = parts[2];
|
||||
|
||||
// 4. Create a truncated 4-byte tag (8 hex characters)
|
||||
const truncatedTagHex = authTagHex.substring(0, 8);
|
||||
const truncatedEncryptedData = `${ivHex}:${truncatedTagHex}:${encryptedHex}`;
|
||||
|
||||
// 5. Overwrite the credentials file with the truncated-tag payload
|
||||
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
|
||||
|
||||
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
|
||||
await expect(keychain.getPassword(service, account)).rejects.toThrow(
|
||||
'Corrupted credentials file detected',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject decryption of a credentials file with a truncated IV', async () => {
|
||||
const keychain = new FileKeychain();
|
||||
const service = 'test-service';
|
||||
const account = 'test-account';
|
||||
const password = 'secure-password-123';
|
||||
|
||||
// 1. Save credentials to trigger encryption and file write
|
||||
await keychain.setPassword(service, account, password);
|
||||
|
||||
// 2. Read the raw encrypted file from disk
|
||||
const credentialsFilePath = path.join(
|
||||
tempDir,
|
||||
'.gemini',
|
||||
'gemini-credentials.json',
|
||||
);
|
||||
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
|
||||
|
||||
// 3. Parse the encrypted data format (iv:authTag:encrypted)
|
||||
const parts = rawEncryptedData.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const ivHex = parts[0];
|
||||
const authTagHex = parts[1];
|
||||
const encryptedHex = parts[2];
|
||||
|
||||
// 4. Create a truncated 4-byte IV (8 hex characters)
|
||||
const truncatedIvHex = ivHex.substring(0, 8);
|
||||
const truncatedEncryptedData = `${truncatedIvHex}:${authTagHex}:${encryptedHex}`;
|
||||
|
||||
// 5. Overwrite the credentials file with the truncated-IV payload
|
||||
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
|
||||
|
||||
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
|
||||
await expect(keychain.getPassword(service, account)).rejects.toThrow(
|
||||
'Corrupted credentials file detected',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -175,93 +175,6 @@ describe('LoopDetectionService', () => {
|
||||
}
|
||||
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should detect an alternating tool call loop (cycle of length 2)', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
|
||||
let loopCount = 0;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const currentEvent = i % 2 === 0 ? eventA : eventB;
|
||||
const result = service.addAndCheck(currentEvent);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect a cyclic tool call loop of length 3', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
const eventC = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_c.txt',
|
||||
});
|
||||
|
||||
let loopCount = 0;
|
||||
const sequence = [eventA, eventB, eventC];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = service.addAndCheck(sequence[i % 3]);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect a cyclic tool call loop of length 5', () => {
|
||||
const e1 = createToolCallRequestEvent('t', { id: 1 });
|
||||
const e2 = createToolCallRequestEvent('t', { id: 2 });
|
||||
const e3 = createToolCallRequestEvent('t', { id: 3 });
|
||||
const e4 = createToolCallRequestEvent('t', { id: 4 });
|
||||
const e5 = createToolCallRequestEvent('t', { id: 5 });
|
||||
|
||||
let loopCount = 0;
|
||||
const sequence = [e1, e2, e3, e4, e5];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const result = service.addAndCheck(sequence[i % 5]);
|
||||
if (result.count > 0) {
|
||||
loopCount = result.count;
|
||||
}
|
||||
}
|
||||
|
||||
expect(loopCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not detect loops for non-looping alternating sequences that vary', () => {
|
||||
const eventA = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_a.txt',
|
||||
});
|
||||
const eventB = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_b.txt',
|
||||
});
|
||||
const eventC = createToolCallRequestEvent('read_file', {
|
||||
file_path: 'loop_c.txt',
|
||||
});
|
||||
|
||||
// Run some alternating calls, then break the pattern with eventC
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
expect(service.addAndCheck(eventC).count).toBe(0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Loop Detection', () => {
|
||||
|
||||
@@ -136,7 +136,8 @@ export class LoopDetectionService {
|
||||
private userPrompt = '';
|
||||
|
||||
// Tool call tracking
|
||||
private toolCallHistory: string[] = [];
|
||||
private lastToolCallKey: string | null = null;
|
||||
private toolCallRepetitionCount: number = 0;
|
||||
|
||||
// Content streaming tracking
|
||||
private streamContentHistory = '';
|
||||
@@ -312,39 +313,15 @@ export class LoopDetectionService {
|
||||
|
||||
private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
|
||||
const key = this.getToolCallKey(toolCall);
|
||||
this.toolCallHistory.push(key);
|
||||
|
||||
const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD;
|
||||
if (this.toolCallHistory.length > maxRequiredLength) {
|
||||
this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength);
|
||||
if (this.lastToolCallKey === key) {
|
||||
this.toolCallRepetitionCount++;
|
||||
} else {
|
||||
this.lastToolCallKey = key;
|
||||
this.toolCallRepetitionCount = 1;
|
||||
}
|
||||
|
||||
const n = this.toolCallHistory.length;
|
||||
const R = TOOL_CALL_LOOP_THRESHOLD; // 5
|
||||
|
||||
// Check for repeating patterns of cycle length k from 1 to 5
|
||||
for (let k = 1; k <= 5; k++) {
|
||||
const requiredLength = k * R;
|
||||
if (n >= requiredLength) {
|
||||
const cycle = this.toolCallHistory.slice(-k);
|
||||
let isPatternMatch = true;
|
||||
|
||||
for (let i = 0; i < requiredLength; i++) {
|
||||
const indexFromEnd = requiredLength - i;
|
||||
const actualKey = this.toolCallHistory[n - indexFromEnd];
|
||||
const expectedKey = cycle[i % k];
|
||||
if (actualKey !== expectedKey) {
|
||||
isPatternMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPatternMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -762,7 +739,8 @@ export class LoopDetectionService {
|
||||
}
|
||||
|
||||
private resetToolCallCount(): void {
|
||||
this.toolCallHistory = [];
|
||||
this.lastToolCallKey = null;
|
||||
this.toolCallRepetitionCount = 0;
|
||||
}
|
||||
|
||||
private resetContentTracking(resetHistory = true): void {
|
||||
|
||||
@@ -37,9 +37,6 @@ export interface ModelConfigKey {
|
||||
// Indicates whether this request originates from the primary interactive chat model.
|
||||
// Enables the default fallback configuration to `chat-base` when unknown.
|
||||
isChatModel?: boolean;
|
||||
|
||||
// The last stream error that triggered this retry attempt, if any.
|
||||
lastStreamError?: unknown;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
|
||||
@@ -139,7 +139,6 @@ export interface ShellExecutionConfig {
|
||||
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
|
||||
originalCommand?: string;
|
||||
sessionId?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,10 +461,9 @@ export class ShellExecutionService {
|
||||
const spawnArgs = [...argsPrefix, finalCommand];
|
||||
|
||||
// 2. Prepare Environment
|
||||
const sourceEnv = shellExecutionConfig.env ?? process.env;
|
||||
const gitConfigKeys: string[] = [];
|
||||
if (!isInteractive) {
|
||||
for (const key in sourceEnv) {
|
||||
for (const key in process.env) {
|
||||
if (key.startsWith('GIT_CONFIG_')) {
|
||||
gitConfigKeys.push(key);
|
||||
}
|
||||
@@ -481,7 +479,7 @@ export class ShellExecutionService {
|
||||
],
|
||||
};
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig);
|
||||
const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig);
|
||||
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
...sanitizedEnv,
|
||||
@@ -495,7 +493,7 @@ export class ShellExecutionService {
|
||||
if (!isInteractive) {
|
||||
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
|
||||
for (const key of gitConfigKeys) {
|
||||
baseEnv[key] = sourceEnv[key];
|
||||
baseEnv[key] = process.env[key];
|
||||
}
|
||||
|
||||
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
|
||||
|
||||
@@ -173,7 +173,6 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 1,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 500,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 5,
|
||||
@@ -230,7 +229,6 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 2,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 1100,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 10,
|
||||
@@ -307,9 +305,6 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 1,
|
||||
totalErrors: 1,
|
||||
totalLatencyMs: 300,
|
||||
errorsByType: {
|
||||
UNKNOWN: 1,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
@@ -324,42 +319,6 @@ describe('UiTelemetryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should track errors by error_type distinctly', () => {
|
||||
const event1 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 200,
|
||||
error: 'Empty response',
|
||||
error_type: 'NO_RESPONSE_TEXT',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
const event2 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 250,
|
||||
error: 'Malformed JSON',
|
||||
error_type: 'MALFORMED_FUNCTION_CALL',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
const event3 = {
|
||||
'event.name': EVENT_API_ERROR,
|
||||
model: 'gemini-2.5-pro',
|
||||
duration_ms: 100,
|
||||
error: 'Another empty response',
|
||||
error_type: 'NO_RESPONSE_TEXT',
|
||||
} as unknown as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
|
||||
|
||||
service.addEvent(event1);
|
||||
service.addEvent(event2);
|
||||
service.addEvent(event3);
|
||||
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics.models['gemini-2.5-pro'].api.errorsByType).toEqual({
|
||||
NO_RESPONSE_TEXT: 2,
|
||||
MALFORMED_FUNCTION_CALL: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should aggregate ApiErrorEvents and ApiResponseEvents', () => {
|
||||
const responseEvent = {
|
||||
'event.name': EVENT_API_RESPONSE,
|
||||
@@ -392,9 +351,6 @@ describe('UiTelemetryService', () => {
|
||||
totalRequests: 2,
|
||||
totalErrors: 1,
|
||||
totalLatencyMs: 800,
|
||||
errorsByType: {
|
||||
UNKNOWN: 1,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
input: 5,
|
||||
|
||||
@@ -56,7 +56,6 @@ export interface ModelMetrics {
|
||||
totalRequests: number;
|
||||
totalErrors: number;
|
||||
totalLatencyMs: number;
|
||||
errorsByType?: Record<string, number>;
|
||||
};
|
||||
tokens: {
|
||||
input: number;
|
||||
@@ -111,7 +110,6 @@ const createInitialModelMetrics = (): ModelMetrics => ({
|
||||
totalRequests: 0,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 0,
|
||||
errorsByType: {},
|
||||
},
|
||||
tokens: {
|
||||
input: 0,
|
||||
@@ -172,23 +170,6 @@ export class UiTelemetryService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
recordSemanticValidationError(model: string, errorType: string): void {
|
||||
const modelMetrics = this.getOrCreateModelMetrics(model);
|
||||
modelMetrics.api.totalErrors++;
|
||||
|
||||
if (!modelMetrics.api.errorsByType) {
|
||||
modelMetrics.api.errorsByType = {};
|
||||
}
|
||||
const type = errorType || 'INVALID_STREAM';
|
||||
modelMetrics.api.errorsByType[type] =
|
||||
(modelMetrics.api.errorsByType[type] || 0) + 1;
|
||||
|
||||
this.emit('update', {
|
||||
metrics: this.#metrics,
|
||||
lastPromptTokenCount: this.#lastPromptTokenCount,
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics(): SessionMetrics {
|
||||
return this.#metrics;
|
||||
}
|
||||
@@ -345,13 +326,6 @@ export class UiTelemetryService extends EventEmitter {
|
||||
modelMetrics.api.totalErrors++;
|
||||
modelMetrics.api.totalLatencyMs += event.duration_ms;
|
||||
|
||||
if (!modelMetrics.api.errorsByType) {
|
||||
modelMetrics.api.errorsByType = {};
|
||||
}
|
||||
const errorType = event.error_type || 'UNKNOWN';
|
||||
modelMetrics.api.errorsByType[errorType] =
|
||||
(modelMetrics.api.errorsByType[errorType] || 0) + 1;
|
||||
|
||||
if (event.role) {
|
||||
if (!modelMetrics.roles[event.role]) {
|
||||
modelMetrics.roles[event.role] = createInitialRoleMetrics();
|
||||
|
||||
@@ -1331,44 +1331,6 @@ function doIt() {
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call FixLLMEditWithInstruction for .json files even when disableLLMCorrection is false', async () => {
|
||||
const filePath = path.join(rootDir, 'test.json');
|
||||
fs.writeFileSync(filePath, '{"key": "value"}', 'utf8');
|
||||
|
||||
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: filePath,
|
||||
instruction: 'Replace value',
|
||||
old_string: 'nonexistent',
|
||||
new_string: 'replacement',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call FixLLMEditWithInstruction for .ipynb files even when disableLLMCorrection is false', async () => {
|
||||
const filePath = path.join(rootDir, 'notebook.ipynb');
|
||||
fs.writeFileSync(filePath, '{"cells": []}', 'utf8');
|
||||
|
||||
(mockConfig.getDisableLLMCorrection as Mock).mockReturnValue(false);
|
||||
|
||||
const params: EditToolParams = {
|
||||
file_path: filePath,
|
||||
instruction: 'Replace cell',
|
||||
old_string: 'nonexistent',
|
||||
new_string: 'replacement',
|
||||
};
|
||||
|
||||
const invocation = tool.build(params);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockFixLLMEditWithInstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JIT context discovery', () => {
|
||||
|
||||
@@ -767,12 +767,7 @@ class EditToolInvocation
|
||||
};
|
||||
}
|
||||
|
||||
const fileExt = path.extname(this.resolvedPath).toLowerCase();
|
||||
const isJsonOrIpynb = ['.json', '.ipynb', '.jsonc', '.json5'].includes(
|
||||
fileExt,
|
||||
);
|
||||
|
||||
if (this.config.getDisableLLMCorrection() || isJsonOrIpynb) {
|
||||
if (this.config.getDisableLLMCorrection()) {
|
||||
return {
|
||||
currentContent,
|
||||
newContent: currentContent,
|
||||
|
||||
@@ -656,7 +656,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
this.context.config.isInteractiveShellEnabled(),
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
env: this.context.config.env,
|
||||
sessionId: this.context.config?.getSessionId?.() ?? 'default',
|
||||
pager: 'cat',
|
||||
sanitizationConfig:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { Config } from '../config/config.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../policy/policy-engine.js';
|
||||
@@ -30,7 +30,6 @@ describe('Tracker Tools Integration', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
config = new Config({
|
||||
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
|
||||
targetDir: tempDir,
|
||||
@@ -43,7 +42,6 @@ describe('Tracker Tools Integration', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const mockConfigInternal = {
|
||||
getIdeMode: vi.fn(() => false),
|
||||
getWorkspaceContext: () => new WorkspaceContext(rootDir, [plansDir]),
|
||||
getApiKey: () => 'test-key',
|
||||
getModel: () => 'gemini-1.5-flash',
|
||||
getModel: () => 'test-model',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getQuestion: () => undefined,
|
||||
@@ -107,7 +107,7 @@ const mockConfigInternal = {
|
||||
isInteractive: () => false,
|
||||
getDisableLLMCorrection: vi.fn(() => true),
|
||||
isPlanMode: vi.fn(() => false),
|
||||
getActiveModel: () => 'gemini-1.5-flash',
|
||||
getActiveModel: () => 'test-model',
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
@@ -428,39 +428,6 @@ describe('WriteFileTool', () => {
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not call ensureCorrectFileContent for .json files', async () => {
|
||||
const filePath = path.join(rootDir, 'config.json');
|
||||
const proposedContent = '{"key": "value\\nwith\\nescapes"}';
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
const result = await getCorrectedFileContent(
|
||||
mockConfig,
|
||||
filePath,
|
||||
proposedContent,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
|
||||
expect(result.correctedContent).toBe(proposedContent);
|
||||
});
|
||||
|
||||
it('should not call ensureCorrectFileContent for .ipynb files', async () => {
|
||||
const filePath = path.join(rootDir, 'notebook.ipynb');
|
||||
const proposedContent =
|
||||
'{"cells": [{"source": ["print(\\"hello\\\\n\\")"]}]}';
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
const result = await getCorrectedFileContent(
|
||||
mockConfig,
|
||||
filePath,
|
||||
proposedContent,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
|
||||
expect(result.correctedContent).toBe(proposedContent);
|
||||
});
|
||||
|
||||
it('should return error if reading an existing file fails (e.g. permissions)', async () => {
|
||||
const filePath = path.join(rootDir, 'unreadable_file.txt');
|
||||
const proposedContent = 'some content';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user