diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index a9e33f36eb..a7df2039d5 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -221,7 +221,9 @@ runs: --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false + if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false + fi - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 8d714b34b0..fe87fb1d5d 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -334,8 +334,20 @@ jobs: if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_MODEL: 'gemini-3-pro-preview' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts + VITEST_RETRY: 0 run: 'npm run test:always_passing_evals' + - name: 'Upload Reliability Logs' + if: "always() && steps.check_evals.outputs.should_run == 'true'" + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}' + path: 'evals/logs/api-reliability.jsonl' + retention-days: 7 + e2e: name: 'E2E' if: | diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index ee17a95121..9acc1de050 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -61,6 +61,8 @@ jobs: GEMINI_MODEL: '${{ matrix.model }}' RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts VITEST_RETRY: 0 run: | CMD="npm run test:all_evals" diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index d79bd910d1..84a0daa3b2 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,30 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.35.0 - 2026-03-24 + +- **Customizable Keyboard Shortcuts:** Users can now customize their keyboard + shortcuts, including support for literal character keybindings and the + extended Kitty protocol + ([#21945](https://github.com/google-gemini/gemini-cli/pull/21945), + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) by + @scidomino). +- **Vim Mode Improvements:** Added missing motions (X, ~, r, f/F/t/T) and + yank/paste support with the unnamed register + ([#21932](https://github.com/google-gemini/gemini-cli/pull/21932), + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) by @aanari). +- **Tool Isolation and Sandboxing:** Introduced `SandboxManager` to isolate + process-spawning tools and added Linux bubblewrap/seccomp sandboxing support + ([#21774](https://github.com/google-gemini/gemini-cli/pull/21774), + [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) by @galz10, + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) by + @DavidAPierce). +- **JIT Context Discovery:** Implemented Just-In-Time context discovery for file + system tools to improve model performance and accuracy + ([#22082](https://github.com/google-gemini/gemini-cli/pull/22082), + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) by + @SandyTao520). + ## Announcements: v0.34.0 - 2026-03-17 - **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index e49ef1c652..21b128ec30 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.34.0 +# Latest stable release: v0.35.1 -Released: March 17, 2026 +Released: March 26, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,474 +11,373 @@ npm install -g @google/gemini-cli ## Highlights -- **Plan Mode Enabled by Default**: The comprehensive planning capability is now - enabled by default, allowing for better structured task management and - execution. -- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) - sandboxing as well as experimental LXC container sandboxing to provide more - robust and isolated execution environments. -- **Improved Loop Detection & Recovery**: Implemented iterative loop detection - and model feedback mechanisms to prevent the CLI from getting stuck in - repetitive actions. -- **Customizable UI Elements**: You can now configure a custom footer using the - new `/footer` command, and enjoy standardized semantic focus colors for better - history visibility. -- **Extensive Subagent Updates**: Refinements across the tracker visualization - tools, background process logging, and broader fallback support for models in - tool execution scenarios. +- **Customizable Keyboard Shortcuts:** Significant improvements to input + flexibility with support for custom keybindings, literal character bindings, + and extended terminal protocol keys. +- **Vim Mode Enhancements:** Further refinement of the Vim modal editing + experience, adding common motions like \`X\`, \`~\`, \`r\`, and \`f/F/t/T\`, + along with yank and paste support. +- **Enhanced Security through Sandboxing:** Introduction of a unified + \`SandboxManager\` and integration of Linux-native sandboxing (bubblewrap and + seccomp) to isolate tool execution and improve system security. +- **JIT Context Discovery:** Improved performance and accuracy by enabling + Just-In-Time context loading for file system tools, ensuring the model has the + most relevant information without overwhelming the context. +- **Subagent & Performance Updates:** Subagents are now enabled by default, + supported by a model-driven parallel tool scheduler and code splitting for + faster startup and more efficient task execution. ## What's Changed -- feat(cli): add chat resume footer on session quit by @lordshashank in - [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) -- Support bold and other styles in svg snapshots by @jacob314 in - [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) -- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in - [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) -- Cleanup old branches. by @jacob314 in - [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) -- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by +- feat(cli): customizable keyboard shortcuts by @scidomino in + [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) +- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in + [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) +- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by @gemini-cli-robot in - [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) -- feat(ui): standardize semantic focus colors and enhance history visibility by - @keithguerin in - [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) -- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in - [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) -- Add extra safety checks for proto pollution by @jacob314 in - [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) -- feat(core): Add tracker CRUD tools & visualization by @anj-s in - [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) -- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" - by @jacob314 in - [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) -- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in - [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) -- fix: model persistence for all scenarios by @sripasg in - [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) -- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by - @gemini-cli-robot in - [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) -- Consistently guard restarts against concurrent auto updates by @scidomino in - [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) -- Defensive coding to reduce the risk of Maximum update depth errors by - @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) -- fix(cli): Polish shell autocomplete rendering to be a little more shell native - feeling. by @jacob314 in - [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) -- Docs: Update plan mode docs by @jkcinouye in - [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) -- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in - [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) -- fix(cli): register extension lifecycle events in DebugProfiler by - @fayerman-source in - [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) -- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in - [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) -- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in - [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) -- Changelog for v0.32.0 by @gemini-cli-robot in - [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) -- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in - [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) -- feat(core): improve @scripts/copy_files.js autocomplete to prioritize - filenames by @sehoon38 in - [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) -- feat(sandbox): add experimental LXC container sandbox support by @h30s in - [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) -- feat(evals): add overall pass rate row to eval nightly summary table by - @gundermanc in - [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) -- feat(telemetry): include language in telemetry and fix accepted lines - computation by @gundermanc in - [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) -- Changelog for v0.32.1 by @gemini-cli-robot in - [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) -- feat(core): add robustness tests, logging, and metrics for CodeAssistServer - SSE parsing by @yunaseoul in - [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) -- feat: add issue assignee workflow by @kartikangiras in - [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) -- fix: improve error message when OAuth succeeds but project ID is required by - @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) -- feat(loop-reduction): implement iterative loop detection and model feedback by - @aishaneeshah in - [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) -- chore(github): require prompt approvers for agent prompt files by @gundermanc - in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) -- Docs: Create tools reference by @jkcinouye in - [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) -- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions - by @spencer426 in - [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) -- chore(cli): enable deprecated settings removal by default by @yashodipmore in - [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) -- feat(core): Disable fast ack helper for hints. by @joshualitt in - [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) -- fix(ui): suppress redundant failure note when tool error note is shown by - @NTaylorMullen in - [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) -- docs: document planning workflows with Conductor example by @jerop in - [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) -- feat(release): ship esbuild bundle in npm package by @genneth in - [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) -- fix(extensions): preserve symlinks in extension source path while enforcing - folder trust by @galz10 in - [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) -- fix(cli): defer tool exclusions to policy engine in non-interactive mode by - @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) -- fix(ui): removed double padding on rendered content by @devr0306 in - [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) -- fix(core): truncate excessively long lines in grep search output by - @gundermanc in - [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) -- feat: add custom footer configuration via `/footer` by @jackwotherspoon in - [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) -- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in - [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) -- refactor(cli): categorize built-in themes into dark/ and light/ directories by - @JayadityaGit in - [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) -- fix(core): explicitly allow codebase_investigator and cli_help in read-only - mode by @Adib234 in - [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) -- test: add browser agent integration tests by @kunal-10-cloud in - [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) -- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in - [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) -- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by - @SandyTao520 in - [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) -- fix(ui): add partial output to cancelled shell UI by @devr0306 in - [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) -- fix(cli): replace hardcoded keybinding strings with dynamic formatters by - @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) -- DOCS: Update quota and pricing page by @g-samroberts in - [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) -- feat(telemetry): implement Clearcut logging for startup statistics by - @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) -- feat(triage): add area/documentation to issue triage by @g-samroberts in - [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) -- Fix so shell calls are formatted by @jacob314 in - [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) -- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in - [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) -- docs: use absolute paths for internal links in plan-mode.md by @jerop in - [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) -- fix(core): prevent unhandled AbortError crash during stream loop detection by - @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) -- fix:reorder env var redaction checks to scan values first by @kartikangiras in - [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) -- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences - by @skeshive in - [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) -- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 - in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) -- test(core): improve testing for API request/response parsing by @sehoon38 in - [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) -- docs(links): update docs-writer skill and fix broken link by @g-samroberts in - [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) -- Fix code colorizer ansi escape bug. by @jacob314 in - [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) -- remove wildcard behavior on keybindings by @scidomino in - [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) -- feat(acp): Add support for AI Gateway auth by @skeshive in - [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) -- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in - [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) -- feat (core): Implement tracker related SI changes by @anj-s in - [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) -- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in - [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) -- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in - [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) -- docs: format release times as HH:MM UTC by @pavan-sh in - [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) -- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in - [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) -- docs: fix incorrect relative links to command reference by @kanywst in - [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) -- documentiong ensures ripgrep by @Jatin24062005 in - [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) -- fix(core): handle AbortError thrown during processTurn by @MumuTW in - [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) -- docs(cli): clarify ! command output visibility in shell commands tutorial by - @MohammedADev in - [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) -- fix: logic for task tracker strategy and remove tracker tools by @anj-s in - [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) -- fix(partUtils): display media type and size for inline data parts by @Aboudjem - in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) -- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in - [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) -- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by - @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) -- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive - filesystems (#19904) by @Nixxx19 in - [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) -- feat(core): add concurrency safety guidance for subagent delegation (#17753) - by @abhipatel12 in - [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) -- feat(ui): dynamically generate all keybinding hints by @scidomino in - [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) -- feat(core): implement unified KeychainService and migrate token storage by - @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) -- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in - [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) -- fix(plan): keep approved plan during chat compression by @ruomengz in - [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) -- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 - in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) -- Update quota and pricing documentation with subscription tiers by @srithreepo - in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) -- fix(core): append correct OTLP paths for HTTP exporters by - @sebastien-prudhomme in - [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) -- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in - [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) -- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in - [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) -- refactor(core): standardize MCP tool naming to mcp\_ FQN format by - @abhipatel12 in - [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) -- feat(cli): hide gemma settings from display and mark as experimental by - @abhipatel12 in - [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) -- feat(skills): refine string-reviewer guidelines and description by @clocky in - [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) -- fix(core): whitelist TERM and COLORTERM in environment sanitization by - @deadsmash07 in - [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) -- fix(billing): fix overage strategy lifecycle and settings integration by - @gsquared94 in - [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) -- fix: expand paste placeholders in TextInput on submit by @Jefftree in - [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) -- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by - @SandyTao520 in - [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) -- feat(cli): overhaul thinking UI by @keithguerin in - [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) -- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by - @jwhelangoog in - [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) -- fix(cli): correct shell height reporting by @jacob314 in - [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) -- Make test suite pass when the GEMINI_SYSTEM_MD env variable or - GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in - [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) -- Disallow underspecified types by @gundermanc in - [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) -- refactor(cli): standardize on 'reload' verb for all components by @keithguerin - in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) -- feat(cli): Invert quota language to 'percent used' by @keithguerin in - [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) -- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye - in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) -- Code review comments as a pr by @jacob314 in - [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) -- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in - [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) -- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) +- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in + [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) +- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) + by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) +- Feat/retry fetch notifications by @aishaneeshah in + [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) +- fix(core): remove OAuth check from handle fallback and clean up stray file by + @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) +- feat(cli): support literal character keybindings and extended Kitty protocol + keys by @scidomino in + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) +- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in + [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) +- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in + [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) +- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by + @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) +- docs(cli): add custom keybinding documentation by @scidomino in + [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) +- docs: fix misleading YOLO mode description in defaultApprovalMode by @Gyanranjan-Priyam in - [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) -- fix(core): display actual graph output in tracker_visualize tool by @anj-s in - [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) -- fix(core): sanitize SSE-corrupted JSON and domain strings in error - classification by @gsquared94 in - [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) -- Docs: Make documentation links relative by @diodesign in - [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) -- feat(cli): expose /tools desc as explicit subcommand for discoverability by - @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) -- feat(cli): add /compact alias for /compress command by @jackwotherspoon in - [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) -- feat(plan): enable Plan Mode by default by @jerop in - [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) -- feat(core): Introduce `AgentLoopContext`. by @joshualitt in - [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) -- fix(core): resolve symlinks for non-existent paths during validation by - @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) -- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in - [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) -- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 - in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) -- feat(cli): implement /upgrade command by @sehoon38 in - [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) -- Feat/browser agent progress emission by @kunal-10-cloud in - [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) -- fix(settings): display objects as JSON instead of [object Object] by - @Zheyuan-Lin in - [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) -- Unmarshall update by @DavidAPierce in - [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) -- Update mcp's list function to check for disablement. by @DavidAPierce in - [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) -- robustness(core): static checks to validate history is immutable by @jacob314 - in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) -- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in - [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) -- feat(security): implement robust IP validation and safeFetch foundation by - @alisa-alisa in - [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) -- feat(core): improve subagent result display by @joshualitt in - [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) -- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in - [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) -- feat(policy): support subagent-specific policies in TOML by @akh64bit in - [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) -- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in - [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) -- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in - [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) -- fix(docs): fix headless mode docs by @ame2en in - [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) -- feat/redesign header compact by @jacob314 in - [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) -- refactor: migrate to useKeyMatchers hook by @scidomino in - [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) -- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by - @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) -- fix(core): resolve Windows line ending and path separation bugs across CLI by - @muhammadusman586 in - [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) -- docs: fix heading formatting in commands.md and phrasing in tools-api.md by - @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) -- refactor(ui): unify keybinding infrastructure and support string - initialization by @scidomino in - [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) -- Add support for updating extension sources and names by @chrstnb in - [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) -- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed - in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) -- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy - in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) -- fix(docs): update theme screenshots and add missing themes by @ashmod in - [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) -- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in - [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) -- build(release): restrict npm bundling to non-stable tags by @sehoon38 in - [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) -- fix(core): override toolRegistry property for sub-agent schedulers by - @gsquared94 in - [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) -- fix(cli): make footer items equally spaced by @jacob314 in - [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) -- docs: clarify global policy rules application in plan mode by @jerop in - [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) -- fix(core): ensure correct flash model steering in plan mode implementation - phase by @jerop in - [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) -- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in - [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) -- refactor(core): improve API response error logging when retry by @yunaseoul in - [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) -- fix(ui): handle headless execution in credits and upgrade dialogs by - @gsquared94 in - [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) -- fix(core): treat retryable errors with >5 min delay as terminal quota errors - by @gsquared94 in - [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) -- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub - Actions by @cocosheng-g in - [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) -- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by - @SandyTao520 in - [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) -- feat(cli): give visibility to /tools list command in the TUI and follow the - subcommand pattern of other commands by @JayadityaGit in - [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) -- Handle dirty worktrees better and warn about running scripts/review.sh on - untrusted code. by @jacob314 in - [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) -- feat(policy): support auto-add to policy by default and scoped persistence by + [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) +- fix: clean up /clear and /resume by @jackwotherspoon in + [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) +- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax + in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) +- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul + in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) +- feat(cli): support removing keybindings via '-' prefix by @scidomino in + [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) +- feat(policy): add --admin-policy flag for supplemental admin policies by + @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) +- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in + [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) +- perf(core): parallelize user quota and experiments fetching in refreshAuth by + @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) +- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in + [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) +- Changelog for v0.33.0 by @gemini-cli-robot in + [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) +- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in + [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) +- feat(core): include initiationMethod in conversation interaction telemetry by + @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) +- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) +- fix(core): enable numerical routing for api key users by @sehoon38 in + [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) +- feat(telemetry): implement retry attempt telemetry for network related retries + by @aishaneeshah in + [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) +- fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in - [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) -- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 - in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) -- fix(release): Improve Patch Release Workflow Comments: Clearer Approval - Guidance by @jerop in - [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) -- docs: clarify telemetry setup and comprehensive data map by @jerop in - [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) -- feat(core): add per-model token usage to stream-json output by @yongruilin in - [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) -- docs: remove experimental badge from plan mode in sidebar by @jerop in - [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) -- fix(cli): prevent race condition in loop detection retry by @skyvanguard in - [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) -- Add behavioral evals for tracker by @anj-s in - [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) -- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in - [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) -- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in - [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) -- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in - [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) -- make command names consistent by @scidomino in - [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) -- refactor: remove agent_card_requires_auth config flag by @adamfweidman in - [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) -- feat(a2a): implement standardized normalization and streaming reassembly by - @alisa-alisa in - [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) -- feat(cli): enable skill activation via slash commands by @NTaylorMullen in - [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) -- docs(cli): mention per-model token usage in stream-json result event by - @yongruilin in - [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) -- fix(plan): prevent plan truncation in approval dialog by supporting - unconstrained heights by @Adib234 in - [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) -- feat(a2a): switch from callback-based to event-driven tool scheduler by - @cocosheng-g in - [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) -- feat(voice): implement speech-friendly response formatter by @ayush31010 in - [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) -- feat: add pulsating blue border automation overlay to browser agent by - @kunal-10-cloud in - [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) -- Add extensionRegistryURI setting to change where the registry is read from by - @kevinjwang1 in - [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) -- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in - [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) -- fix: prevent hangs in non-interactive mode and improve agent guidance by - @cocosheng-g in - [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) -- Add ExtensionDetails dialog and support install by @chrstnb in - [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) -- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by - @gemini-cli-robot in - [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) -- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in - [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) -- fix(cli): stabilize prompt layout to prevent jumping when typing by + [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) +- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 + in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) +- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in + [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) +- feat(core): support custom base URL via env vars by @junaiddshaukat in + [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) +- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in + [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) +- fix(core): silently retry API errors up to 3 times before halting session by + @spencer426 in + [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) +- feat(core): simplify subagent success UI and improve early termination display + by @abhipatel12 in + [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) +- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in + [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) +- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 + in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) +- feat(core): implement SandboxManager interface and config schema by @galz10 in + [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) +- docs: document npm deprecation warnings as safe to ignore by @h30s in + [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) +- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in + [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) +- fix(core): propagate subagent context to policy engine by @NTaylorMullen in + [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) +- fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in - [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) -- fix: preserve prompt text when cancelling streaming by @Nixxx19 in - [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) -- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in - [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) -- feat: implement background process logging and cleanup by @galz10 in - [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) -- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in - [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) -- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 + [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) +- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in + [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) +- fix(policy): ensure user policies are loaded when policyPaths is empty by + @NTaylorMullen in + [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) +- Docs: Add documentation for model steering (experimental). by @jkcinouye in + [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) +- Add issue for automated changelogs by @g-samroberts in + [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) +- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + @spencer426 in + [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) +- feat(core): differentiate User-Agent for a2a-server and ACP clients by + @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) +- refactor(core): extract ExecutionLifecycleService for tool backgrounding by + @adamfweidman in + [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) +- feat: Display pending and confirming tool calls by @sripasg in + [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) +- feat(browser): implement input blocker overlay during automation by + @kunal-10-cloud in + [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) +- fix: register themes on extension load not start by @jackwotherspoon in + [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) +- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in + [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) +- chore: remove unnecessary log for themes by @jackwotherspoon in + [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) +- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in + subagents by @abhipatel12 in + [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) +- fix(cli): validate --model argument at startup by @JaisalJain in + [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) +- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in + [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) +- feat(telemetry): add Clearcut instrumentation for AI credits billing events by + @gsquared94 in + [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) +- feat(core): add google credentials provider for remote agents by @adamfweidman + in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) +- test(cli): add integration test for node deprecation warnings by @Nixxx19 in + [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) +- feat(cli): allow safe tools to execute concurrently while agent is busy by + @spencer426 in + [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) +- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in + [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) +- update vulnerable deps by @scidomino in + [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) +- fix(core): fix startup stats to use int values for timestamps and durations by + @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) +- fix(core): prevent duplicate tool schemas for instantiated tools by + @abhipatel12 in + [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) +- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman + in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) +- fix(core/ide): add Antigravity CLI fallbacks by @apfine in + [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) +- fix(browser): fix duplicate function declaration error in browser agent by + @gsquared94 in + [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) +- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah + in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) +- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in + [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) +- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in + [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) +- fix: remove unused img.png from project root by @SandyTao520 in + [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) +- docs(local model routing): add docs on how to use Gemma for local model + routing by @douglas-reid in + [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) +- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in + [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) +- fix(cli): escape @ symbols on paste to prevent unintended file expansion by + @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) +- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in + [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) +- docs: clarify that tools.core is an allowlist for ALL built-in tools by + @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) +- docs(plan): document hooks with plan mode by @ruomengz in + [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) +- Changelog for v0.33.1 by @gemini-cli-robot in + [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) +- build(ci): fix false positive evals trigger on merge commits by @gundermanc in + [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) +- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by + @abhipatel12 in + [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) +- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in + [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) +- feat(core): increase sub-agent turn and time limits by @bdmorgan in + [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) +- feat(core): instrument file system tools for JIT context discovery by + @SandyTao520 in + [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) +- refactor(ui): extract pure session browser utilities by @abhipatel12 in + [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) +- fix(plan): Fix AskUser evals by @Adib234 in + [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) +- fix(settings): prevent j/k navigation keys from intercepting edit buffer input + by @student-ankitpandit in + [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) +- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in + [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) +- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in + [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) +- fix(core): show descriptive error messages when saving settings fails by + @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) +- docs(core): add authentication guide for remote subagents by @adamfweidman in + [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) +- docs: overhaul subagents documentation and add /agents command by @abhipatel12 + in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) +- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in + [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) +- test: add Object.create context regression test and tool confirmation + integration test by @gsquared94 in + [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) +- feat(tracker): return TodoList display for tracker tools by @anj-s in + [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) +- feat(agent): add allowed domain restrictions for browser agent by + @cynthialong0-0 in + [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) +- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + @gemini-cli-robot in + [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) +- Move keychain fallback to keychain service by @chrstnb in + [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) +- feat(core): integrate SandboxManager to sandbox all process-spawning tools by + @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) +- fix(cli): support CJK input and full Unicode scalar values in terminal + protocols by @scidomino in + [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) +- Promote stable tests. by @gundermanc in + [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) +- feat(tracker): add tracker policy by @anj-s in + [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) +- feat(security): add disableAlwaysAllow setting to disable auto-approvals by + @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) +- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in + [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) +- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 + in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) +- fix(core): use session-specific temp directory for task tracker by @anj-s in + [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) +- Fix issue where config was undefined. by @gundermanc in + [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) +- fix(core): deduplicate project memory when JIT context is enabled by + @SandyTao520 in + [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) +- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by + @Abhijit-2592 in + [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) +- fix(core): fix manual deletion of subagent histories by @abhipatel12 in + [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) +- Add registry var by @kevinjwang1 in + [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) +- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in + [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) +- fix(cli): improve command conflict handling for skills by @NTaylorMullen in + [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) +- fix(core): merge user settings with extension-provided MCP servers by + @abhipatel12 in + [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) +- fix(core): skip discovery for incomplete MCP configs and resolve merge race + condition by @abhipatel12 in + [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) +- fix(automation): harden stale PR closer permissions and maintainer detection + by @bdmorgan in + [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) +- fix(automation): evaluate staleness before checking protected labels by + @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) +- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with + pre-built bundle by @cynthialong0-0 in + [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) +- perf: optimize TrackerService dependency checks by @anj-s in + [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) +- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 + in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) +- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in + [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) +- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled + tool calls. by @sripasg in + [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) +- Disallow Object.create() and reflect. by @gundermanc in + [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) +- Guard pro model usage by @sehoon38 in + [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) +- refactor(core): Creates AgentSession abstraction for consolidated agent + interface. by @mbleigh in + [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) +- docs(changelog): remove internal commands from release notes by + @jackwotherspoon in + [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) +- feat: enable subagents by @abhipatel12 in + [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) +- feat(extensions): implement cryptographic integrity verification for extension + updates by @ehedlund in + [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) +- feat(tracker): polish UI sorting and formatting by @anj-s in + [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) +- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in + [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) +- fix(core): fix three JIT context bugs in read_file, read_many_files, and + memoryDiscovery by @SandyTao520 in + [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) +- refactor(core): introduce InjectionService with source-aware injection and + backend-native background completions by @adamfweidman in + [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) +- Linux sandbox bubblewrap by @DavidAPierce in + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) +- feat(core): increase thought signature retry resilience by @bdmorgan in + [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) +- feat(core): implement Stage 2 security and consistency improvements for + web_fetch by @aishaneeshah in + [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) +- refactor(core): replace positional execute params with ExecuteOptions bag by + @adamfweidman in + [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) +- feat(config): enable JIT context loading by default by @SandyTao520 in + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) +- fix(config): ensure discoveryMaxDirs is passed to global config during + initialization by @kevin-ramdass in + [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) +- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in + [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) +- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in + [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) +- feat(core): add foundation for subagent tool isolation by @akh64bit in + [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) +- fix(core): handle surrogate pairs in truncateString by @sehoon38 in + [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) +- fix(cli): override j/k navigation in settings dialog to fix search input + conflict by @sehoon38 in + [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) +- feat(plan): add 'All the above' option to multi-select AskUser questions by + @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) +- docs: distribute package-specific GEMINI.md context to each package by + @SandyTao520 in + [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) +- fix(cli): clean up stale pasted placeholder metadata after word/line deletions + by @Jomak-x in + [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) +- refactor(core): align JIT memory placement with tiered context model by + @SandyTao520 in + [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) +- Linux sandbox seccomp by @DavidAPierce in + [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) +- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch + version v0.35.0-preview.1 and create version 0.35.0-preview.2 by + @gemini-cli-robot in + [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) +- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch + version v0.35.0-preview.2 and create version 0.35.0-preview.3 by + @gemini-cli-robot in + [#23565](https://github.com/google-gemini/gemini-cli/pull/23565) +- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546 [CONFLICTS] by @gemini-cli-robot in - [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) -- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch - version v0.34.0-preview.1 and create version 0.34.0-preview.2 by - @gemini-cli-robot in - [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) -- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch - version v0.34.0-preview.2 and create version 0.34.0-preview.3 by - @gemini-cli-robot in - [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) -- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch - version v0.34.0-preview.3 and create version 0.34.0-preview.4 by - @gemini-cli-robot in - [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) + [#23585](https://github.com/google-gemini/gemini-cli/pull/23585) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1 diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md deleted file mode 100644 index 18ebf865b4..0000000000 --- a/docs/get-started/examples.md +++ /dev/null @@ -1,141 +0,0 @@ -# Gemini CLI examples - -Gemini CLI helps you automate common engineering tasks by combining AI reasoning -with local system tools. This document provides examples of how to use the CLI -for file management, code analysis, and data transformation. - - -> [!NOTE] -> These examples demonstrate potential capabilities. Your actual -> results can vary based on the model used and your project environment. - -## Rename your photographs based on content - -You can use Gemini CLI to automate file management tasks that require visual -analysis. In this example, Gemini CLI renames images based on their actual -subject matter. - -Scenario: You have a folder containing the following files: - -```bash -photos/photo1.png -photos/photo2.png -photos/photo3.png -``` - -Give Gemini the following prompt: - -```cli -Rename the photos in my "photos" directory based on their contents. -``` - -Result: Gemini asks for permission to rename your files. - -Select **Allow once** and your files are renamed: - -```bash -photos/yellow_flowers.png -photos/antique_dresser.png -photos/green_android_robot.png -``` - -## Explain a repository by reading its code - -Gemini CLI is effective for rapid codebase exploration. The following example -shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project. - -Scenario: You want to understand how a popular open-source utility works by -inspecting its code, not just its README. - -Give Gemini CLI the following prompt: - -```cli -Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works. -``` - -Result: Gemini performs a sequence of actions to answer your request. - -1. First, it asks for permission to run `git clone` to download the repository. -2. Next, it finds the important source files and asks for permission to read - them. -3. Finally, after analyzing the code, it provides a summary. - -Gemini CLI returns an explanation based on the actual source code: - -```markdown -The `chalk` library is a popular npm package for styling terminal output with -colors. After analyzing the source code, here's how it works: - -- **Core functionality:** The main file sets up a chainable API. Each color or - modifier (like `bold` or `italic`) is a getter that appends the corresponding - ANSI escape code to an internal stack. - -- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing - getters. The `red` getter adds the red color code, and the `bold` getter adds - the bold code. - -- **Output generation:** When the chain is treated as a string (e.g., in - `console.log`), a final `toString()` method is called. This method joins all - the stored ANSI codes, wraps them around the input string ('Hello'), and adds - a reset code at the end. This produces the final, styled string that the - terminal can render. -``` - -## Combine two spreadsheets into one spreadsheet - -Gemini CLI can process and transform data across multiple files. Use this -capability to merge reports or reformat data sets without manual copying. - -Scenario: You have two .csv files: `Revenue - 2023.csv` and -`Revenue - 2024.csv`. Each file contains monthly revenue figures. - -Give Gemini CLI the following prompt: - -```cli -Combine the two .csv files into a single .csv file, with each year a different column. -``` - -Result: Gemini CLI reads each file and then asks for permission to write a new -file. Provide your permission and Gemini CLI provides the combined data: - -```csv -Month,2023,2024 -January,0,1000 -February,0,1200 -March,0,2400 -April,900,500 -May,1000,800 -June,1000,900 -July,1200,1000 -August,1800,400 -September,2000,2000 -October,2400,3400 -November,3400,1800 -December,2100,9000 -``` - -## Run unit tests - -Gemini CLI can generate boilerplate code and tests based on your existing -implementation. This example demonstrates how to request code coverage for a -JavaScript component. - -Scenario: You've written a simple login page. You wish to write unit tests to -ensure that your login page has code coverage. - -Give Gemini CLI the following prompt: - -```cli -Write unit tests for Login.js. -``` - -Result: Gemini CLI asks for permission to write a new file and creates a test -for your login page. - -## Next steps - -- Follow the [File management](../cli/tutorials/file-management.md) guide to - start working with your codebase. -- Follow the [Quickstart](./index.md) to start your first session. -- See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of - available commands. diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 566ac6e9df..906998ab48 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -62,7 +62,133 @@ Once installed and authenticated, you can start using Gemini CLI by issuing commands and prompts in your terminal. Ask it to generate code, explain files, and more. -To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md). + +> [!NOTE] +> These examples demonstrate potential capabilities. Your actual +> results can vary based on the model used and your project environment. + +### Rename your photographs based on content + +You can use Gemini CLI to automate file management tasks that require visual +analysis. In this example, Gemini CLI renames images based on their actual +subject matter. + +Scenario: You have a folder containing the following files: + +```bash +photos/photo1.png +photos/photo2.png +photos/photo3.png +``` + +Give Gemini the following prompt: + +```cli +Rename the photos in my "photos" directory based on their contents. +``` + +Result: Gemini asks for permission to rename your files. + +Select **Allow once** and your files are renamed: + +```bash +photos/yellow_flowers.png +photos/antique_dresser.png +photos/green_android_robot.png +``` + +### Explain a repository by reading its code + +Gemini CLI is effective for rapid codebase exploration. The following example +shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project. + +Scenario: You want to understand how a popular open-source utility works by +inspecting its code, not just its README. + +Give Gemini CLI the following prompt: + +```cli +Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works. +``` + +Result: Gemini performs a sequence of actions to answer your request. + +1. First, it asks for permission to run `git clone` to download the repository. +2. Next, it finds the important source files and asks for permission to read + them. +3. Finally, after analyzing the code, it provides a summary. + +Gemini CLI returns an explanation based on the actual source code: + +```markdown +The `chalk` library is a popular npm package for styling terminal output with +colors. After analyzing the source code, here's how it works: + +- **Core functionality:** The main file sets up a chainable API. Each color or + modifier (like `bold` or `italic`) is a getter that appends the corresponding + ANSI escape code to an internal stack. + +- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing + getters. The `red` getter adds the red color code, and the `bold` getter adds + the bold code. + +- **Output generation:** When the chain is treated as a string (e.g., in + `console.log`), a final `toString()` method is called. This method joins all + the stored ANSI codes, wraps them around the input string ('Hello'), and adds + a reset code at the end. This produces the final, styled string that the + terminal can render. +``` + +### Combine two spreadsheets into one spreadsheet + +Gemini CLI can process and transform data across multiple files. Use this +capability to merge reports or reformat data sets without manual copying. + +Scenario: You have two .csv files: `Revenue - 2023.csv` and +`Revenue - 2024.csv`. Each file contains monthly revenue figures. + +Give Gemini CLI the following prompt: + +```cli +Combine the two .csv files into a single .csv file, with each year a different column. +``` + +Result: Gemini CLI reads each file and then asks for permission to write a new +file. Provide your permission and Gemini CLI provides the combined data: + +```csv +Month,2023,2024 +January,0,1000 +February,0,1200 +March,0,2400 +April,900,500 +May,1000,800 +June,1000,900 +July,1200,1000 +August,1800,400 +September,2000,2000 +October,2400,3400 +November,3400,1800 +December,2100,9000 +``` + +### Run unit tests + +Gemini CLI can generate boilerplate code and tests based on your existing +implementation. This example demonstrates how to request code coverage for a +JavaScript component. + +Scenario: You've written a simple login page. You wish to write unit tests to +ensure that your login page has code coverage. + +Give Gemini CLI the following prompt: + +```cli +Write unit tests for Login.js. +``` + +Result: Gemini CLI asks for permission to write a new file and creates a test +for your login page. ## Check usage and quota diff --git a/docs/index.md b/docs/index.md index af1915bb8f..d1c1febf55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,8 +19,6 @@ Jump in to Gemini CLI. on your system. - **[Authentication](./get-started/authentication.md):** Setup instructions for personal and enterprise accounts. -- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in - action. - **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common commands and options. - **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3 diff --git a/docs/redirects.json b/docs/redirects.json index 598f42cccf..db2dae4333 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -13,6 +13,7 @@ "/docs/faq": "/docs/resources/faq", "/docs/get-started/configuration": "/docs/reference/configuration", "/docs/get-started/configuration-v1": "/docs/reference/configuration", + "/docs/get-started/examples": "/docs/get-started/index", "/docs/index": "/docs", "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", "/docs/tos-privacy": "/docs/resources/tos-privacy", diff --git a/docs/sidebar.json b/docs/sidebar.json index 7198a0336b..e1ebd6ddd5 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -12,7 +12,6 @@ "label": "Authentication", "slug": "docs/get-started/authentication" }, - { "label": "Examples", "slug": "docs/get-started/examples" }, { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, { "label": "Gemini 3 on Gemini CLI", diff --git a/evals/test-helper.test.ts b/evals/test-helper.test.ts new file mode 100644 index 0000000000..c0147cda75 --- /dev/null +++ b/evals/test-helper.test.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { internalEvalTest } from './test-helper.js'; +import { TestRig } from '@google/gemini-cli-test-utils'; + +// Mock TestRig to control API success/failure +vi.mock('@google/gemini-cli-test-utils', () => { + return { + TestRig: vi.fn().mockImplementation(() => ({ + setup: vi.fn(), + run: vi.fn(), + cleanup: vi.fn(), + readToolLogs: vi.fn().mockReturnValue([]), + _lastRunStderr: '', + })), + }; +}); + +describe('evalTest reliability logic', () => { + const LOG_DIR = path.resolve(process.cwd(), 'evals/logs'); + const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl'); + + beforeEach(() => { + vi.clearAllMocks(); + if (fs.existsSync(RELIABILITY_LOG)) { + fs.unlinkSync(RELIABILITY_LOG); + } + }); + + afterEach(() => { + if (fs.existsSync(RELIABILITY_LOG)) { + fs.unlinkSync(RELIABILITY_LOG); + } + }); + + it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate permanent 500 error + mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down')); + + // Execute the test function directly + await internalEvalTest({ + name: 'test-api-failure', + prompt: 'do something', + assert: async () => {}, + }); + + // Verify retries: 1 initial + 3 retries = 4 setups/runs + expect(mockRig.run).toHaveBeenCalledTimes(4); + + // Verify log content + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + expect(logContent.length).toBe(4); + + const entries = logContent.map((line) => JSON.parse(line)); + expect(entries[0].status).toBe('RETRY'); + expect(entries[0].attempt).toBe(0); + expect(entries[3].status).toBe('SKIP'); + expect(entries[3].attempt).toBe(3); + expect(entries[3].testName).toBe('test-api-failure'); + }); + + it('should fail immediately on non-500 errors (like assertion failures)', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate a real logic error/bug + mockRig.run.mockResolvedValue('Success'); + const assertError = new Error('Assertion failed: expected foo to be bar'); + + // Expect the test function to throw immediately + await expect( + internalEvalTest({ + name: 'test-logic-failure', + prompt: 'do something', + assert: async () => { + throw assertError; + }, + }), + ).rejects.toThrow('Assertion failed'); + + // Verify NO retries: only 1 attempt + expect(mockRig.run).toHaveBeenCalledTimes(1); + + // Verify NO reliability log was created (it's not an API error) + expect(fs.existsSync(RELIABILITY_LOG)).toBe(false); + }); + + it('should recover if a retry succeeds', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Fail once, then succeed + mockRig.run + .mockRejectedValueOnce(new Error('status: INTERNAL')) + .mockResolvedValueOnce('Success'); + + await internalEvalTest({ + name: 'test-recovery', + prompt: 'do something', + assert: async () => {}, + }); + + // Ran twice: initial (fail) + retry 1 (success) + expect(mockRig.run).toHaveBeenCalledTimes(2); + + // Log should only have the one RETRY entry + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + expect(logContent.length).toBe(1); + expect(JSON.parse(logContent[0]).status).toBe('RETRY'); + }); + + it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate permanent 503 error + mockRig.run.mockRejectedValue( + new Error('status: UNAVAILABLE - Service Busy'), + ); + + await internalEvalTest({ + name: 'test-api-503', + prompt: 'do something', + assert: async () => {}, + }); + + expect(mockRig.run).toHaveBeenCalledTimes(4); + + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + const entries = logContent.map((line) => JSON.parse(line)); + expect(entries[0].errorCode).toBe('503'); + expect(entries[3].status).toBe('SKIP'); + }); + + it('should throw if an absolute path is used in files', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp'); + if (!fs.existsSync(mockRig.testDir)) { + fs.mkdirSync(mockRig.testDir, { recursive: true }); + } + + try { + await expect( + internalEvalTest({ + name: 'test-absolute-path', + prompt: 'do something', + files: { + '/etc/passwd': 'hacked', + }, + assert: async () => {}, + }), + ).rejects.toThrow('Invalid file path in test case: /etc/passwd'); + } finally { + if (fs.existsSync(mockRig.testDir)) { + fs.rmSync(mockRig.testDir, { recursive: true, force: true }); + } + } + }); + + it('should throw if directory traversal is detected in files', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp'); + + // Create a mock test-dir + if (!fs.existsSync(mockRig.testDir)) { + fs.mkdirSync(mockRig.testDir, { recursive: true }); + } + + try { + await expect( + internalEvalTest({ + name: 'test-traversal', + prompt: 'do something', + files: { + '../sensitive.txt': 'hacked', + }, + assert: async () => {}, + }), + ).rejects.toThrow('Invalid file path in test case: ../sensitive.txt'); + } finally { + if (fs.existsSync(mockRig.testDir)) { + fs.rmSync(mockRig.testDir, { recursive: true, force: true }); + } + } + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 7683fc510e..f79a78779a 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -39,87 +39,34 @@ export * from '@google/gemini-cli-test-utils'; export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES'; export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { - const fn = async () => { + runEval( + policy, + evalCase.name, + () => internalEvalTest(evalCase), + evalCase.timeout, + ); +} + +export async function internalEvalTest(evalCase: EvalCase) { + const maxRetries = 3; + let attempt = 0; + + while (attempt <= maxRetries) { const rig = new TestRig(); const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`); const logFile = path.join(logDir, `${sanitizedName}.log`); let isSuccess = false; + try { rig.setup(evalCase.name, evalCase.params); - // Symlink node modules to reduce the amount of time needed to - // bootstrap test projects. - symlinkNodeModules(rig.testDir || ''); - if (evalCase.files) { - const acknowledgedAgents: Record> = {}; - const projectRoot = fs.realpathSync(rig.testDir!); - - for (const [filePath, content] of Object.entries(evalCase.files)) { - const fullPath = path.join(rig.testDir!, filePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - - // If it's an agent file, calculate hash for acknowledgement - if ( - filePath.startsWith('.gemini/agents/') && - filePath.endsWith('.md') - ) { - const hash = crypto - .createHash('sha256') - .update(content) - .digest('hex'); - - try { - const agentDefs = await parseAgentMarkdown(fullPath, content); - if (agentDefs.length > 0) { - const agentName = agentDefs[0].name; - if (!acknowledgedAgents[projectRoot]) { - acknowledgedAgents[projectRoot] = {}; - } - acknowledgedAgents[projectRoot][agentName] = hash; - } - } catch (error) { - console.warn( - `Failed to parse agent for test acknowledgement: ${filePath}`, - error, - ); - } - } - } - - // Write acknowledged_agents.json to the home directory - if (Object.keys(acknowledgedAgents).length > 0) { - const ackPath = path.join( - rig.homeDir!, - '.gemini', - 'acknowledgments', - 'agents.json', - ); - fs.mkdirSync(path.dirname(ackPath), { recursive: true }); - fs.writeFileSync( - ackPath, - JSON.stringify(acknowledgedAgents, null, 2), - ); - } - - const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; - execSync('git init', execOptions); - execSync('git config user.email "test@example.com"', execOptions); - execSync('git config user.name "Test User"', execOptions); - - // Temporarily disable the interactive editor and git pager - // to avoid hanging the tests. It seems the the agent isn't - // consistently honoring the instructions to avoid interactive - // commands. - execSync('git config core.editor "true"', execOptions); - execSync('git config core.pager "cat"', execOptions); - execSync('git config commit.gpgsign false', execOptions); - execSync('git add .', execOptions); - execSync('git commit --allow-empty -m "Initial commit"', execOptions); + await setupTestFiles(rig, evalCase.files); } + symlinkNodeModules(rig.testDir || ''); + // If messages are provided, write a session file so --resume can load it. let sessionId: string | undefined; if (evalCase.messages) { @@ -188,6 +135,37 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { await evalCase.assert(rig, result); isSuccess = true; + return; // Success! Exit the retry loop. + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = getApiErrorCode(errorMessage); + + if (errorCode) { + const status = attempt < maxRetries ? 'RETRY' : 'SKIP'; + logReliabilityEvent( + evalCase.name, + attempt, + status, + errorCode, + errorMessage, + ); + + if (attempt < maxRetries) { + attempt++; + console.warn( + `[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`, + ); + continue; // Retry + } + + console.warn( + `[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`, + ); + return; // Gracefully exit without failing the test + } + + throw error; // Real failure } finally { if (isSuccess) { await fs.promises.unlink(activityLogFile).catch((err) => { @@ -206,9 +184,131 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { ); await rig.cleanup(); } + } +} + +function getApiErrorCode(message: string): '500' | '503' | undefined { + if ( + message.includes('status: UNAVAILABLE') || + message.includes('code: 503') || + message.includes('Service Unavailable') + ) { + return '503'; + } + if ( + message.includes('status: INTERNAL') || + message.includes('code: 500') || + message.includes('Internal error encountered') + ) { + return '500'; + } + return undefined; +} + +/** + * Log reliability event for later harvesting. + * + * Note: Uses synchronous file I/O to ensure the log is persisted even if the + * test process is abruptly terminated by a timeout or CI crash. Performance + * impact is negligible compared to long-running evaluation tests. + */ +function logReliabilityEvent( + testName: string, + attempt: number, + status: 'RETRY' | 'SKIP', + errorCode: '500' | '503', + errorMessage: string, +) { + const reliabilityLog = { + timestamp: new Date().toISOString(), + testName, + model: process.env.GEMINI_MODEL || 'unknown', + attempt, + status, + errorCode, + error: errorMessage, }; - runEval(policy, evalCase.name, fn, evalCase.timeout); + try { + const relDir = path.resolve(process.cwd(), 'evals/logs'); + fs.mkdirSync(relDir, { recursive: true }); + fs.appendFileSync( + path.join(relDir, 'api-reliability.jsonl'), + JSON.stringify(reliabilityLog) + '\n', + ); + } catch (logError) { + console.error('Failed to write reliability log:', logError); + } +} + +/** + * Helper to setup test files and git repository. + * + * Note: While this is an async function (due to parseAgentMarkdown), it + * intentionally uses synchronous filesystem and child_process operations + * for simplicity and to ensure sequential environment preparation. + */ +async function setupTestFiles(rig: TestRig, files: Record) { + const acknowledgedAgents: Record> = {}; + const projectRoot = fs.realpathSync(rig.testDir!); + + for (const [filePath, content] of Object.entries(files)) { + if (filePath.includes('..') || path.isAbsolute(filePath)) { + throw new Error(`Invalid file path in test case: ${filePath}`); + } + const fullPath = path.join(projectRoot, filePath); + if (!fullPath.startsWith(projectRoot)) { + throw new Error(`Path traversal detected: ${filePath}`); + } + + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + + if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) { + const hash = crypto.createHash('sha256').update(content).digest('hex'); + try { + const agentDefs = await parseAgentMarkdown(fullPath, content); + if (agentDefs.length > 0) { + const agentName = agentDefs[0].name; + if (!acknowledgedAgents[projectRoot]) { + acknowledgedAgents[projectRoot] = {}; + } + acknowledgedAgents[projectRoot][agentName] = hash; + } + } catch (error) { + console.warn( + `Failed to parse agent for test acknowledgement: ${filePath}`, + error, + ); + } + } + } + + if (Object.keys(acknowledgedAgents).length > 0) { + const ackPath = path.join( + rig.homeDir!, + '.gemini', + 'acknowledgments', + 'agents.json', + ); + fs.mkdirSync(path.dirname(ackPath), { recursive: true }); + fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2)); + } + + const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; + execSync('git init --initial-branch=main', execOptions); + execSync('git config user.email "test@example.com"', execOptions); + execSync('git config user.name "Test User"', execOptions); + + // Temporarily disable the interactive editor and git pager + // to avoid hanging the tests. It seems the the agent isn't + // consistently honoring the instructions to avoid interactive + // commands. + execSync('git config core.editor "true"', execOptions); + execSync('git config core.pager "cat"', execOptions); + execSync('git config commit.gpgsign false', execOptions); + execSync('git add .', execOptions); + execSync('git commit --allow-empty -m "Initial commit"', execOptions); } /** diff --git a/evals/vitest.config.ts b/evals/vitest.config.ts index 3231f31a10..50733a999c 100644 --- a/evals/vitest.config.ts +++ b/evals/vitest.config.ts @@ -16,10 +16,6 @@ export default defineConfig({ }, test: { testTimeout: 300000, // 5 minutes - // Retry in CI but not nightly to avoid blocking on API error. - retry: process.env['VITEST_RETRY'] - ? parseInt(process.env['VITEST_RETRY'], 10) - : 3, reporters: ['default', 'json'], outputFile: { json: 'evals/logs/report.json', diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 1978efd92e..1f0cbd9890 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -525,6 +525,8 @@ const baseMockUiState = { nightly: false, updateInfo: null, pendingHistoryItems: [], + mainControlsRef: () => {}, + rootUiRef: { current: null }, }; export const mockAppState: AppState = { diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 950363f6a8..b836202eb7 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -70,9 +70,7 @@ describe('App', () => { cleanUiDetailsVisible: true, quittingMessages: null, dialogsVisible: false, - mainControlsRef: { - current: null, - } as unknown as React.MutableRefObject, + mainControlsRef: vi.fn(), rootUiRef: { current: null, } as unknown as React.MutableRefObject, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ce5fc7c872..d58ed45d89 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -14,7 +14,7 @@ import { } from 'react'; import { type DOMElement, - measureElement, + ResizeObserver, useApp, useStdout, useStdin, @@ -397,7 +397,6 @@ export const AppContainer = (props: AppContainerProps) => { const branchName = useGitBranchName(config.getTargetDir()); // Layout measurements - const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); const lastTitleRef = useRef(null); @@ -1396,6 +1395,7 @@ Logging in with Google... Restarting Gemini CLI to continue. !proQuotaRequest && !copyModeEnabled; + const observerRef = useRef(null); const [controlsHeight, setControlsHeight] = useState(0); const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); @@ -1410,15 +1410,26 @@ Logging in with Google... Restarting Gemini CLI to continue. ? lastNonCopyControlsHeight : controlsHeight; - useLayoutEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - const roundedHeight = Math.round(fullFooterMeasurement.height); - if (roundedHeight > 0 && roundedHeight !== controlsHeight) { - setControlsHeight(roundedHeight); - } + const mainControlsRef = useCallback((node: DOMElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; } - }, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]); + + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const roundedHeight = Math.round(entry.contentRect.height); + setControlsHeight((prev) => + roundedHeight !== prev ? roundedHeight : prev, + ); + } + }); + observer.observe(node); + observerRef.current = observer; + } + }, []); // Compute available terminal height based on stable controls measurement const availableTerminalHeight = Math.max( diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 53c820f69e..4f1cca7d8c 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1491,4 +1491,47 @@ describe('AskUserDialog', () => { expect(frame).toContain('3. Option 3'); }); }); + + it('allows the question to exceed 15 lines in a tall terminal', async () => { + const longQuestion = Array.from( + { length: 25 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const questions: Question[] = [ + { + question: longQuestion, + header: 'Tall Test', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: 'D1' }, + { label: 'Option 2', description: 'D2' }, + { label: 'Option 3', description: 'D3' }, + ], + multiSelect: false, + unconstrainedHeight: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should show more than 15 lines of the question + // (The limit was previously 15, so showing Line 20 proves it's working) + expect(frame).toContain('Line 20'); + expect(frame).toContain('Line 25'); + // Should still show the options + expect(frame).toContain('1. Option 1'); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index cbb505320c..483fcb5055 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -855,13 +855,7 @@ const ChoiceQuestionView: React.FC = ({ listHeight && !isAlternateBuffer ? question.unconstrainedHeight ? Math.max(1, listHeight - selectionItems.length * 2) - : Math.min( - 15, - Math.max( - 1, - listHeight - Math.max(DIALOG_PADDING, reservedListHeight), - ), - ) + : Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight)) : undefined; const maxItemsToShow = diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index e5d74b5cf5..b6bc0795eb 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -21,6 +21,10 @@ import { type UIState, } from '../contexts/UIStateContext.js'; import { type IndividualToolCallDisplay } from '../types.js'; +import { + type ConfirmingToolState, + useConfirmingTool, +} from '../hooks/useConfirmingTool.js'; // Mock dependencies const mockUseSettings = vi.fn().mockReturnValue({ @@ -53,6 +57,10 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: vi.fn(), })); +vi.mock('../hooks/useConfirmingTool.js', () => ({ + useConfirmingTool: vi.fn(), +})); + vi.mock('./AppHeader.js', () => ({ AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => ( {showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'} @@ -503,6 +511,54 @@ describe('MainContent', () => { unmount(); }); + it('renders a subagent with a complete box including bottom border', async () => { + const subagentCall = { + callId: 'subagent-1', + name: 'codebase_investigator', + description: 'Investigating codebase', + status: CoreToolCallStatus.Executing, + kind: 'agent', + resultDisplay: { + isSubagentProgress: true, + agentName: 'codebase_investigator', + recentActivity: [ + { + id: '1', + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + state: 'running', + }, + } as Partial as IndividualToolCallDisplay; + + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Investigate' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: [subagentCall], + borderBottom: true, + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + expect(lastFrame()).toContain('codebase_investigator'); + }); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + it('renders a split tool group without a gap between static and pending areas', async () => { const toolCalls = [ { @@ -547,13 +603,124 @@ describe('MainContent', () => { const { lastFrame, unmount } = await renderWithProviders(, { uiState: uiState as Partial, }); - const output = lastFrame(); - // Verify Part 1 and Part 2 are rendered. - expect(output).toContain('Part 1'); - expect(output).toContain('Part 2'); + + await waitFor(() => { + const output = lastFrame(); + // Verify Part 1 and Part 2 are rendered. + expect(output).toContain('Part 1'); + expect(output).toContain('Part 2'); + }); // The snapshot will be the best way to verify there is no gap (empty line) between them. - expect(output).toMatchSnapshot(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders a ToolConfirmationQueue without an extra line when preceded by hidden tools', async () => { + const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import( + '@google/gemini-cli-core' + ); + const hiddenToolCalls = [ + { + callId: 'tool-hidden', + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + resultDisplay: 'Hidden content', + } as Partial as IndividualToolCallDisplay, + ]; + + const confirmingTool = { + tool: { + callId: 'call-1', + name: 'exit_plan_mode', + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exit_plan_mode' as const, + planPath: '/path/to/plan', + }, + }, + index: 1, + total: 1, + }; + + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Apply plan' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: hiddenToolCalls, + borderBottom: true, + }, + ], + }; + + // We need to mock useConfirmingTool to return our confirmingTool + vi.mocked(useConfirmingTool).mockReturnValue( + confirmingTool as unknown as ConfirmingToolState, + ); + + mockUseSettings.mockReturnValue( + createMockSettings({ + security: { enablePermanentToolApproval: true }, + ui: { errorVerbosity: 'full' }, + }), + ); + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + const output = lastFrame(); + // The output should NOT contain 'Hidden content' + expect(output).not.toContain('Hidden content'); + // The output should contain the confirmation header + expect(output).toContain('Ready to start implementation?'); + }); + + // Snapshot will reveal if there are extra blank lines + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders a spurious line when a tool group has only hidden tools and borderBottom true', async () => { + const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import( + '@google/gemini-cli-core' + ); + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Apply plan' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: [ + { + callId: 'tool-1', + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + resultDisplay: 'hidden', + } as Partial as IndividualToolCallDisplay, + ], + borderBottom: true, + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Apply plan'); + }); + + // This snapshot will show no spurious line because the group is now correctly suppressed. + expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index d5173e8c9c..0e8e29e54d 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -91,6 +91,19 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc " `; +exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Apply plan +ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Ready to start implementation? │ +│ │ +│ Error reading plan: Storage must be initialized before use │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = ` "AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────╮ @@ -105,6 +118,30 @@ exports[`MainContent > renders a split tool group without a gap between static a " `; +exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Apply plan +ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ +" +`; + +exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Investigate +ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ā–„ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ≔ Running Agent... (ctrl+o to collapse) │ +│ │ +│ Running subagent codebase_investigator... │ +│ │ +│ ā ‹ run_shell_command echo hello │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = ` "ScrollableList AppHeader(full) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 69da3a1029..637e8afa40 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -172,12 +172,10 @@ export const ToolGroupMessage: React.FC = ({ // If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity // internal errors, plan-mode hidden write/edit), we should not emit standalone // border fragments. The only case where an empty group should render is the - // explicit "closing slice" (tools: []) used to bridge static/pending sections. + // explicit "closing slice" (tools: []) used to bridge static/pending sections, + // and only if it's actually continuing an open box from above. const isExplicitClosingSlice = allToolCalls.length === 0; - if ( - visibleToolCalls.length === 0 && - (!isExplicitClosingSlice || borderBottomOverride !== true) - ) { + if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) { return null; } @@ -269,19 +267,20 @@ export const ToolGroupMessage: React.FC = ({ We have to keep the bottom border separate so it doesn't get drawn over by the sticky header directly inside it. */ - (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( - - ) + (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && + borderBottomOverride !== false && ( + + ) } ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx new file mode 100644 index 0000000000..96239fb720 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import { + makeFakeConfig, + CoreToolCallStatus, + ApprovalMode, + WRITE_FILE_DISPLAY_NAME, + Kind, +} from '@google/gemini-cli-core'; +import os from 'node:os'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; + +describe('ToolGroupMessage Regression Tests', () => { + const baseMockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + }); + const fullVerbositySettings = createMockSettings({ + ui: { errorVerbosity: 'full' }, + }); + + const createToolCall = ( + overrides: Partial = {}, + ): IndividualToolCallDisplay => + ({ + callId: 'tool-123', + name: 'test-tool', + status: CoreToolCallStatus.Success, + ...overrides, + }) as IndividualToolCallDisplay; + + const createItem = (tools: IndividualToolCallDisplay[]) => ({ + id: 1, + type: 'tool_group' as const, + tools, + }); + + it('Plan Mode: suppresses phantom tool group (hidden tools)', async () => { + const toolCalls = [ + createToolCall({ + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it('Agent Case: suppresses the bottom border box for ongoing agents (no vertical ticks)', async () => { + const toolCalls = [ + createToolCall({ + name: 'agent', + kind: Kind.Agent, + status: CoreToolCallStatus.Executing, + resultDisplay: { + isSubagentProgress: true, + agentName: 'TestAgent', + state: 'running', + recentActivity: [], + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Running Agent...'); + // It should render side borders from the content + expect(output).toContain('│'); + // It should NOT render the bottom border box (no corners ā•° ╯) + expect(output).not.toContain('ā•°'); + expect(output).not.toContain('╯'); + unmount(); + }); + + it('Agent Case: renders a bottom border horizontal line for completed agents', async () => { + const toolCalls = [ + createToolCall({ + name: 'agent', + kind: Kind.Agent, + status: CoreToolCallStatus.Success, + resultDisplay: { + isSubagentProgress: true, + agentName: 'TestAgent', + state: 'completed', + recentActivity: [], + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + // Verify it rendered subagent content + expect(output).toContain('Agent'); + // It should render the bottom horizontal line + expect(output).toContain( + '╰──────────────────────────────────────────────────────────────────────────╯', + ); + unmount(); + }); + + it('Bridges: still renders a bridge if it has a top border', async () => { + const toolCalls: IndividualToolCallDisplay[] = []; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + expect(lastFrame({ allowEmpty: true })).not.toBe(''); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e4d95a79af..8447247e53 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -191,7 +191,7 @@ export interface UIState { sessionStats: SessionStatsState; terminalWidth: number; terminalHeight: number; - mainControlsRef: React.MutableRefObject; + mainControlsRef: React.RefCallback; // NOTE: This is for performance profiling only. rootUiRef: React.MutableRefObject; currentIDE: IdeInfo | null; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 54006d2ab2..757c24f2c3 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -26,7 +26,6 @@ import { debugLogger, runInDevTraceSpan, EDIT_TOOL_NAMES, - ASK_USER_TOOL_NAME, processRestorableToolCalls, recordToolCallInteractions, ToolErrorType, @@ -40,6 +39,7 @@ import { isBackgroundExecutionData, Kind, ACTIVATE_SKILL_TOOL_NAME, + shouldHideToolCall, } from '@google/gemini-cli-core'; import type { Config, @@ -66,7 +66,12 @@ import type { SlashCommandProcessorResult, HistoryItemModel, } from '../types.js'; -import { StreamingState, MessageType } from '../types.js'; +import { + StreamingState, + MessageType, + mapCoreStatusToDisplayStatus, + ToolCallStatus, +} from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; @@ -541,14 +546,39 @@ export const useGeminiStream = ( const anyVisibleInHistory = pushedToolCallIds.size > 0; const anyVisibleInPending = remainingTools.some((tc) => { - // AskUser tools are rendered by AskUserDialog, not ToolGroupMessage - const isInProgress = - tc.status !== 'success' && - tc.status !== 'error' && - tc.status !== 'cancelled'; - if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) { + const displayName = tc.tool?.displayName ?? tc.request.name; + + let hasResultDisplay = false; + if ( + tc.status === CoreToolCallStatus.Success || + tc.status === CoreToolCallStatus.Error || + tc.status === CoreToolCallStatus.Cancelled + ) { + hasResultDisplay = !!tc.response?.resultDisplay; + } else if (tc.status === CoreToolCallStatus.Executing) { + hasResultDisplay = !!tc.liveOutput; + } + + // AskUser tools and Plan Mode write/edit are handled by this logic + if ( + shouldHideToolCall({ + displayName, + status: tc.status, + approvalMode: tc.approvalMode, + hasResultDisplay, + parentCallId: tc.request.parentCallId, + }) + ) { return false; } + + // ToolGroupMessage explicitly hides Confirming tools because they are + // rendered in the interactive ToolConfirmationQueue instead. + const displayStatus = mapCoreStatusToDisplayStatus(tc.status); + if (displayStatus === ToolCallStatus.Confirming) { + return false; + } + // ToolGroupMessage now shows all non-canceled tools, so they are visible // in pending and we need to draw the closing border for them. return true; diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 575202ce98..0bcb3863ce 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -691,6 +691,40 @@ describe('useSlashCompletion', () => { }); unmount(); }); + + it('should rank primary name prefix matches higher than alias prefix matches', async () => { + const slashCommands = [ + createTestCommand({ + name: 'footer', + altNames: ['statusline'], + description: 'Configure footer', + }), + createTestCommand({ + name: 'stats', + altNames: ['usage'], + description: 'Check stats', + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/stat', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + // 'stats' should be first because 'stat' is a prefix match on its name + // while 'footer' only matches 'stat' via its alias 'statusline' + expect(result.current.suggestions[0].label).toBe('stats'); + expect(result.current.suggestions[1].label).toBe('footer'); + }); + unmount(); + }); }); describe('Sub-Commands', () => { diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 4afa8e2241..7b06fdc1f4 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -272,13 +272,45 @@ function useCommandSuggestions( } if (!signal.aborted) { - // Sort potentialSuggestions so that exact match (by name or altName) comes first + // Sort potentialSuggestions so that exact name/prefix match comes first, + // prioritizing primary name over altNames. + const lowerPartial = partial.toLowerCase(); const sortedSuggestions = [...potentialSuggestions].sort((a, b) => { - const aIsExact = matchesCommand(a, partial); - const bIsExact = matchesCommand(b, partial); - if (aIsExact && !bIsExact) return -1; - if (!aIsExact && bIsExact) return 1; - return 0; + // 1. Exact name match + const aNameExact = a.name.toLowerCase() === lowerPartial; + const bNameExact = b.name.toLowerCase() === lowerPartial; + if (aNameExact && !bNameExact) return -1; + if (!aNameExact && bNameExact) return 1; + + // 2. Exact altName match + const aAltExact = + a.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + const bAltExact = + b.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + if (aAltExact && !bAltExact) return -1; + if (!aAltExact && bAltExact) return 1; + + // 3. Prefix name match + const aNamePrefix = a.name.toLowerCase().startsWith(lowerPartial); + const bNamePrefix = b.name.toLowerCase().startsWith(lowerPartial); + if (aNamePrefix && !bNamePrefix) return -1; + if (!aNamePrefix && bNamePrefix) return 1; + + // 4. Prefix altName match + const aAltPrefix = + a.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + const bAltPrefix = + b.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + if (aAltPrefix && !bAltPrefix) return -1; + if (!aAltPrefix && bAltPrefix) return 1; + + return 0; // Maintain FZF score order for other matches }); const finalSuggestions = sortedSuggestions.map((cmd) => { diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 43b970da8e..7bf51b7d84 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -25,7 +25,7 @@ const mockUIState = { dialogsVisible: false, streamingState: StreamingState.Idle, isBackgroundShellListOpen: false, - mainControlsRef: { current: null }, + mainControlsRef: vi.fn(), customDialog: null, historyManager: { addItem: vi.fn() }, history: [], diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index c38457e4aa..a326164c43 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -9,6 +9,7 @@ import { BrowserManager } from './browserManager.js'; import { makeFakeConfig } from '../../test-utils/config.js'; import type { Config } from '../../config/config.js'; import { injectAutomationOverlay } from './automationOverlay.js'; +import { injectInputBlocker } from './inputBlocker.js'; import { coreEvents } from '../../utils/events.js'; // Mock the MCP SDK @@ -54,6 +55,13 @@ vi.mock('./automationOverlay.js', () => ({ injectAutomationOverlay: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./inputBlocker.js', () => ({ + injectInputBlocker: vi.fn().mockResolvedValue(undefined), + removeInputBlocker: vi.fn().mockResolvedValue(undefined), + suspendInputBlocker: vi.fn().mockResolvedValue(undefined), + resumeInputBlocker: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -78,6 +86,7 @@ describe('BrowserManager', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {}); // Re-establish consent mock after resetAllMocks @@ -692,21 +701,66 @@ describe('BrowserManager', () => { }); describe('overlay re-injection in callTool', () => { - it('should re-inject overlay after click in non-headless mode', async () => { + it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => { + // Enable input disabling in config + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: '1_2' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); - it('should re-inject overlay after navigate_page in non-headless mode', async () => { + it('should re-inject overlay and input blocker after navigate_page in non-headless mode when input disabling is enabled', async () => { + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); await manager.callTool('navigate_page', { url: 'https://example.com' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); - it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => { + it('should re-inject overlay and input blocker after click_at, new_page, press_key, handle_dialog when input disabling is enabled', async () => { + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); for (const tool of [ 'click_at', @@ -715,12 +769,15 @@ describe('BrowserManager', () => { 'handle_dialog', ]) { vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).toHaveBeenCalledTimes(1); + expect(injectInputBlocker).toHaveBeenCalledTimes(1); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); } }); - it('should NOT re-inject overlay after read-only tools', async () => { + it('should NOT re-inject overlay or input blocker after read-only tools', async () => { const manager = new BrowserManager(mockConfig); for (const tool of [ 'take_snapshot', @@ -729,8 +786,10 @@ describe('BrowserManager', () => { 'fill', ]) { vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).not.toHaveBeenCalled(); + expect(injectInputBlocker).not.toHaveBeenCalled(); } }); diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 4eb9c2b19c..90de6b99fc 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -215,6 +215,10 @@ export class BrowserManager { // Re-inject the automation overlay and input blocker after tools that // can cause a full-page navigation. chrome-devtools-mcp emits no MCP // notifications, so callTool() is the only interception point. + // + // The input blocker injection is idempotent: the injected function + // reuses the existing DOM element when present and only recreates + // it when navigation has actually replaced the page DOM. if ( !result.isError && POTENTIALLY_NAVIGATING_TOOLS.has(toolName) && @@ -224,17 +228,8 @@ export class BrowserManager { if (this.shouldInjectOverlay) { await injectAutomationOverlay(this, signal); } - // Only re-inject the input blocker for tools that *reliably* - // replace the page DOM (navigate_page, new_page, select_page). - // click/click_at are handled by pointer-events suspend/resume - // in mcpToolWrapper — no full re-inject roundtrip needed. - // press_key/handle_dialog only sometimes navigate. - const reliableNavigation = - toolName === 'navigate_page' || - toolName === 'new_page' || - toolName === 'select_page'; - if (this.shouldDisableInput && reliableNavigation) { - await injectInputBlocker(this); + if (this.shouldDisableInput) { + await injectInputBlocker(this, signal); } } catch { // Never let overlay/blocker failures interrupt the tool result diff --git a/packages/core/src/agents/browser/inputBlocker.test.ts b/packages/core/src/agents/browser/inputBlocker.test.ts index 5d77aac079..abccac70c3 100644 --- a/packages/core/src/agents/browser/inputBlocker.test.ts +++ b/packages/core/src/agents/browser/inputBlocker.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { injectInputBlocker, removeInputBlocker } from './inputBlocker.js'; +import { + injectInputBlocker, + removeInputBlocker, + suspendInputBlocker, + resumeInputBlocker, +} from './inputBlocker.js'; import type { BrowserManager } from './browserManager.js'; describe('inputBlocker', () => { @@ -28,6 +33,7 @@ describe('inputBlocker', () => { { function: expect.stringContaining('__gemini_input_blocker'), }, + undefined, ); }); @@ -77,6 +83,29 @@ describe('inputBlocker', () => { injectInputBlocker(mockBrowserManager), ).resolves.toBeUndefined(); }); + + it('should be safe to call multiple times (idempotent injection)', async () => { + await injectInputBlocker(mockBrowserManager); + await injectInputBlocker(mockBrowserManager); + + expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 1, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 2, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + }); }); describe('removeInputBlocker', () => { @@ -88,6 +117,7 @@ describe('inputBlocker', () => { { function: expect.stringContaining('__gemini_input_blocker'), }, + undefined, ); }); @@ -110,4 +140,38 @@ describe('inputBlocker', () => { ).resolves.toBeUndefined(); }); }); + + describe('suspendInputBlocker and resumeInputBlocker', () => { + it('should not throw when blocker element is missing', async () => { + // Simulate evaluate_script resolving successfully even if the DOM element is absent. + mockBrowserManager.callTool = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Script ran on page and returned:' }], + }); + + await expect( + suspendInputBlocker(mockBrowserManager), + ).resolves.toBeUndefined(); + await expect( + resumeInputBlocker(mockBrowserManager), + ).resolves.toBeUndefined(); + + expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 1, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 2, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + }); + }); }); diff --git a/packages/core/src/agents/browser/inputBlocker.ts b/packages/core/src/agents/browser/inputBlocker.ts index ea6a797271..0d6b9610cf 100644 --- a/packages/core/src/agents/browser/inputBlocker.ts +++ b/packages/core/src/agents/browser/inputBlocker.ts @@ -198,11 +198,14 @@ const RESUME_BLOCKER_FUNCTION = `() => { */ export async function injectInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: INPUT_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: INPUT_BLOCKER_FUNCTION }, + signal, + ); debugLogger.log('Input blocker injected successfully'); } catch (error) { // Log but don't throw - input blocker is a UX enhancement, not critical functionality @@ -222,11 +225,14 @@ export async function injectInputBlocker( */ export async function removeInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: REMOVE_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: REMOVE_BLOCKER_FUNCTION }, + signal, + ); debugLogger.log('Input blocker removed successfully'); } catch (error) { // Log but don't throw - removal failure is not critical @@ -244,11 +250,14 @@ export async function removeInputBlocker( */ export async function suspendInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: SUSPEND_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: SUSPEND_BLOCKER_FUNCTION }, + signal, + ); } catch { // Non-critical — tool call will still attempt to proceed } @@ -260,11 +269,14 @@ export async function suspendInputBlocker( */ export async function resumeInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: RESUME_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: RESUME_BLOCKER_FUNCTION }, + signal, + ); } catch { // Non-critical } diff --git a/packages/core/src/agents/browser/mcpToolWrapper.test.ts b/packages/core/src/agents/browser/mcpToolWrapper.test.ts index 3a4d5cfe38..fa9aa228a5 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.test.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.test.ts @@ -224,6 +224,7 @@ describe('mcpToolWrapper', () => { expect.objectContaining({ function: expect.stringContaining('__gemini_input_blocker'), }), + expect.any(AbortSignal), ); // Second call: click @@ -241,6 +242,7 @@ describe('mcpToolWrapper', () => { expect.objectContaining({ function: expect.stringContaining('__gemini_input_blocker'), }), + expect.any(AbortSignal), ); }); diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts index b57a7af7f0..cab493dff7 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.ts @@ -129,7 +129,7 @@ class McpToolInvocation extends BaseToolInvocation< // chrome-devtools-mcp's interactability checks pass. // Only toggles pointer-events CSS — no DOM change, no flicker. if (this.needsBlockerSuspend) { - await suspendInputBlocker(this.browserManager); + await suspendInputBlocker(this.browserManager, signal); } const result: McpToolCallResult = await this.browserManager.callTool( @@ -155,7 +155,7 @@ class McpToolInvocation extends BaseToolInvocation< // Resume input blocker after interactive tool completes. if (this.needsBlockerSuspend) { - await resumeInputBlocker(this.browserManager); + await resumeInputBlocker(this.browserManager, signal); } if (result.isError) { @@ -181,7 +181,7 @@ class McpToolInvocation extends BaseToolInvocation< // Resume on error path too so the blocker is always restored if (this.needsBlockerSuspend) { - await resumeInputBlocker(this.browserManager).catch(() => {}); + await resumeInputBlocker(this.browserManager, signal).catch(() => {}); } debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 5bde6a44da..b58fe271f6 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LinuxSandboxManager } from './LinuxSandboxManager.js'; -import * as sandboxManager from '../../services/sandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import fs from 'node:fs'; @@ -18,14 +17,16 @@ vi.mock('node:fs', async () => { // @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")' ...actual.default, existsSync: vi.fn(() => true), - realpathSync: vi.fn((p: string | Buffer) => p.toString()), + realpathSync: vi.fn((p) => p.toString()), + statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), }, existsSync: vi.fn(() => true), - realpathSync: vi.fn((p: string | Buffer) => p.toString()), + realpathSync: vi.fn((p) => p.toString()), + statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), openSync: vi.fn(), closeSync: vi.fn(), @@ -48,8 +49,12 @@ describe('LinuxSandboxManager', () => { vi.restoreAllMocks(); }); - const getBwrapArgs = async (req: SandboxRequest) => { - const result = await manager.prepareCommand(req); + const getBwrapArgs = async ( + req: SandboxRequest, + customManager?: LinuxSandboxManager, + ) => { + const mgr = customManager || manager; + const result = await mgr.prepareCommand(req); expect(result.program).toBe('sh'); expect(result.args[0]).toBe('-c'); expect(result.args[1]).toBe( @@ -60,41 +65,6 @@ describe('LinuxSandboxManager', () => { return result.args.slice(4); }; - /** - * Helper to verify only the dynamic, policy-based binds (e.g. allowedPaths, forbiddenPaths). - * It asserts that the base workspace and governance files are present exactly once, - * then strips them away, leaving only the dynamic binds for a focused, non-brittle assertion. - */ - const expectDynamicBinds = ( - bwrapArgs: string[], - expectedDynamicBinds: string[], - ) => { - const bindsIndex = bwrapArgs.indexOf('--seccomp'); - const allBinds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex); - - const baseBinds = [ - '--bind', - workspace, - workspace, - '--ro-bind', - `${workspace}/.gitignore`, - `${workspace}/.gitignore`, - '--ro-bind', - `${workspace}/.geminiignore`, - `${workspace}/.geminiignore`, - '--ro-bind', - `${workspace}/.git`, - `${workspace}/.git`, - ]; - - // Verify the base binds are present exactly at the beginning - expect(allBinds.slice(0, baseBinds.length)).toEqual(baseBinds); - - // Extract the remaining dynamic binds - const dynamicBinds = allBinds.slice(baseBinds.length); - expect(dynamicBinds).toEqual(expectedDynamicBinds); - }; - describe('prepareCommand', () => { it('should correctly format the base command and args', async () => { const bwrapArgs = await getBwrapArgs({ @@ -117,7 +87,7 @@ describe('LinuxSandboxManager', () => { '/proc', '--tmpfs', '/tmp', - '--bind', + '--ro-bind-try', workspace, workspace, '--ro-bind', @@ -137,6 +107,73 @@ describe('LinuxSandboxManager', () => { ]); }); + it('binds workspace read-write when readonly is false', async () => { + const customManager = new LinuxSandboxManager({ + workspace, + modeConfig: { readonly: false }, + }); + const bwrapArgs = await getBwrapArgs( + { + command: 'ls', + args: [], + cwd: workspace, + env: {}, + }, + customManager, + ); + + expect(bwrapArgs).toContain('--bind-try'); + expect(bwrapArgs).toContain(workspace); + }); + + it('maps network permissions to --share-net', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'curl', + args: [], + cwd: workspace, + env: {}, + policy: { additionalPermissions: { network: true } }, + }); + + expect(bwrapArgs).toContain('--share-net'); + }); + + it('maps explicit write permissions to --bind-try', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'touch', + args: [], + cwd: workspace, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { write: ['/home/user/workspace/out/dir'] }, + }, + }, + }); + + const index = bwrapArgs.indexOf('--bind-try'); + expect(index).not.toBe(-1); + expect(bwrapArgs[index + 1]).toBe('/home/user/workspace/out/dir'); + }); + + it('rejects overrides in plan mode', async () => { + const customManager = new LinuxSandboxManager({ + workspace, + modeConfig: { allowOverrides: false }, + }); + await expect( + customManager.prepareCommand({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + policy: { additionalPermissions: { network: true } }, + }), + ).rejects.toThrow( + /Cannot override readonly\/network\/filesystem restrictions in Plan mode/, + ); + }); + it('should correctly pass through the cwd to the resulting command', async () => { const req: SandboxRequest = { command: 'ls', @@ -184,12 +221,7 @@ describe('LinuxSandboxManager', () => { }, }); - expect(bwrapArgs).toContain('--unshare-user'); - expect(bwrapArgs).toContain('--unshare-ipc'); - expect(bwrapArgs).toContain('--unshare-pid'); - expect(bwrapArgs).toContain('--unshare-uts'); - expect(bwrapArgs).toContain('--unshare-cgroup'); - expect(bwrapArgs).not.toContain('--unshare-all'); + expect(bwrapArgs).toContain('--share-net'); }); describe('governance files', () => { @@ -252,15 +284,32 @@ describe('LinuxSandboxManager', () => { }, }); - // Verify the specific bindings were added correctly - expectDynamicBinds(bwrapArgs, [ + expect(bwrapArgs).toContain('--bind-try'); + expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe( '--bind-try', - '/tmp/cache', - '/tmp/cache', + ); + expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe( '--bind-try', - '/opt/tools', - '/opt/tools', - ]); + ); + }); + + it('should not grant read-write access to allowedPaths inside the workspace when readonly mode is active', async () => { + const manager = new LinuxSandboxManager({ + workspace, + modeConfig: { readonly: true }, + }); + const result = await manager.prepareCommand({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + policy: { + allowedPaths: [workspace + '/subdirectory'], + }, + }); + const bwrapArgs = result.args; + const bindIndex = bwrapArgs.indexOf(workspace + '/subdirectory'); + expect(bwrapArgs[bindIndex - 1]).toBe('--ro-bind-try'); }); it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => { @@ -274,23 +323,20 @@ describe('LinuxSandboxManager', () => { }, }); - // Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash - expectDynamicBinds(bwrapArgs, []); + const binds = bwrapArgs.filter((a) => a === workspace); + expect(binds.length).toBe(2); }); }); describe('forbiddenPaths', () => { it('should parameterize forbidden paths and explicitly deny them', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => { - // Mock /tmp/cache as a directory, and /opt/secret.txt as a file + vi.mocked(fs.statSync).mockImplementation((p) => { if (p.toString().includes('cache')) { return { isDirectory: () => true } as fs.Stats; } return { isDirectory: () => false } as fs.Stats; }); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -302,27 +348,22 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--tmpfs', - '/tmp/cache', - '--remount-ro', - '/tmp/cache', - '--ro-bind-try', - '/dev/null', - '/opt/secret.txt', - ]); + const cacheIndex = bwrapArgs.indexOf('/tmp/cache'); + expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs'); + + const secretIndex = bwrapArgs.indexOf('/opt/secret.txt'); + expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind'); + expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null'); }); it('resolves forbidden symlink paths to their real paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => false }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt'; - return p.toString(); - }, + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => false }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt'; + return p.toString(); + }); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -334,24 +375,18 @@ describe('LinuxSandboxManager', () => { }, }); - // Should explicitly mask both the resolved path and the original symlink path - expectDynamicBinds(bwrapArgs, [ - '--ro-bind-try', - '/dev/null', - '/opt/real-target.txt', - '--ro-bind-try', - '/dev/null', - '/tmp/forbidden-symlink', - ]); + const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt'); + expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind'); + expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null'); }); it('explicitly denies non-existent forbidden paths to prevent creation', async () => { const error = new Error('File not found') as NodeJS.ErrnoException; error.code = 'ENOENT'; - vi.spyOn(fs.promises, 'stat').mockRejectedValue(error); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + vi.mocked(fs.statSync).mockImplementation(() => { + throw error; + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -363,23 +398,19 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--symlink', - '/.forbidden', - '/tmp/not-here.txt', - ]); + const idx = bwrapArgs.indexOf('/tmp/not-here.txt'); + expect(bwrapArgs[idx - 2]).toBe('--symlink'); + expect(bwrapArgs[idx - 1]).toBe('/dev/null'); }); it('masks directory symlinks with tmpfs for both paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => true }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/tmp/dir-link') return '/opt/real-dir'; - return p.toString(); - }, + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => true }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === '/tmp/dir-link') return '/opt/real-dir'; + return p.toString(); + }); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -391,25 +422,15 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--tmpfs', - '/opt/real-dir', - '--remount-ro', - '/opt/real-dir', - '--tmpfs', - '/tmp/dir-link', - '--remount-ro', - '/tmp/dir-link', - ]); + const idx = bwrapArgs.indexOf('/opt/real-dir'); + expect(bwrapArgs[idx - 1]).toBe('--tmpfs'); }); it('should override allowed paths if a path is also in forbidden paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => true }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => true }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -422,15 +443,12 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--bind-try', - '/tmp/conflict', - '/tmp/conflict', - '--tmpfs', - '/tmp/conflict', - '--remount-ro', - '/tmp/conflict', - ]); + const bindTryIdx = bwrapArgs.indexOf('--bind-try'); + const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs'); + + expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict'); + expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict'); + expect(tmpfsIdx).toBeGreaterThan(bindTryIdx); }); }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 2b3e8cc7c9..33f12beafa 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs'; +import { debugLogger } from '../../utils/debugLogger.js'; import { join, dirname, normalize } from 'node:path'; import os from 'node:os'; import { @@ -12,15 +13,25 @@ import { type GlobalSandboxOptions, type SandboxRequest, type SandboxedCommand, + type SandboxPermissions, GOVERNANCE_FILES, sanitizePaths, - tryRealpath, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; -import { isNodeError } from '../../utils/errors.js'; +import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { + isStrictlyApproved, + verifySandboxOverrides, + getCommandName, +} from '../utils/commandUtils.js'; +import { + tryRealpath, + resolveGitWorktreePaths, + isErrnoException, +} from '../utils/fsUtils.js'; let cachedBpfPath: string | undefined; @@ -102,13 +113,24 @@ function touch(filePath: string, isDirectory: boolean) { import { isKnownSafeCommand, isDangerousCommand, -} from '../macos/commandSafety.js'; +} from '../utils/commandSafety.js'; /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ + +export interface LinuxSandboxOptions extends GlobalSandboxOptions { + modeConfig?: { + readonly?: boolean; + network?: boolean; + approvedTools?: string[]; + allowOverrides?: boolean; + }; + policyManager?: SandboxPolicyManager; +} + export class LinuxSandboxManager implements SandboxManager { - constructor(private readonly options: GlobalSandboxOptions) {} + constructor(private readonly options: LinuxSandboxOptions) {} isKnownSafeCommand(args: string[]): boolean { return isKnownSafeCommand(args); @@ -119,6 +141,41 @@ export class LinuxSandboxManager implements SandboxManager { } async prepareCommand(req: SandboxRequest): Promise { + const isReadonlyMode = this.options.modeConfig?.readonly ?? true; + const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; + + verifySandboxOverrides(allowOverrides, req.policy); + + const commandName = await getCommandName(req); + const isApproved = allowOverrides + ? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools) + : false; + const workspaceWrite = !isReadonlyMode || isApproved; + const networkAccess = + this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; + + const persistentPermissions = allowOverrides + ? this.options.policyManager?.getCommandPermissions(commandName) + : undefined; + + const mergedAdditional: SandboxPermissions = { + fileSystem: { + read: [ + ...(persistentPermissions?.fileSystem?.read ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.read ?? []), + ], + write: [ + ...(persistentPermissions?.fileSystem?.write ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.write ?? []), + ], + }, + network: + networkAccess || + persistentPermissions?.network || + req.policy?.additionalPermissions?.network || + false, + }; + const sanitizationConfig = getSecureSanitizationConfig( req.policy?.sanitizationConfig, ); @@ -126,13 +183,142 @@ export class LinuxSandboxManager implements SandboxManager { const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); const bwrapArgs: string[] = [ - ...this.getNetworkArgs(req), - ...this.getBaseArgs(), - ...this.getGovernanceArgs(), - ...this.getAllowedPathsArgs(req.policy?.allowedPaths), - ...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)), + '--unshare-all', + '--new-session', // Isolate session + '--die-with-parent', // Prevent orphaned runaway processes ]; + if (mergedAdditional.network) { + bwrapArgs.push('--share-net'); + } + + bwrapArgs.push( + '--ro-bind', + '/', + '/', + '--dev', // Creates a safe, minimal /dev (replaces --dev-bind) + '/dev', + '--proc', // Creates a fresh procfs for the unshared PID namespace + '/proc', + '--tmpfs', // Provides an isolated, writable /tmp directory + '/tmp', + ); + + const workspacePath = tryRealpath(this.options.workspace); + + const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + + if (workspaceWrite) { + bwrapArgs.push( + '--bind-try', + this.options.workspace, + this.options.workspace, + ); + if (workspacePath !== this.options.workspace) { + bwrapArgs.push('--bind-try', workspacePath, workspacePath); + } + } else { + bwrapArgs.push( + '--ro-bind-try', + this.options.workspace, + this.options.workspace, + ); + if (workspacePath !== this.options.workspace) { + bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath); + } + } + + const { worktreeGitDir, mainGitDir } = + resolveGitWorktreePaths(workspacePath); + if (worktreeGitDir) { + bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir); + } + if (mainGitDir) { + bwrapArgs.push(bindFlag, mainGitDir, mainGitDir); + } + + const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; + const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, ''); + for (const allowedPath of allowedPaths) { + const resolved = tryRealpath(allowedPath); + if (!fs.existsSync(resolved)) continue; + const normalizedAllowedPath = normalize(resolved).replace(/\/$/, ''); + if (normalizedAllowedPath !== normalizedWorkspace) { + if ( + !workspaceWrite && + normalizedAllowedPath.startsWith(normalizedWorkspace + '/') + ) { + bwrapArgs.push('--ro-bind-try', resolved, resolved); + } else { + bwrapArgs.push('--bind-try', resolved, resolved); + } + } + } + + const additionalReads = + sanitizePaths(mergedAdditional.fileSystem?.read) || []; + for (const p of additionalReads) { + try { + const safeResolvedPath = tryRealpath(p); + bwrapArgs.push('--ro-bind-try', safeResolvedPath, safeResolvedPath); + } catch (e: unknown) { + debugLogger.warn(e instanceof Error ? e.message : String(e)); + } + } + + const additionalWrites = + sanitizePaths(mergedAdditional.fileSystem?.write) || []; + for (const p of additionalWrites) { + try { + const safeResolvedPath = tryRealpath(p); + bwrapArgs.push('--bind-try', safeResolvedPath, safeResolvedPath); + } catch (e: unknown) { + debugLogger.warn(e instanceof Error ? e.message : String(e)); + } + } + + for (const file of GOVERNANCE_FILES) { + const filePath = join(this.options.workspace, file.path); + touch(filePath, file.isDirectory); + const realPath = tryRealpath(filePath); + bwrapArgs.push('--ro-bind', filePath, filePath); + if (realPath !== filePath) { + bwrapArgs.push('--ro-bind', realPath, realPath); + } + } + + const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; + for (const p of forbiddenPaths) { + let resolved: string; + try { + resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path + if (!fs.existsSync(resolved)) continue; + } catch (e: unknown) { + debugLogger.warn( + `Failed to resolve forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`, + ); + bwrapArgs.push('--ro-bind', '/dev/null', p); + continue; + } + try { + const stat = fs.statSync(resolved); + if (stat.isDirectory()) { + bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved); + } else { + bwrapArgs.push('--ro-bind', '/dev/null', resolved); + } + } catch (e: unknown) { + if (isErrnoException(e) && e.code === 'ENOENT') { + bwrapArgs.push('--symlink', '/dev/null', resolved); + } else { + debugLogger.warn( + `Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`, + ); + bwrapArgs.push('--ro-bind', '/dev/null', resolved); + } + } + } + const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); @@ -153,142 +339,4 @@ export class LinuxSandboxManager implements SandboxManager { cwd: req.cwd, }; } - - /** - * Generates arguments for network isolation. - */ - private getNetworkArgs(req: SandboxRequest): string[] { - return req.policy?.networkAccess - ? [ - '--unshare-user', - '--unshare-ipc', - '--unshare-pid', - '--unshare-uts', - '--unshare-cgroup', - ] - : ['--unshare-all']; - } - - /** - * Generates the base bubblewrap arguments for isolation. - */ - private getBaseArgs(): string[] { - return [ - '--new-session', // Isolate session - '--die-with-parent', // Prevent orphaned runaway processes - '--ro-bind', - '/', - '/', - '--dev', // Creates a safe, minimal /dev (replaces --dev-bind) - '/dev', - '--proc', // Creates a fresh procfs for the unshared PID namespace - '/proc', - '--tmpfs', // Provides an isolated, writable /tmp directory - '/tmp', - // Note: --dev /dev sets up /dev/pts automatically - '--bind', - this.options.workspace, - this.options.workspace, - ]; - } - - /** - * Generates arguments for protected governance files. - */ - private getGovernanceArgs(): string[] { - const args: string[] = []; - // Protected governance files are bind-mounted as read-only, even if the workspace is RW. - // We ensure they exist on the host and resolve real paths to prevent symlink bypasses. - // In bwrap, later binds override earlier ones for the same path. - for (const file of GOVERNANCE_FILES) { - const filePath = join(this.options.workspace, file.path); - touch(filePath, file.isDirectory); - - const realPath = fs.realpathSync(filePath); - - args.push('--ro-bind', filePath, filePath); - if (realPath !== filePath) { - args.push('--ro-bind', realPath, realPath); - } - } - return args; - } - - /** - * Generates arguments for allowed paths. - */ - private getAllowedPathsArgs(allowedPaths?: string[]): string[] { - const args: string[] = []; - const paths = sanitizePaths(allowedPaths) || []; - const normalizedWorkspace = this.normalizePath(this.options.workspace); - - for (const p of paths) { - if (this.normalizePath(p) !== normalizedWorkspace) { - args.push('--bind-try', p, p); - } - } - return args; - } - - /** - * Generates arguments for forbidden paths. - */ - private async getForbiddenPathsArgs( - forbiddenPaths?: string[], - ): Promise { - const args: string[] = []; - const paths = sanitizePaths(forbiddenPaths) || []; - - for (const p of paths) { - try { - const originalPath = this.normalizePath(p); - const resolvedPath = await tryRealpath(originalPath); - - // Mask the resolved path to prevent access to the underlying file. - const resolvedMask = await this.getMaskArgs(resolvedPath); - args.push(...resolvedMask); - - // If the original path was a symlink, mask it as well to prevent access - // through the link itself. - if (resolvedPath !== originalPath) { - const originalMask = await this.getMaskArgs(originalPath); - args.push(...originalMask); - } - } catch (e) { - throw new Error( - `Failed to deny access to forbidden path: ${p}. ${ - e instanceof Error ? e.message : String(e) - }`, - ); - } - } - return args; - } - - /** - * Generates bubblewrap arguments to mask a forbidden path. - */ - private async getMaskArgs(path: string): Promise { - try { - const stats = await fs.promises.stat(path); - - if (stats.isDirectory()) { - // Directories are masked by mounting an empty, read-only tmpfs. - return ['--tmpfs', path, '--remount-ro', path]; - } - // Existing files are masked by binding them to /dev/null. - return ['--ro-bind-try', '/dev/null', path]; - } catch (e) { - if (isNodeError(e) && e.code === 'ENOENT') { - // Non-existent paths are masked by a broken symlink. This prevents - // creation within the sandbox while avoiding host remnants. - return ['--symlink', '/.forbidden', path]; - } - throw e; - } - } - - private normalizePath(p: string): string { - return normalize(p).replace(/\/$/, ''); - } } diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 0c7e83ecfe..3f23a22553 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -38,7 +38,7 @@ describe('MacOsSandboxManager', () => { manager = new MacOsSandboxManager({ workspace: mockWorkspace }); // Mock the seatbelt args builder to isolate manager tests - vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([ + vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([ '-p', '(mock profile)', '-D', diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index c767c18b82..db2768d7c6 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -24,8 +24,9 @@ import { isKnownSafeCommand, isDangerousCommand, isStrictlyApproved, -} from './commandSafety.js'; +} from '../utils/commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { verifySandboxOverrides } from '../utils/commandUtils.js'; export interface MacOsSandboxOptions extends GlobalSandboxOptions { /** The current sandbox mode behavior from config. */ @@ -70,17 +71,7 @@ export class MacOsSandboxManager implements SandboxManager { const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; // Reject override attempts in plan mode - if (!allowOverrides && req.policy?.additionalPermissions) { - const perms = req.policy.additionalPermissions; - if ( - perms.network || - (perms.fileSystem?.write && perms.fileSystem.write.length > 0) - ) { - throw new Error( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', - ); - } - } + verifySandboxOverrides(allowOverrides, req.policy); // If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes const isApproved = allowOverrides @@ -120,7 +111,7 @@ export class MacOsSandboxManager implements SandboxManager { false, }; - const sandboxArgs = await buildSeatbeltArgs({ + const sandboxArgs = buildSeatbeltArgs({ workspace: this.options.workspace, allowedPaths: [...(req.policy?.allowedPaths || [])], forbiddenPaths: req.policy?.forbiddenPaths, diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index dd2c95235e..fcab494059 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -3,25 +3,31 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; -import * as sandboxManager from '../../services/sandboxManager.js'; +import * as fsUtils from '../utils/fsUtils.js'; import fs from 'node:fs'; import os from 'node:os'; +vi.mock('../utils/fsUtils.js', async () => { + const actual = await vi.importActual('../utils/fsUtils.js'); + return { + ...actual, + tryRealpath: vi.fn((p) => p), + resolveGitWorktreePaths: vi.fn(() => ({})), + }; +}); + describe('seatbeltArgsBuilder', () => { - beforeEach(() => { + afterEach(() => { vi.restoreAllMocks(); }); describe('buildSeatbeltArgs', () => { - it('should build a strict allowlist profile allowing the workspace via param', async () => { - // Mock tryRealpath to just return the path for testing - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should build a strict allowlist profile allowing the workspace via param', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace', }); @@ -38,11 +44,9 @@ describe('seatbeltArgsBuilder', () => { expect(args).toContain(`TMPDIR=${os.tmpdir()}`); }); - it('should allow network when networkAccess is true', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); - const args = await buildSeatbeltArgs({ + it('should allow network when networkAccess is true', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); + const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true, }); @@ -51,10 +55,8 @@ describe('seatbeltArgsBuilder', () => { }); describe('governance files', () => { - it('should inject explicit deny rules for governance files', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + it('should inject explicit deny rules for governance files', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p.toString()); vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'lstatSync').mockImplementation( (p) => @@ -64,35 +66,29 @@ describe('seatbeltArgsBuilder', () => { }) as unknown as fs.Stats, ); - const args = await buildSeatbeltArgs({ - workspace: '/Users/test/workspace', + const args = buildSeatbeltArgs({ + workspace: '/test/workspace', }); const profile = args[1]; - // .gitignore should be a literal deny expect(args).toContain('-D'); - expect(args).toContain( - 'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore', - ); + expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore'); expect(profile).toContain( '(deny file-write* (literal (param "GOVERNANCE_FILE_0")))', ); - // .git should be a subpath deny - expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git'); + expect(args).toContain('GOVERNANCE_FILE_2=/test/workspace/.git'); expect(profile).toContain( '(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))', ); }); - it('should protect both the symlink and the real path if they differ', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/workspace/.gitignore') - return '/test/real/.gitignore'; - return p.toString(); - }, - ); + it('should protect both the symlink and the real path if they differ', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/workspace/.gitignore') + return '/test/real/.gitignore'; + return p.toString(); + }); vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'lstatSync').mockImplementation( () => @@ -102,7 +98,7 @@ describe('seatbeltArgsBuilder', () => { }) as unknown as fs.Stats, ); - const args = await buildSeatbeltArgs({ workspace: '/test/workspace' }); + const args = buildSeatbeltArgs({ workspace: '/test/workspace' }); const profile = args[1]; expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore'); @@ -117,15 +113,13 @@ describe('seatbeltArgsBuilder', () => { }); describe('allowedPaths', () => { - it('should parameterize allowed paths and normalize them', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/symlink') return '/test/real_path'; - return p; - }, - ); + it('should parameterize allowed paths and normalize them', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/symlink') return '/test/real_path'; + return p; + }); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', allowedPaths: ['/custom/path1', '/test/symlink'], }); @@ -141,12 +135,10 @@ describe('seatbeltArgsBuilder', () => { }); describe('forbiddenPaths', () => { - it('should parameterize forbidden paths and explicitly deny them', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should parameterize forbidden paths and explicitly deny them', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/secret/path'], }); @@ -161,22 +153,21 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('resolves forbidden symlink paths to their real paths', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/symlink') return '/test/real_path'; - return p; - }, - ); + it('resolves forbidden symlink paths to their real paths', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/symlink' || p === '/test/missing-dir') { + return '/test/real_path'; + } + return p; + }); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/test/symlink'], }); const profile = args[1]; - // The builder should resolve the symlink and explicitly deny the real target path expect(args).toContain('-D'); expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path'); expect(profile).toContain( @@ -184,12 +175,10 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('explicitly denies non-existent forbidden paths to prevent creation', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('explicitly denies non-existent forbidden paths to prevent creation', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/test/missing-dir/missing-file.txt'], }); @@ -205,12 +194,10 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('should override allowed paths if a path is also in forbidden paths', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should override allowed paths if a path is also in forbidden paths', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', allowedPaths: ['/custom/path1'], forbiddenPaths: ['/custom/path1'], @@ -226,8 +213,6 @@ describe('seatbeltArgsBuilder', () => { expect(profile).toContain(allowString); expect(profile).toContain(denyString); - // Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string - // Seatbelt rules are evaluated in order where the latest rule matching a path wins const allowIndex = profile.indexOf(allowString); const denyIndex = profile.indexOf(denyString); expect(denyIndex).toBeGreaterThan(allowIndex); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index f72229b5cc..cfdcee1687 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -15,8 +15,8 @@ import { type SandboxPermissions, sanitizePaths, GOVERNANCE_FILES, - tryRealpath, } from '../../services/sandboxManager.js'; +import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js'; /** * Options for building macOS Seatbelt arguments. @@ -44,13 +44,11 @@ export interface SeatbeltArgsOptions { * Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '', '-D', ...]) * Does not include the final '--' separator or the command to run. */ -export async function buildSeatbeltArgs( - options: SeatbeltArgsOptions, -): Promise { +export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { let profile = BASE_SEATBELT_PROFILE + '\n'; const args: string[] = []; - const workspacePath = await tryRealpath(options.workspace); + const workspacePath = tryRealpath(options.workspace); args.push('-D', `WORKSPACE=${workspacePath}`); args.push('-D', `WORKSPACE_RAW=${options.workspace}`); profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`; @@ -67,7 +65,7 @@ export async function buildSeatbeltArgs( // (Seatbelt evaluates rules in order, later rules win for same path). for (let i = 0; i < GOVERNANCE_FILES.length; i++) { const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path); - const realGovernanceFile = await tryRealpath(governanceFile); + const realGovernanceFile = tryRealpath(governanceFile); // Determine if it should be treated as a directory (subpath) or a file (literal). // .git is generally a directory, while ignore files are literals. @@ -92,42 +90,20 @@ export async function buildSeatbeltArgs( } // Auto-detect and support git worktrees by granting read and write access to the underlying git directory - try { - const gitPath = path.join(workspacePath, '.git'); - const gitStat = fs.lstatSync(gitPath); - if (gitStat.isFile()) { - const gitContent = fs.readFileSync(gitPath, 'utf8'); - const match = gitContent.match(/^gitdir:\s*(.+)$/m); - if (match && match[1]) { - let worktreeGitDir = match[1].trim(); - if (!path.isAbsolute(worktreeGitDir)) { - worktreeGitDir = path.resolve(workspacePath, worktreeGitDir); - } - const resolvedWorktreeGitDir = await tryRealpath(worktreeGitDir); - - // Grant write access to the worktree's specific .git directory - args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`); - profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`; - - // Grant write access to the main repository's .git directory (objects, refs, etc. are shared) - // resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name - const mainGitDir = await tryRealpath( - path.dirname(path.dirname(resolvedWorktreeGitDir)), - ); - if (mainGitDir && mainGitDir.endsWith('.git')) { - args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`); - profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`; - } - } - } - } catch (_e) { - // Ignore if .git doesn't exist, isn't readable, etc. + const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath); + if (worktreeGitDir) { + args.push('-D', `WORKTREE_GIT_DIR=${worktreeGitDir}`); + profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`; + } + if (mainGitDir) { + args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`); + profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`; } - const tmpPath = await tryRealpath(os.tmpdir()); + const tmpPath = tryRealpath(os.tmpdir()); args.push('-D', `TMPDIR=${tmpPath}`); - const nodeRootPath = await tryRealpath( + const nodeRootPath = tryRealpath( path.dirname(path.dirname(process.execPath)), ); args.push('-D', `NODE_ROOT=${nodeRootPath}`); @@ -142,7 +118,7 @@ export async function buildSeatbeltArgs( for (const p of paths) { if (!p.trim()) continue; try { - let resolved = await tryRealpath(p); + let resolved = tryRealpath(p); // If this is a 'bin' directory (like /usr/local/bin or homebrew/bin), // also grant read access to its parent directory so that symlinked @@ -165,8 +141,10 @@ export async function buildSeatbeltArgs( // Handle allowedPaths const allowedPaths = sanitizePaths(options.allowedPaths) || []; + const resolvedAllowedPaths: string[] = []; for (let i = 0; i < allowedPaths.length; i++) { - const allowedPath = await tryRealpath(allowedPaths[i]); + const allowedPath = tryRealpath(allowedPaths[i]); + resolvedAllowedPaths.push(allowedPath); args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`); profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`; } @@ -176,7 +154,7 @@ export async function buildSeatbeltArgs( const { read, write } = options.additionalPermissions.fileSystem; if (read) { for (let i = 0; i < read.length; i++) { - const resolved = await tryRealpath(read[i]); + const resolved = tryRealpath(read[i]); const paramName = `ADDITIONAL_READ_${i}`; args.push('-D', `${paramName}=${resolved}`); let isFile = false; @@ -194,7 +172,7 @@ export async function buildSeatbeltArgs( } if (write) { for (let i = 0; i < write.length; i++) { - const resolved = await tryRealpath(write[i]); + const resolved = tryRealpath(write[i]); const paramName = `ADDITIONAL_WRITE_${i}`; args.push('-D', `${paramName}=${resolved}`); let isFile = false; @@ -215,7 +193,7 @@ export async function buildSeatbeltArgs( // Handle forbiddenPaths const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || []; for (let i = 0; i < forbiddenPaths.length; i++) { - const forbiddenPath = await tryRealpath(forbiddenPaths[i]); + const forbiddenPath = tryRealpath(forbiddenPaths[i]); args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`); profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`; } diff --git a/packages/core/src/sandbox/macos/commandSafety.ts b/packages/core/src/sandbox/utils/commandSafety.ts similarity index 100% rename from packages/core/src/sandbox/macos/commandSafety.ts rename to packages/core/src/sandbox/utils/commandSafety.ts diff --git a/packages/core/src/sandbox/utils/commandUtils.ts b/packages/core/src/sandbox/utils/commandUtils.ts new file mode 100644 index 0000000000..772df65afa --- /dev/null +++ b/packages/core/src/sandbox/utils/commandUtils.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type SandboxRequest } from '../../services/sandboxManager.js'; +import { + getCommandRoots, + initializeShellParsers, + splitCommands, + stripShellWrapper, +} from '../../utils/shell-utils.js'; +import { isKnownSafeCommand } from './commandSafety.js'; +import { parse as shellParse } from 'shell-quote'; +import path from 'node:path'; + +export async function isStrictlyApproved( + req: SandboxRequest, + approvedTools?: string[], +): Promise { + if (!approvedTools || approvedTools.length === 0) { + return false; + } + + await initializeShellParsers(); + + const fullCmd = [req.command, ...req.args].join(' '); + const stripped = stripShellWrapper(fullCmd); + + const roots = getCommandRoots(stripped); + if (roots.length === 0) return false; + + const allRootsApproved = roots.every((root) => approvedTools.includes(root)); + if (allRootsApproved) { + return true; + } + + const pipelineCommands = splitCommands(stripped); + if (pipelineCommands.length === 0) return false; + + for (const cmdString of pipelineCommands) { + const parsedArgs = shellParse(cmdString).map(String); + if (!isKnownSafeCommand(parsedArgs)) { + return false; + } + } + + return true; +} + +export async function getCommandName(req: SandboxRequest): Promise { + await initializeShellParsers(); + const fullCmd = [req.command, ...req.args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + if (roots.length > 0) { + return roots[0]; + } + return path.basename(req.command); +} + +export function verifySandboxOverrides( + allowOverrides: boolean, + policy: SandboxRequest['policy'], +) { + if (!allowOverrides) { + if ( + policy?.networkAccess || + policy?.allowedPaths?.length || + policy?.additionalPermissions?.network || + policy?.additionalPermissions?.fileSystem?.read?.length || + policy?.additionalPermissions?.fileSystem?.write?.length + ) { + throw new Error( + 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', + ); + } + } +} diff --git a/packages/core/src/sandbox/utils/fsUtils.ts b/packages/core/src/sandbox/utils/fsUtils.ts new file mode 100644 index 0000000000..f7fafd4c59 --- /dev/null +++ b/packages/core/src/sandbox/utils/fsUtils.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export function isErrnoException(e: unknown): e is NodeJS.ErrnoException { + return e instanceof Error && 'code' in e; +} + +export function tryRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch (_e) { + if (isErrnoException(_e) && _e.code === 'ENOENT') { + const parentDir = path.dirname(p); + if (parentDir === p) { + return p; + } + return path.join(tryRealpath(parentDir), path.basename(p)); + } + throw _e; + } +} + +export function resolveGitWorktreePaths(workspacePath: string): { + worktreeGitDir?: string; + mainGitDir?: string; +} { + try { + const gitPath = path.join(workspacePath, '.git'); + const gitStat = fs.lstatSync(gitPath); + if (gitStat.isFile()) { + const gitContent = fs.readFileSync(gitPath, 'utf8'); + const match = gitContent.match(/^gitdir:\s+(.+)$/m); + if (match && match[1]) { + let worktreeGitDir = match[1].trim(); + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(workspacePath, worktreeGitDir); + } + const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir); + + // Security check: Verify the bidirectional link to prevent sandbox escape + let isValid = false; + try { + const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir'); + const backlink = fs.readFileSync(backlinkPath, 'utf8').trim(); + // The backlink must resolve to the workspace's .git file + if (tryRealpath(backlink) === tryRealpath(gitPath)) { + isValid = true; + } + } catch (_e) { + // Fallback for submodules: check core.worktree in config + try { + const configPath = path.join(resolvedWorktreeGitDir, 'config'); + const config = fs.readFileSync(configPath, 'utf8'); + const match = config.match(/^\s*worktree\s*=\s*(.+)$/m); + if (match && match[1]) { + const worktreePath = path.resolve( + resolvedWorktreeGitDir, + match[1].trim(), + ); + if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) { + isValid = true; + } + } + } catch (_e2) { + // Ignore + } + } + + if (!isValid) { + return {}; // Reject: valid worktrees/submodules must have a readable backlink + } + + const mainGitDir = tryRealpath( + path.dirname(path.dirname(resolvedWorktreeGitDir)), + ); + return { + worktreeGitDir: resolvedWorktreeGitDir, + mainGitDir: mainGitDir.endsWith('.git') ? mainGitDir : undefined, + }; + } + } + } catch (_e) { + // Ignore if .git doesn't exist, isn't readable, etc. + } + return {}; +} diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 8f9b9d617c..2c7e08a730 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -111,7 +111,7 @@ describe('WindowsSandboxManager', () => { }; await expect(planManager.prepareCommand(req)).rejects.toThrow( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', + 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', ); }); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 0a5d08637c..a213d7b619 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -31,6 +31,7 @@ import { isStrictlyApproved, } from './commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { verifySandboxOverrides } from '../utils/commandUtils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -214,17 +215,7 @@ export class WindowsSandboxManager implements SandboxManager { const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; // Reject override attempts in plan mode - if (!allowOverrides && req.policy?.additionalPermissions) { - const perms = req.policy.additionalPermissions; - if ( - perms.network || - (perms.fileSystem?.write && perms.fileSystem.write.length > 0) - ) { - throw new Error( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', - ); - } - } + verifySandboxOverrides(allowOverrides, req.policy); // Fetch persistent approvals for this command const commandName = await getCommandName(req.command, req.args); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 0e282b0748..ea18e5857d 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { isKnownSafeCommand as isMacSafeCommand, isDangerousCommand as isMacDangerousCommand, -} from '../sandbox/macos/commandSafety.js'; +} from '../sandbox/utils/commandSafety.js'; import { isKnownSafeCommand as isWindowsSafeCommand, isDangerousCommand as isWindowsDangerousCommand, diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index bb8cea4752..6e09ab135f 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -42,7 +42,11 @@ export function createSandboxManager( policyManager, }); } else if (os.platform() === 'linux') { - return new LinuxSandboxManager({ workspace }); + return new LinuxSandboxManager({ + workspace, + modeConfig, + policyManager, + }); } else if (os.platform() === 'darwin') { return new MacOsSandboxManager({ workspace, diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index 81f9eb09a4..63aa4628fb 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -354,4 +354,13 @@ describe('getErrorType', () => { expect(getErrorType(null)).toBe('unknown'); expect(getErrorType(undefined)).toBe('unknown'); }); + + it('should strip leading underscores from error names', () => { + class _GaxiosError extends Error {} + expect(getErrorType(new _GaxiosError('test'))).toBe('GaxiosError'); + + const errorWithUnderscoreName = new Error('test'); + errorWithUnderscoreName.name = '_CodeBuddyError'; + expect(getErrorType(errorWithUnderscoreName)).toBe('CodeBuddyError'); + }); }); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index a390abcdc4..834d1e4586 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -58,9 +58,12 @@ export function getErrorType(error: unknown): string { if (!(error instanceof Error)) return 'unknown'; // Return constructor name if the generic 'Error' name is used (for custom errors) - return error.name === 'Error' - ? (error.constructor?.name ?? 'Error') - : error.name; + const name = + error.name === 'Error' ? (error.constructor?.name ?? 'Error') : error.name; + + // Strip leading underscore from error names. Bundlers like esbuild sometimes + // rename classes to avoid scope collisions. + return name.replace(/^_+/, ''); } export class FatalError extends Error { diff --git a/scripts/harvest_api_reliability.sh b/scripts/harvest_api_reliability.sh new file mode 100755 index 0000000000..140063b8ea --- /dev/null +++ b/scripts/harvest_api_reliability.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# Gemini API Reliability Harvester +# ------------------------------- +# This script gathers data about 500 API errors encountered during evaluation runs +# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused +# by transient API failures. +# +# Usage: +# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH] +# +# Examples: +# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches +# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500 +# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch +# +# Prerequisites: +# - GitHub CLI (gh) installed and authenticated (`gh auth login`) +# - jq installed + +# Arguments & Defaults +if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + SINCE="$1" +elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then + DAYS="${BASH_REMATCH[1]}" + if [[ "$OSTYPE" == "darwin"* ]]; then + SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d) + else + SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d) + fi +else + # Default to 7 days ago in YYYY-MM-DD format (UTC) + if [[ "$OSTYPE" == "darwin"* ]]; then + SINCE=$(date -u -v-7d +%Y-%m-%d) + else + SINCE=$(date -u -d "7 days ago" +%Y-%m-%d) + fi +fi + +LIMIT=${2:-300} +BRANCH=${3:-""} +WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly") +DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX) +MERGED_FILE="api-reliability-summary.jsonl" + +# Ensure cleanup on exit +trap 'rm -rf "$DEST_DIR"' EXIT + +if ! command -v gh &> /dev/null; then + echo "āŒ Error: GitHub CLI (gh) is not installed." + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "āŒ Error: jq is not installed." + exit 1 +fi + +# Clean start +rm -f "$MERGED_FILE" + +# gh run list --created expects a date (YYYY-MM-DD) or a range +CREATED_QUERY=">=$SINCE" + +for WORKFLOW in "${WORKFLOWS[@]}"; do + echo "šŸ” Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..." + + # Construct arguments for gh run list + GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId") + if [ -n "$BRANCH" ]; then + GH_ARGS+=("--branch" "$BRANCH") + fi + + RUN_IDS=$(gh run list "${GH_ARGS[@]}") + exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "āŒ Failed to fetch runs for '$WORKFLOW' (exit code: $exit_code). Please check 'gh auth status' and permissions." >&2 + continue + fi + + if [ -z "$RUN_IDS" ]; then + echo "šŸ“­ No runs found for workflow '$WORKFLOW' since $SINCE." + continue + fi + + for ID in $RUN_IDS; do + # Download artifacts named 'eval-logs-*' + # Silencing output because many older runs won't have artifacts + gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue + + # Append to master log + # Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID + find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null + done +done + +if [ ! -f "$MERGED_FILE" ]; then + echo "šŸ“­ No reliability data found in the retrieved logs." + exit 0 +fi + +echo -e "\nāœ… Harvest Complete! Data merged into: $MERGED_FILE" +echo "------------------------------------------------" +echo "šŸ“Š Gemini API Reliability Summary (Since $SINCE)" +echo "------------------------------------------------" + +cat "$MERGED_FILE" | jq -s ' + group_by(.model) | map({ + model: .[0].model, + "500s": (map(select(.errorCode == "500")) | length), + "503s": (map(select(.errorCode == "503")) | length), + retries: (map(select(.status == "RETRY")) | length), + skips: (map(select(.status == "SKIP")) | length) + })' + +echo -e "\nšŸ’” Total events captured: $(wc -l < "$MERGED_FILE")"