Compare commits

..

9 Commits

Author SHA1 Message Date
davidapierce ac818594b9 refactor(core): simplify history scrubbing by removing redundant calls 2026-08-04 00:05:52 +00:00
davidapierce 3cf18c822f fix(core,cli): resolve context corruption and quota error fallback issues 2026-08-03 22:21:04 +00:00
amelidev ac42fb0a24 fix(cli): fall back to embedded macOS seatbelt profiles if missing (#28551) 2026-08-03 19:31:48 +00:00
David Pierce f47d6c6f7a fix(core,cli): propagate InvalidStreamError details to UI for specific empty response guidance (#28566)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-07-31 17:55:10 +00:00
luisfelipe-alt d55e366f6a fix(core): classify capacity exhaustion as terminal to prevent retry hangs (#28599) 2026-07-30 17:59:01 +00:00
gemini-cli-robot dc859e8e48 chore/release: bump version to 0.55.0-nightly.20260729.g3499c84f7 (#28573) 2026-07-29 18:55:47 +00:00
gemini-cli-robot 4bb7e93c45 Changelog for v0.53.0 (#28568)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:53 +00:00
gemini-cli-robot 55a31ef909 Changelog for v0.54.0-preview.0 (#28567)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-07-29 18:54:50 +00:00
gemini-cli-robot 3499c84f7b chore(release): bump version to 0.55.0-nightly.20260728.gd29268d36 (#28569) 2026-07-28 22:10:39 +00:00
25 changed files with 1162 additions and 940 deletions
+17
View File
@@ -18,6 +18,23 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.53.0 - 2026-07-28
- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and
container build setup
([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by
@chadd28).
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports
([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015).
- **Security & Loop Mitigations:** Enforced workspace trust and task isolation
in the A2A server, aligned macOS Seatbelt profiles with the deny-default
model, and mitigated infinite ReAct/prompt injection loops
([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by
@luisfelipe-alt,
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by
@ompatel-aiml).
## Announcements: v0.52.0 - 2026-07-22
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
+31 -52
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.52.0
# Latest stable release: v0.53.0
Released: July 22, 2026
Released: July 28, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,59 +11,38 @@ npm install -g @google/gemini-cli
## Highlights
- **Caretaker Services:** Introduced a new caretaker triage worker including
core foundational modules, main worker execution loops, egress action
publishers, and octokit GitHub Action handlers.
- **Robust File Editing:** Core tools like `write_file` and `replace` now bypass
LLM corrections for JSON and IPYNB files to ensure accurate and direct file
modifications.
- **Plan Mode Improvements:** Simplified plan mode write policies to natively
support writing to relative paths, enhancing project directory navigation.
- **Enhanced Account Visibility:** Improved clear user-facing messages when the
user account does not have a Code Assist tier, and enriched shared project
quota limit errors with setup instructions.
- **Caretaker Triage Orchestrator:** Implemented an LLM triage orchestrator and
container build setup to manage automated caretakers.
- **Eval Coverage Reporting:** Introduced a new command for generating
evaluation coverage reports to track agent decision logic and testing.
- **Security and Sandboxing:** Aligned macOS permissive Seatbelt profiles with
the deny-default model, and enforced workspace trust with task isolation in
the A2A server.
- **Robust Conversation Loops:** Coalesced consecutive message roles and grouped
cancelled tool responses to avoid Bad Request errors, and mitigated infinite
ReAct loops and prompt injection vulnerabilities.
## What's Changed
- Refactor: exclude transient CI configuration files from workspace context by
@DavidAPierce in
[#28216](https://github.com/google-gemini/gemini-cli/pull/28216)
- feat(caretaker-triage): add triage worker core foundational modules by
@chadd28 in [#28163](https://github.com/google-gemini/gemini-cli/pull/28163)
- feat(caretaker-egress): implement octokit github action handler for egress
service by @chadd28 in
[#28303](https://github.com/google-gemini/gemini-cli/pull/28303)
- chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 by
@gemini-cli-robot in
[#28323](https://github.com/google-gemini/gemini-cli/pull/28323)
- Changelog for v0.51.0-preview.0 by @gemini-cli-robot in
[#28320](https://github.com/google-gemini/gemini-cli/pull/28320)
- Changelog for v0.50.0 by @gemini-cli-robot in
[#28322](https://github.com/google-gemini/gemini-cli/pull/28322)
- fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file
and replace by @amelidev in
[#28223](https://github.com/google-gemini/gemini-cli/pull/28223)
- fix(core): use unambiguous previous intent label in fallback summary by
@amelidev in [#28343](https://github.com/google-gemini/gemini-cli/pull/28343)
- feat(caretaker-triage): implement main worker execution loop and egress action
publisher by @chadd28 in
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306)
- fix(privacy): show a clear message when the account has no Code Assist tier by
@ompatel-aiml in
[#28304](https://github.com/google-gemini/gemini-cli/pull/28304)
- fix(core): enrich shared project quota limit errors with setup hint by
@amelidev in [#28391](https://github.com/google-gemini/gemini-cli/pull/28391)
- fix(a2a-server): ensure task cancellation aborts execution loop by
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
to prevent 400 Bad Request by @luisfelipe-alt in
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
- feat(caretaker-triage): implement LLM triage orchestrator and container build
by @chadd28 in
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
model by @ompatel-aiml in
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
@luisfelipe-alt in
[#28316](https://github.com/google-gemini/gemini-cli/pull/28316)
- fix(core): simplify plan mode write policy to support relative paths by
@DavidAPierce in
[#28398](https://github.com/google-gemini/gemini-cli/pull/28398)
- feat(core): Bump node google-auth-library version to 10.9.0 by @jerrylin3321
in [#28385](https://github.com/google-gemini/gemini-cli/pull/28385)
- chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b by
@gemini-cli-robot in
[#28402](https://github.com/google-gemini/gemini-cli/pull/28402)
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
- fix(core): sequentially verify cached credentials and restore
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
- feat(evals): add eval coverage report command by @ved015 in
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.51.0...v0.52.0
https://github.com/google-gemini/gemini-cli/compare/v0.52.0...v0.53.0
+52 -35
View File
@@ -1,6 +1,6 @@
# Preview release: v0.53.0-preview.0
# Preview release: v0.54.0-preview.0
Released: July 22, 2026
Released: July 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,42 +13,59 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Caretaker LLM Triage Orchestrator**: Implemented the LLM triage orchestrator
and container build configuration to support caretaker triage workflows.
- **Enhanced Workspace Trust & Sandbox Hardening**: Aligned macOS permissive
Seatbelt profiles with the deny-default model and enforced workspace trust and
task isolation in the Agent-to-Agent (A2A) server to prevent remote code
execution (RCE).
- **Core Robustness & API Protections**: Mitigated infinite ReAct and prompt
injection loops, and prevented 400 Bad Request errors by grouping cancelled
tool responses and coalescing consecutive roles.
- **Robust Credentials & Fallbacks**: Restored the
`GOOGLE_APPLICATION_CREDENTIALS` environment variable fallback and
sequentially verified cached credentials.
- **Evaluation Coverage Reporting**: Added a new command to generate
comprehensive eval coverage reports.
- **Antigravity Agent & PR Generator:** Integrated the Antigravity agent runner,
Firestore dual-locking for concurrency, prompt templates, and ingestion
testing utilities.
- **Caretaker Triage & Issue Management:** Enhanced the issue triage workflow by
automatically posting a comment before closing issues, and sanitizing and
wrapping issue titles in `untrusted_context`.
- **Core API & Session Stability:** Enforced HTTPS for
GoogleCredentialsAuthProvider to prevent cleartext leakage, rotated session
IDs on model fallback to prevent stateful API errors, and refined chat history
by filtering out thought parts when context management is disabled.
## What's Changed
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
to prevent 400 Bad Request by @luisfelipe-alt in
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
- feat(caretaker-triage): implement LLM triage orchestrator and container build
by @chadd28 in
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
model by @ompatel-aiml in
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
- Changelog for v0.52.0 by @gemini-cli-robot in
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 by
@gemini-cli-robot in
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
- fix(core): rotate session ID on model fallback to prevent stateful API errors
by @amelidev in
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
cleartext leakage by @amelidev in
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
- fix(core): filter out thought parts from getHistoryTurns when context
management is disabled by @DavidAPierce in
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
@luisfelipe-alt in
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
- fix(core): sequentially verify cached credentials and restore
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
- feat(evals): add eval coverage report command by @ved015 in
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
- fix(core): enforce explicit tag length and validation in file keychain by
@luisfelipe-alt in
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
@gemini-cli-robot in
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
ingestion utilities by @joneba-google in
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
templates … by @joneba-google in
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
- fix(core): skip merged function-response turns when finding the active loop by
@adamfweidman in
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.52.0-preview.0...v0.53.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.54.0-preview.0
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"workspaces": [
"packages/*"
],
@@ -17782,7 +17782,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
@@ -18242,7 +18242,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -18458,7 +18458,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -19131,7 +19131,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"dependencies": {
"ws": "8.16.0"
@@ -19167,7 +19167,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19506,7 +19506,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19524,7 +19524,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.2",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-nightly.20260729.g3499c84f7"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-nightly.20260729.g3499c84f7"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -103,7 +103,10 @@ vi.mock('../utils.js', () => ({
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
const validationParser = yargs([])
.locale('en')
.command(installCommand)
.fail(false);
expect(() => validationParser.parse('install')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
@@ -27,7 +27,10 @@ vi.mock('../utils.js', () => ({
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([]).command(validateCommand).fail(false);
const validationParser = yargs([])
.locale('en')
.command(validateCommand)
.fail(false);
expect(() => validationParser.parse('validate')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
+1 -1
View File
@@ -17,7 +17,7 @@ describe('mcp command', () => {
});
it('should show help when no subcommand is provided', async () => {
const yargsInstance = yargs();
const yargsInstance = yargs().locale('en');
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
const parser = yargsInstance.command(mcpCommand).help();
+104 -101
View File
@@ -1046,107 +1046,6 @@ describe('useGeminiStream', () => {
});
});
it('should record tool responses in history when the model was switched due to a quota error', async () => {
// Regression test: returning early on a quota-triggered model switch
// without recording the responses leaves the already-recorded
// functionCall unpaired, which corrupts all subsequent requests.
const responseParts: Part[] = [
{
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'tool result' },
},
},
];
const completedToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'testTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-quota',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts,
errorType: undefined,
},
tool: { displayName: 'MockTool' },
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database');
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
true, // modelSwitchedFromQuotaError
() => {},
() => {},
() => {},
80,
24,
false,
mockConsumeUserHint,
),
);
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
// The tool response must be paired with its functionCall in history,
// with no steering-hint text ahead of it...
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: responseParts,
});
// ...the turn must NOT auto-continue on the fallback model...
expect(mockSendMessageStream).not.toHaveBeenCalled();
// ...and the pending hint is left for the next real submit.
expect(mockConsumeUserHint).not.toHaveBeenCalled();
});
});
it('should NOT stop responding when only update_topic is called', async () => {
const topicToolCalls: TrackedToolCall[] = [
{
@@ -4461,4 +4360,108 @@ describe('useGeminiStream', () => {
});
expect(spanMetadata.input).toBe('telemetry test query');
});
describe('Quota Error fallback', () => {
it('should add tool responses to geminiClient history if modelSwitchedFromQuotaError is true', async () => {
const completedToolCalls: TrackedCompletedToolCall[] = [
{
request: {
callId: 'call1',
name: 'tool1',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts: [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { success: true },
},
},
],
errorType: undefined,
},
tool: {
name: 'tool1',
displayName: 'tool1',
description: 'desc1',
build: vi.fn(),
isOutputMarkdown: false,
} as any,
invocation: {
getDescription: () => 'desc1',
} as any,
} as unknown as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
true, // modelSwitchedFromQuotaError is true
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with completed tools
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { success: true },
},
},
],
});
});
});
});
});
+17 -21
View File
@@ -2110,27 +2110,6 @@ export const useGeminiStream = (
(toolCall) => toolCall.response.responseParts,
);
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error, but still
// record the responses: the matching functionCall is already in history,
// and leaving it unpaired corrupts every subsequent request. Any pending
// steering hint is deliberately left unconsumed so it rides along with
// the next query the user actually submits.
if (modelSwitchedFromQuotaError) {
if (geminiClient && responsesToSend.length > 0) {
await geminiClient.addHistory({
role: 'user',
parts: responsesToSend,
});
}
return;
}
if (consumeUserHint) {
const userHint = consumeUserHint();
if (userHint && userHint.trim().length > 0) {
@@ -2141,10 +2120,27 @@ export const useGeminiStream = (
}
}
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
const prompt_ids = geminiTools.map(
(toolCall) => toolCall.request.prompt_id,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error
if (modelSwitchedFromQuotaError) {
if (geminiClient && responsesToSend.length > 0) {
await geminiClient.addHistory({
role: 'user',
parts: responsesToSend,
});
}
return;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
submitQuery(
responsesToSend,
+134
View File
@@ -292,6 +292,140 @@ describe('sandbox', () => {
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should fall back to embedded profile if the .sb file is missing on disk', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
);
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
const onSpy = vi.spyOn(process, 'on');
const offSpy = vi.spyOn(process, 'off');
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
// Verify fs.writeFileSync was called with the temp profile file, content, and 0o600 permissions
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
expect.stringContaining('deny default'),
expect.objectContaining({
encoding: 'utf8',
mode: 0o600,
}),
);
// Verify spawn was called with the temp profile file
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'-f',
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
]),
expect.objectContaining({ stdio: 'inherit' }),
);
// Verify process on/off hooks were called for exit, SIGINT, and SIGTERM cleanups
expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function));
expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
// Verify fs.unlinkSync was called to clean up the temp file
expect(fs.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining(
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
),
);
});
it.each([
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
'strict-open',
'strict-proxied',
])(
'should fall back to embedded content successfully for profile "%s"',
async (profile) => {
vi.mocked(os.platform).mockReturnValue('darwin');
// Mock existsSync to return false for the profile file but true for temp directories
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes('gemini-sandbox-macos-'),
);
vi.stubEnv('SEATBELT_PROFILE', profile);
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config, [], undefined, ['arg1']);
setTimeout(() => {
mockSpawnProcess.emit('close', 0);
}, 10);
await expect(promise).resolves.toBe(0);
// Verify fs.writeFileSync was called with the correct file mode and content for the profile
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining(`gemini-sandbox-macos-${profile}-`),
expect.stringContaining('deny default'),
expect.objectContaining({
encoding: 'utf8',
mode: 0o600,
}),
);
vi.unstubAllEnvs();
},
);
it('should handle Docker execution', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
+212 -149
View File
@@ -39,6 +39,7 @@ import {
SANDBOX_PROXY_NAME,
BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';
import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -56,6 +57,41 @@ export async function start_sandbox(
patcher.patch();
let stopProxy: (() => void) | undefined = undefined;
let tempProfileFile: string | null = null;
const cleanup = () => {
if (tempProfileFile && fs.existsSync(tempProfileFile)) {
try {
fs.unlinkSync(tempProfileFile);
} catch {
// ignore
}
tempProfileFile = null;
}
if (stopProxy) {
try {
stopProxy();
} catch {
// ignore
}
}
};
const sigintHandler = () => {
cleanup();
process.off('SIGINT', sigintHandler);
process.kill(process.pid, 'SIGINT');
};
const sigtermHandler = () => {
cleanup();
process.off('SIGTERM', sigtermHandler);
process.kill(process.pid, 'SIGTERM');
};
process.on('exit', cleanup);
process.on('SIGINT', sigintHandler);
process.on('SIGTERM', sigtermHandler);
try {
if (config.command === 'sandbox-exec') {
@@ -81,161 +117,193 @@ export async function start_sandbox(
profileFile = fs.existsSync(userProfileFile)
? userProfileFile
: projectProfileFile;
}
if (!fs.existsSync(profileFile)) {
throw new FatalSandboxError(
`Missing macos seatbelt profile file '${profileFile}'`,
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
if (cliConfig) {
const workspaceContext = cliConfig.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
// Filter out TARGET_DIR
for (const dir of directories) {
const realDir = fs.realpathSync(dir);
if (realDir !== targetDir) {
includedDirs.push(realDir);
} else {
// For builtin profiles, if the file doesn't exist on disk (e.g. bundled or bazel environments),
// write the embedded profile content to a temporary file.
if (!fs.existsSync(profileFile)) {
const content = BUILTIN_SEATBELT_PROFILE_CONTENTS[profile];
if (content) {
try {
const tempDir = fs.realpathSync(os.tmpdir());
const rand = randomBytes(8).toString('hex');
tempProfileFile = path.join(
tempDir,
`gemini-sandbox-macos-${profile}-${rand}.sb`,
);
fs.writeFileSync(tempProfileFile, content, {
encoding: 'utf8',
mode: 0o600,
});
profileFile = tempProfileFile;
} catch (err) {
debugLogger.warn(
`Failed to write temporary seatbelt profile: ${err}`,
);
}
}
}
}
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (
hostPath &&
path.isAbsolute(hostPath) &&
fs.existsSync(hostPath)
) {
const realDir = fs.realpathSync(hostPath);
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
try {
if (!fs.existsSync(profileFile)) {
throw new FatalSandboxError(
`Missing macos seatbelt profile file '${profileFile}'`,
);
}
debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
// if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
const nodeOptions = [
...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
...nodeArgs,
].join(' ');
const args = [
'-D',
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
'-D',
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
'-D',
`HOME_DIR=${fs.realpathSync(homedir())}`,
'-D',
`CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
];
// Add included directories from the workspace context
// Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
const MAX_INCLUDE_DIRS = 5;
const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
const includedDirs: string[] = [];
if (cliConfig) {
const workspaceContext = cliConfig.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
// Filter out TARGET_DIR
for (const dir of directories) {
const realDir = fs.realpathSync(dir);
if (realDir !== targetDir) {
includedDirs.push(realDir);
}
}
}
}
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
`SANDBOX=sandbox-exec`,
`NODE_OPTIONS="${nodeOptions}"`,
...finalArgv.map((arg) => quote([arg])),
].join(' '),
);
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
let proxyProcess: ChildProcess | undefined = undefined;
let sandboxProcess: ChildProcess | undefined = undefined;
const sandboxEnv = { ...process.env };
if (proxyCommand) {
const proxy =
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'] ||
'http://localhost:8877';
sandboxEnv['HTTPS_PROXY'] = proxy;
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
sandboxEnv['HTTP_PROXY'] = proxy;
sandboxEnv['http_proxy'] = proxy;
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
if (noProxy) {
sandboxEnv['NO_PROXY'] = noProxy;
sandboxEnv['no_proxy'] = noProxy;
}
proxyProcess = spawn(proxyCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
try {
process.kill(-proxyProcess.pid, 'SIGTERM');
} catch {
// ignore
// Add custom allowed paths from config
if (config.allowedPaths) {
for (const hostPath of config.allowedPaths) {
if (
hostPath &&
path.isAbsolute(hostPath) &&
fs.existsSync(hostPath)
) {
const realDir = fs.realpathSync(hostPath);
if (!includedDirs.includes(realDir) && realDir !== targetDir) {
includedDirs.push(realDir);
}
}
}
};
process.on('exit', stopProxy);
process.on('SIGINT', stopProxy);
process.on('SIGTERM', stopProxy);
}
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
let dirPath = '/dev/null'; // Default to a safe path that won't cause issues
if (i < includedDirs.length) {
dirPath = includedDirs[i];
}
throw new FatalSandboxError(
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
args.push(
'-f',
profileFile,
'sh',
'-c',
[
`SANDBOX=sandbox-exec`,
'NODE_OPTIONS=' + quote([nodeOptions]),
...finalArgv.map((arg) => quote([arg])),
].join(' '),
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
return await new Promise((resolve, reject) => {
sandboxProcess?.on('error', reject);
sandboxProcess?.on('close', (code) => {
process.stdin.resume();
resolve(code ?? 1);
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
let proxyProcess: ChildProcess | undefined = undefined;
let sandboxProcess: ChildProcess | undefined = undefined;
const sandboxEnv = { ...process.env };
if (proxyCommand) {
const proxy =
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'] ||
'http://localhost:8877';
sandboxEnv['HTTPS_PROXY'] = proxy;
sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
sandboxEnv['HTTP_PROXY'] = proxy;
sandboxEnv['http_proxy'] = proxy;
const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
if (noProxy) {
sandboxEnv['NO_PROXY'] = noProxy;
sandboxEnv['no_proxy'] = noProxy;
}
proxyProcess = spawn(proxyCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
try {
process.kill(-proxyProcess.pid, 'SIGTERM');
} catch {
// ignore
}
}
};
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
`Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
await execAsync(
`until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
});
return await new Promise((resolve, reject) => {
sandboxProcess?.on('error', (err) => {
cleanup();
reject(err);
});
sandboxProcess?.on('close', (code) => {
process.stdin.resume();
cleanup();
resolve(code ?? 1);
});
});
} catch (err) {
cleanup();
throw err;
}
}
if (config.command === 'lxc') {
@@ -768,9 +836,6 @@ export async function start_sandbox(
// ignore
}
};
process.on('exit', stopProxy);
process.on('SIGINT', stopProxy);
process.on('SIGTERM', stopProxy);
// commented out as it disrupts ink rendering
// proxyProcess.stdout?.on('data', (data) => {
@@ -821,12 +886,10 @@ export async function start_sandbox(
});
});
} finally {
if (stopProxy) {
stopProxy();
process.off('exit', stopProxy);
process.off('SIGINT', stopProxy);
process.off('SIGTERM', stopProxy);
}
process.off('exit', cleanup);
process.off('SIGINT', sigintHandler);
process.off('SIGTERM', sigtermHandler);
cleanup();
patcher.cleanup();
}
}
@@ -0,0 +1,555 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const BUILTIN_SEATBELT_PROFILE_CONTENTS: Record<string, string> = {
'permissive-open': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "*:*"))
(allow network-bind (local ip "*:*"))
(allow network-outbound)`,
'permissive-proxied': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
(literal "/dev/ptmx")
(regex #"^/dev/ttys[0-9]*$")
)
(allow mach-lookup
(global-name "com.apple.sysmond")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.mDNSResponder")
(global-name "com.apple.mDNSResponderHelper")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)
)
)
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-bind (local ip "*:*"))
(allow network-outbound (remote tcp "localhost:8877"))`,
'restrictive-open': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound)`,
'restrictive-proxied': `(version 1)
(deny default)
(allow file-read*)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound (remote tcp "localhost:8877"))`,
'strict-open': `(version 1)
(deny default)
(allow file-read*
(literal "/")
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
(subpath (string-append (param "HOME_DIR") "/.nvm"))
(subpath (string-append (param "HOME_DIR") "/.fnm"))
(subpath (string-append (param "HOME_DIR") "/.node"))
(subpath (string-append (param "HOME_DIR") "/.config"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/Library")
(subpath "/System")
(subpath "/private")
(subpath "/dev")
(subpath "/etc")
(subpath "/opt")
(subpath "/Applications")
)
(allow file-read-metadata)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound)`,
'strict-proxied': `(version 1)
(deny default)
(allow file-read*
(literal "/")
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
(subpath (string-append (param "HOME_DIR") "/.nvm"))
(subpath (string-append (param "HOME_DIR") "/.fnm"))
(subpath (string-append (param "HOME_DIR") "/.node"))
(subpath (string-append (param "HOME_DIR") "/.config"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/Library")
(subpath "/System")
(subpath "/private")
(subpath "/dev")
(subpath "/etc")
(subpath "/opt")
(subpath "/Applications")
)
(allow file-read-metadata)
(allow process-exec)
(allow process-fork)
(allow signal (target self))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.optional.arm.FEAT_BF16")
(sysctl-name "hw.optional.arm.FEAT_DotProd")
(sysctl-name "hw.optional.arm.FEAT_FCMA")
(sysctl-name "hw.optional.arm.FEAT_FHM")
(sysctl-name "hw.optional.arm.FEAT_FP16")
(sysctl-name "hw.optional.arm.FEAT_I8MM")
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
(sysctl-name "hw.optional.arm.FEAT_LSE")
(sysctl-name "hw.optional.arm.FEAT_RDM")
(sysctl-name "hw.optional.arm.FEAT_SHA512")
(sysctl-name "hw.optional.armv8_2_sha512")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name-prefix "hw.perflevel")
)
(allow file-write*
(subpath (param "TARGET_DIR"))
(subpath (param "TMP_DIR"))
(subpath (param "CACHE_DIR"))
(subpath (string-append (param "HOME_DIR") "/.gemini"))
(subpath (string-append (param "HOME_DIR") "/.npm"))
(subpath (string-append (param "HOME_DIR") "/.cache"))
(subpath (param "INCLUDE_DIR_0"))
(subpath (param "INCLUDE_DIR_1"))
(subpath (param "INCLUDE_DIR_2"))
(subpath (param "INCLUDE_DIR_3"))
(subpath (param "INCLUDE_DIR_4"))
(literal "/dev/stdout")
(literal "/dev/stderr")
(literal "/dev/null")
)
(allow mach-lookup (global-name "com.apple.sysmond"))
(allow file-ioctl (regex #"^/dev/tty.*"))
(allow network-inbound (local ip "localhost:9229"))
(allow network-outbound (remote tcp "localhost:8877"))`,
};
// Map standard 'closed' profiles to their strict counterparts for backward compatibility and fallback support
BUILTIN_SEATBELT_PROFILE_CONTENTS['permissive-closed'] =
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-open'];
BUILTIN_SEATBELT_PROFILE_CONTENTS['restrictive-closed'] =
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-proxied'];
+2
View File
@@ -15,8 +15,10 @@ export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
export const BUILTIN_SEATBELT_PROFILES = [
'permissive-open',
'permissive-closed',
'permissive-proxied',
'restrictive-open',
'restrictive-closed',
'restrictive-proxied',
'strict-open',
'strict-proxied',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
-292
View File
@@ -228,16 +228,6 @@ describe('GeminiChat', () => {
// Disable 429 simulation for tests
setSimulate429(false);
// The mid-stream retry loop sleeps on a real timer (1s + 2s + 4s) between
// attempts, which exceeds the default 5s test timeout and silently killed
// every InvalidStreamError test before it reached its assertions. Run those
// delays instantly.
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => {
fn();
return 0;
}) as unknown as typeof globalThis.setTimeout);
// Reset history for each test by creating a new instance
chat = new GeminiChat(mockConfig);
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
@@ -1009,219 +999,6 @@ describe('GeminiChat', () => {
expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
});
it('should not fuse the next user message into a preserved tool-response turn', async () => {
// Regression: when a stream fails mid tool-loop the tool response is
// deliberately preserved (see the test above), which leaves history
// ending on a user turn. The user's next message was then coalesced into
// that same turn as [functionResponse, text]. The model reads the
// trailing text as a continuation of the tool result and completes the
// sentence instead of answering it.
chat.agentHistory.push({
id: 'model-turn-1',
content: {
role: 'model',
parts: [{ functionCall: { name: 'test_tool', args: {} } }],
},
});
// 1. Tool response goes back, model returns nothing -> InvalidStreamError.
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{ content: { role: 'model', parts: [] }, finishReason: 'STOP' },
],
} as unknown as GenerateContentResponse;
})(),
);
const failingStream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
[
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
],
'prompt-id-fusion-setup',
new AbortController().signal,
LlmRole.MAIN,
);
await expect(
(async () => {
for await (const _ of failingStream) {
// consume
}
})(),
).rejects.toThrow(InvalidStreamError);
// 2. The user types a brand new instruction.
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
'are you done?',
'prompt-id-fusion-check',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('are you done?')),
);
expect(fusedTurn).toBeUndefined();
});
it('should not fuse the next user message into a cancelled tool response', async () => {
// Same defect reached by a different trigger: cancelling a tool call
// records its response via addHistory then returns without submitting,
// leaving history on an unanswered user turn just like a stream failure.
chat.agentHistory.push({
id: 'model-turn-cancel',
content: {
role: 'model',
parts: [
{ functionCall: { id: 'c1', name: 'run_shell_command', args: {} } },
],
},
});
chat.addHistory({
role: 'user',
parts: [
{
functionResponse: {
id: 'c1',
name: 'run_shell_command',
response: { error: '[Operation Cancelled]' },
},
},
],
});
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
"you're querying local database, I meant nprd",
'prompt-id-cancel-fusion',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedCancelTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('I meant nprd')),
);
expect(fusedCancelTurn).toBeUndefined();
});
it('should close a dangling tool response restored from a resumed session', async () => {
// The guard runs when a new user message arrives rather than when the
// turn fails, so it does not depend on a placeholder having been
// persisted. A session resumed from disk that ends on an unanswered tool
// response is repaired on the next message just the same.
chat.setHistory([
{ role: 'user', parts: [{ text: 'run the tests' }] },
{
role: 'model',
parts: [
{ functionCall: { id: 'c1', name: 'run_shell_command', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'c1',
name: 'run_shell_command',
response: { output: 'ok' },
},
},
],
},
]);
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
'are you done?',
'prompt-id-resumed-fusion',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedResumedTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('are you done?')),
);
expect(fusedResumedTurn).toBeUndefined();
});
it('should preserve mixed multimodal function responses during rollback when InvalidStreamError is thrown (regression)', async () => {
// 1. Setup history ending with a model turn containing functionCall
chat.agentHistory.push({
@@ -3433,75 +3210,6 @@ describe('GeminiChat', () => {
expect(turns[0].content.parts![0].text).toBe('Question 1');
expect(turns[0].content.parts![1].text).toBe('Question 2');
});
it('should inject a synthetic thoughtSignature onto a functionCall left signature-less after stripping a thought part that carried it (regression test for #28604)', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{ role: 'user', parts: [{ text: 'activate the skill' }] },
{
role: 'model',
parts: [
{
text: 'internal monologue',
thought: true,
thoughtSignature: 'real-sig-from-api',
} as unknown as Part,
{
functionCall: { name: 'activate_skill', args: {} },
},
],
},
{
role: 'user',
parts: [
{ functionResponse: { name: 'activate_skill', response: {} } },
],
},
]);
const turns = chat.getHistoryTurns(true);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0].functionCall?.name).toBe(
'activate_skill',
);
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
});
it('should leave an existing thoughtSignature on a functionCall untouched when stripping thoughts', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{ role: 'user', parts: [{ text: 'activate the skill' }] },
{
role: 'model',
parts: [
{
text: 'internal monologue',
thought: true,
thoughtSignature: 'real-sig-from-api',
} as unknown as Part,
{
functionCall: { name: 'activate_skill', args: {} },
thoughtSignature: 'existing-sig-on-call',
},
],
},
]);
const turns = chat.getHistoryTurns(true);
const modelTurn = turns[1];
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
'existing-sig-on-call',
);
});
});
describe('ensureActiveLoopHasThoughtSignatures', () => {
+9 -72
View File
@@ -55,7 +55,11 @@ import {
} from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { scrubHistory, scrubContents } from '../utils/historyHardening.js';
import {
hardenHistory,
scrubHistory,
scrubContents,
} from '../utils/historyHardening.js';
import {
partListUnionToString,
ensureStableToolIds,
@@ -108,13 +112,6 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Stands in for a model turn that never arrived because the stream failed
* after a tool response was already committed to history.
*/
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
'[The previous response was interrupted before it completed.]';
/**
* Internal interface for parts that carry the magic 'callIndex' property
* used during model response consolidation.
@@ -415,16 +412,6 @@ export class GeminiChat {
let userContent = createUserContent(message);
const isOriginalFunctionResponse = isFunctionResponse(userContent);
// A turn can end leaving history on an unanswered tool response: a stream
// error after the response was committed, or a cancelled tool call. Close
// it before recording a genuinely new user message, otherwise the two user
// turns are coalesced into one and the model continues the trailing text
// instead of answering it.
if (!isOriginalFunctionResponse) {
this.closeUnansweredToolResponseTurn();
}
const { model } =
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
@@ -700,28 +687,6 @@ export class GeminiChat {
return streamWithRetries.call(this);
}
/**
* Appends a closing model turn when history ends with an unanswered tool
* response, so the next user message stays a turn of its own.
*/
private closeUnansweredToolResponseTurn(): void {
const turns = this.agentHistory.get();
const last = turns[turns.length - 1];
if (
last?.content.role !== 'user' ||
!last.content.parts?.some((part) => !!part.functionResponse)
) {
return;
}
this.agentHistory.push({
id: randomUUID(),
content: {
role: 'model',
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
},
});
}
private extractBinaryInjections(
parts: Part[] | undefined,
): Part[] | undefined {
@@ -751,14 +716,9 @@ export class GeminiChat {
role: LlmRole,
apiHistoryOverride?: Content[],
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
let scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
// Always coalesce consecutive roles to prevent 400 Bad Request errors
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
// Last mile hardening and scrubbing to ensure absolute compliance with Gemini API invariants
// and remove internal tracking properties (e.g. callIndex).
const scrubbedHistory = hardenHistory([...requestHistory]);
const scrubbedContents = scrubbedHistory.map((h) => h.content);
@@ -1687,34 +1647,11 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
if (!hasThought) return turn;
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
// The thoughtSignature the API requires on the first functionCall of a
// model turn is sometimes only carried by the thought part we just
// removed, not by the functionCall part itself. Without it, replaying
// this turn in a later request gets rejected with a 400 "missing
// thought_signature" error, so inject a synthetic one if needed.
let patchedFirstCall = false;
const finalParts =
turn.content.role === 'model'
? nonThoughtParts.map((p) => {
if (!patchedFirstCall && p.functionCall) {
patchedFirstCall = true;
if (!p.thoughtSignature) {
return {
...p,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
};
}
}
return p;
})
: nonThoughtParts;
return {
...turn,
content: {
...turn.content,
parts: finalParts,
parts: nonThoughtParts,
},
};
})
@@ -308,125 +308,6 @@ describe('ChatRecordingService', () => {
)) as ConversationRecord;
expect(conversation.sessionId).toBe('old-session-id');
});
it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => {
// Regression test for the `/compress` "Failed to load resumed session
// data from file" bug: when resuming with a filePath that cannot be
// loaded from disk, initialize must NOT throw. It should adopt the
// in-memory conversation it was handed and rewrite a clean file.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const missingFile = path.join(chatsDir, 'missing-session.jsonl');
expect(fs.existsSync(missingFile)).toBe(false);
const inMemoryConversation = {
sessionId: 'resumed-session-id',
projectHash: 'resumed-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg-1',
type: 'user',
timestamp: new Date().toISOString(),
content: 'hello from memory',
},
],
} as unknown as ConversationRecord;
await expect(
chatRecordingService.initialize({
filePath: missingFile,
conversation: inMemoryConversation,
}),
).resolves.not.toThrow();
// The in-memory conversation is adopted.
expect(chatRecordingService.getConversation()?.sessionId).toBe(
'resumed-session-id',
);
// A clean, loadable file is rewritten from the in-memory copy so future
// loads and appends succeed.
const reloaded = (await loadConversationRecord(
missingFile,
)) as ConversationRecord;
expect(reloaded).not.toBeNull();
expect(reloaded.sessionId).toBe('resumed-session-id');
expect(reloaded.projectHash).toBe('resumed-project-hash');
expect(reloaded.messages).toHaveLength(1);
});
it('should preserve an unreadable session file instead of destroying it', async () => {
// The reload may have failed only transiently, so the original bytes
// must survive the recovery rewrite.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'unreadable.jsonl');
// No usable metadata line => loadConversationRecord() returns null.
const originalBytes = '{"not":"a valid metadata line"}\n';
fs.writeFileSync(sessionFile, originalBytes);
await chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'recovered-session-id',
projectHash: 'recovered-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
});
// The rewritten file is loadable again...
const reloaded = (await loadConversationRecord(
sessionFile,
)) as ConversationRecord;
expect(reloaded.sessionId).toBe('recovered-session-id');
// ...and the original bytes were kept alongside it.
const preserved = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('unreadable.jsonl.unreadable-'));
expect(preserved).toHaveLength(1);
expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe(
originalBytes,
);
});
it('should not leave a temp file behind when the rewrite fails', async () => {
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl');
// Fail the rename that publishes the temp file, leaving it orphaned.
const realRename = fs.renameSync;
vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
if (String(from).includes('.tmp-')) {
throw new Error('simulated rename failure');
}
return realRename(from, to);
});
await expect(
chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'temp-cleanup-session',
projectHash: 'temp-cleanup-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
}),
).rejects.toThrow('simulated rename failure');
const leftovers = fs
.readdirSync(chatsDir)
.filter((f) => f.includes('.tmp-'));
expect(leftovers).toEqual([]);
});
});
describe('recordMessage', () => {
@@ -462,16 +462,7 @@ export class ChatRecordingService {
// Update the session ID in the existing file
this.updateMetadata({ sessionId: this.sessionId });
} else {
// The file could not be reloaded (missing, corrupt metadata, or an
// I/O error). Fall back to the in-memory conversation we were handed
// rather than failing the caller, and rewrite a clean file from it.
debugLogger.warn(
'Failed to reload resumed session data from file; falling back ' +
'to the in-memory conversation.',
);
this.cachedConversation = resumedSessionData.conversation;
this.projectHash = this.cachedConversation.projectHash;
this.rewriteConversationFile(this.cachedConversation);
throw new Error('Failed to load resumed session data from file');
}
} else {
// Create new session
@@ -572,73 +563,6 @@ export class ChatRecordingService {
}
}
/**
* Rewrites the session file from an in-memory record. Any existing
* (unreadable) file is preserved alongside rather than destroyed, and the
* new file is written atomically (temp file + rename).
*/
private rewriteConversationFile(conversation: ConversationRecord): void {
if (!this.conversationFile) return;
// Normalize legacy `.json` paths to the `.jsonl` format we write.
if (this.conversationFile.endsWith('.json')) {
this.conversationFile = this.conversationFile + 'l';
}
const { messages, memoryScratchpad, ...metadata } = conversation;
const lines: string[] = [JSON.stringify(metadata)];
for (const msg of messages) {
lines.push(JSON.stringify(msg));
}
if (memoryScratchpad) {
lines.push(JSON.stringify({ $set: { memoryScratchpad } }));
}
const content = lines.join('\n') + '\n';
try {
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
// The existing file was unreadable, but it may have been only
// transiently so (a lock or I/O blip) rather than truly corrupt. Keep
// its bytes rather than destroying them.
if (fs.existsSync(this.conversationFile)) {
const backup = `${this.conversationFile}.unreadable-${Date.now()}`;
try {
fs.renameSync(this.conversationFile, backup);
debugLogger.warn(
`Preserved the unreadable session file at ${backup}.`,
);
} catch (backupError) {
debugLogger.error(
'Failed to preserve the unreadable session file.',
backupError,
);
}
}
const tempFile = `${this.conversationFile}.tmp-${process.pid}`;
try {
fs.writeFileSync(tempFile, content);
fs.renameSync(tempFile, this.conversationFile);
} catch (error) {
// The rename did not complete, so the temp file would be left behind.
try {
fs.unlinkSync(tempFile);
} catch {
// Ignore cleanup errors so the original failure still surfaces.
}
throw error;
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOSPC') {
this.conversationFile = null;
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
} else {
throw error;
}
}
}
private updateMetadata(updates: Partial<ConversationRecord>): void {
if (!this.cachedConversation) return;
Object.assign(this.cachedConversation, updates);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.54.1",
"version": "0.55.0-nightly.20260729.g3499c84f7",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {