mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 19:20:58 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2f9cbaa8 | |||
| b213fd68ec | |||
| 928a311fb0 | |||
| f6494f3862 | |||
| b7f2067dd7 | |||
| 7a5a8183bf | |||
| 0c0d88d90b | |||
| 2151653133 | |||
| a6ed2cc5e3 |
@@ -210,6 +210,22 @@ To update an extension's settings:
|
||||
gemini extensions config <name> [setting] [--scope <scope>]
|
||||
```
|
||||
|
||||
#### Environment variable sanitization
|
||||
|
||||
For security reasons, sensitive environment variables are filtered out and not
|
||||
passed to extensions or MCP servers by default.
|
||||
|
||||
Extensions **will not** inherit the user's full shell environment variables.
|
||||
They will only have access to:
|
||||
|
||||
1. Standard safe variables (e.g., `HOME`, `PATH`, `TMPDIR`).
|
||||
2. Variables explicitly declared and requested in the `gemini-extension.json`
|
||||
manifest via the `settings` array (using the `envVar` property).
|
||||
|
||||
If your extension requires specific environment variables (like an API key,
|
||||
custom host, or config path), you **must** declare them in the `settings` array
|
||||
so the CLI can allowlist them for use within the extension.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Provide [custom commands](../cli/custom-commands.md) by placing TOML files in a
|
||||
|
||||
@@ -159,6 +159,13 @@ When a user installs this extension, Gemini CLI will prompt them to enter the
|
||||
`sensitive` is true) and injected into the MCP server's process as the
|
||||
`MY_SERVICE_API_KEY` environment variable.
|
||||
|
||||
> **Important (Environment Variable Sanitization):** For security reasons,
|
||||
> sensitive environment variables are filtered out and not passed to extensions
|
||||
> or MCP servers by default. Extensions will _only_ have access to environment
|
||||
> variables that are explicitly declared in the `settings` array using the
|
||||
> `envVar` property, plus a few standard safe variables. Do not expect host
|
||||
> environment variables to be available otherwise.
|
||||
|
||||
## Step 4: Link your extension
|
||||
|
||||
Link your extension to your Gemini CLI installation for local development.
|
||||
|
||||
@@ -111,8 +111,8 @@ You can also run Gemini CLI using one of the following advanced methods:
|
||||
directly. This is useful for environments where you only have Docker and want
|
||||
to run the CLI.
|
||||
```bash
|
||||
# Run the published sandbox image
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1
|
||||
# Run the published sandbox image for a specified CLI version
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e
|
||||
```
|
||||
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
|
||||
(using the standard installation described above), you can instruct it to run
|
||||
|
||||
@@ -221,8 +221,10 @@ spawning MCP server processes.
|
||||
#### Automatic redaction
|
||||
|
||||
By default, the CLI redacts sensitive environment variables from the base
|
||||
environment (inherited from the host process) to prevent unintended exposure to
|
||||
third-party MCP servers. This includes:
|
||||
environment (inherited from the host process). This prevents the accidental
|
||||
leakage of sensitive host environment variables (like AWS keys or GitHub tokens)
|
||||
to arbitrary third-party MCP servers that might execute malicious code or log
|
||||
your environment. This includes:
|
||||
|
||||
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
|
||||
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
|
||||
@@ -232,7 +234,8 @@ third-party MCP servers. This includes:
|
||||
#### Explicit overrides
|
||||
|
||||
If an environment variable must be passed to an MCP server, you must explicitly
|
||||
state it in the `env` property of the server configuration in `settings.json`.
|
||||
state it in the `env` property of the server configuration in `settings.json`
|
||||
(or `mcp_config.json` if configuring standard MCP clients or remote skills).
|
||||
Explicitly defined variables (including those from extensions) are trusted and
|
||||
are **not** subjected to the automatic redaction process.
|
||||
|
||||
@@ -247,6 +250,24 @@ specific data with that server.
|
||||
> (for example, `"MY_KEY": "$MY_KEY"`) to securely pull the value from your host
|
||||
> environment at runtime.
|
||||
|
||||
**Example: Passing a GitHub Token securely to the
|
||||
[official GitHub MCP server](https://github.com/github/github-mcp-server) via
|
||||
`mcp_config.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@github/github-mcp-server"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth support for remote MCP servers
|
||||
|
||||
Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or
|
||||
|
||||
Generated
+364
-348
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:sea-launch": "vitest run sea/sea-launch.test.js",
|
||||
"posttest": "npm run build",
|
||||
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"posttest": "npm run build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -336,7 +336,14 @@ describe('sandbox', () => {
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
||||
expect.arrayContaining([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--entrypoint',
|
||||
'',
|
||||
]),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
|
||||
|
||||
@@ -314,6 +314,10 @@ export async function start_sandbox(
|
||||
// run init binary inside container to forward signals & reap zombies
|
||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
||||
|
||||
// explicitly clear the entrypoint to prevent the container's default
|
||||
// entrypoint from interfering with the CLI's spawn command.
|
||||
args.push('--entrypoint', '');
|
||||
|
||||
// add runsc runtime if using runsc
|
||||
if (config.command === 'runsc') {
|
||||
args.push('--runtime=runsc');
|
||||
@@ -728,6 +732,8 @@ export async function start_sandbox(
|
||||
'run',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--entrypoint',
|
||||
'',
|
||||
...(userFlag ? userFlag.split(' ') : []),
|
||||
'--name',
|
||||
SANDBOX_PROXY_NAME,
|
||||
|
||||
+18
-16
@@ -16,6 +16,7 @@
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"posttest": "npm run build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
@@ -32,22 +33,22 @@
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
"@opentelemetry/core": "^2.5.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.211.0",
|
||||
"@opentelemetry/otlp-exporter-base": "^0.211.0",
|
||||
"@opentelemetry/resources": "^2.5.0",
|
||||
"@opentelemetry/sdk-logs": "^0.211.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.5.0",
|
||||
"@opentelemetry/sdk-node": "^0.211.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.5.0",
|
||||
"@opentelemetry/api-logs": "^0.218.0",
|
||||
"@opentelemetry/core": "^2.7.1",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "^0.218.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-logs": "^0.218.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
@@ -66,6 +67,7 @@
|
||||
"glob": "^12.0.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
|
||||
@@ -324,6 +324,38 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('model fallback', () => {
|
||||
it('should fallback to default model when an obsolete model is provided', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'gemini-pro-latest',
|
||||
});
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should fallback to default model when an invalid gemini model is provided', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'gemini-invalid-model',
|
||||
});
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should fallback to default model when setModel is called with an invalid gemini model', () => {
|
||||
const config = new Config(baseParams);
|
||||
config.setModel('gemini-invalid-model');
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should allow custom models (non-gemini prefixed)', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'custom-model',
|
||||
});
|
||||
expect(config.getModel()).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setShellExecutionConfig', () => {
|
||||
it('should preserve existing shell execution fields that are not being updated', () => {
|
||||
const config = new Config({
|
||||
@@ -2613,7 +2645,7 @@ describe('Config getHooks', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-pro',
|
||||
model: 'gemini-2.5-flash',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
@@ -2862,7 +2894,7 @@ describe('Config getExperiments', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-pro',
|
||||
model: 'gemini-2.5-flash',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
@@ -2907,7 +2939,7 @@ describe('Config setExperiments logging', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-pro',
|
||||
model: 'gemini-2.5-flash',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -80,10 +80,12 @@ import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
isAutoModel,
|
||||
isPreviewModel,
|
||||
isGemini2Model,
|
||||
isValidModel,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
} from './models.js';
|
||||
@@ -1114,9 +1116,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.cwd = params.cwd ?? process.cwd();
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
this.model = isValidModel(params.model)
|
||||
? params.model
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = params.model;
|
||||
this._activeModel = this.model;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
@@ -1902,17 +1906,20 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
setModel(newModel: string, isTemporary: boolean = true): void {
|
||||
if (this.model !== newModel || this._activeModel !== newModel) {
|
||||
this.model = newModel;
|
||||
const validatedModel = isValidModel(newModel)
|
||||
? newModel
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
if (this.model !== validatedModel || this._activeModel !== validatedModel) {
|
||||
this.model = validatedModel;
|
||||
// When the user explicitly sets a model, that becomes the active model.
|
||||
this._activeModel = newModel;
|
||||
coreEvents.emitModelChanged(newModel);
|
||||
this._activeModel = validatedModel;
|
||||
coreEvents.emitModelChanged(validatedModel);
|
||||
this.lastEmittedQuotaRemaining = undefined;
|
||||
this.lastEmittedQuotaLimit = undefined;
|
||||
this.emitQuotaChangedEvent();
|
||||
}
|
||||
if (this.onModelChange && !isTemporary) {
|
||||
this.onModelChange(newModel);
|
||||
this.onModelChange(validatedModel);
|
||||
}
|
||||
this.modelAvailabilityService.reset();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { normalizeModelId } from '../utils/modelUtils.js';
|
||||
|
||||
export interface ModelResolutionContext {
|
||||
useGemini3_1?: boolean;
|
||||
useGemini3_1FlashLite?: boolean;
|
||||
@@ -110,6 +112,20 @@ export function getAutoModelDescription(
|
||||
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
|
||||
}
|
||||
|
||||
export function isValidModel(model: string): boolean {
|
||||
const normalized = normalizeModelId(model);
|
||||
return (
|
||||
VALID_GEMINI_MODELS.has(normalized) ||
|
||||
normalized === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
normalized === GEMINI_MODEL_ALIAS_PRO ||
|
||||
normalized === GEMINI_MODEL_ALIAS_FLASH ||
|
||||
normalized === GEMINI_MODEL_ALIAS_FLASH_LITE ||
|
||||
normalized === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
normalized === DEFAULT_GEMINI_MODEL_AUTO ||
|
||||
!normalized.startsWith('gemini-')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name.
|
||||
@@ -196,7 +212,12 @@ export function resolveModel(
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
resolved = normalizedModel;
|
||||
if (!isValidModel(normalizedModel)) {
|
||||
// Fallback to stable default for unknown/obsolete models.
|
||||
resolved = DEFAULT_GEMINI_MODEL;
|
||||
} else {
|
||||
resolved = normalizedModel;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from './contentGenerator.js';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
@@ -464,6 +466,174 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject HttpsProxyAgent into googleAuthOptions when proxy URL uses https://', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue('https://proxy.example.com:8080'),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
proxy: 'https://proxy.example.com:8080',
|
||||
},
|
||||
mockConfigWithProxy,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: {
|
||||
clientOptions: {
|
||||
transporterOptions: {
|
||||
agent: expect.any(HttpsProxyAgent),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should still use HttpsProxyAgent for HTTPS destinations even when proxy URL uses http://', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue('http://proxy.example.com:8080'),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
},
|
||||
mockConfigWithProxy,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: {
|
||||
clientOptions: {
|
||||
transporterOptions: {
|
||||
agent: expect.any(HttpsProxyAgent),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject HttpProxyAgent when destination baseUrl uses http://', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue('http://proxy.example.com:8080'),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator);
|
||||
|
||||
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'http://localhost:9999');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
},
|
||||
mockConfigWithProxy,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: {
|
||||
clientOptions: {
|
||||
transporterOptions: {
|
||||
agent: expect.any(HttpProxyAgent),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should trim whitespace from proxy URL before instantiating agent', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(' https://proxy.example.com:8080 '),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
proxy: ' https://proxy.example.com:8080 ',
|
||||
},
|
||||
mockConfigWithProxy,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: {
|
||||
clientOptions: {
|
||||
transporterOptions: {
|
||||
agent: expect.any(HttpsProxyAgent),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include googleAuthOptions when no proxy is configured', async () => {
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator);
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const callArg = vi.mocked(GoogleGenAI).mock.calls[0][0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(callArg).not.toHaveProperty('googleAuthOptions');
|
||||
});
|
||||
|
||||
it('should pass api key as Authorization Header when GEMINI_API_KEY_AUTH_MECHANISM is set to bearer', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type EmbedContentResponse,
|
||||
type EmbedContentParameters,
|
||||
} from '@google/genai';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import * as os from 'node:os';
|
||||
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
|
||||
import { isCloudShell } from '../ide/detect-ide.js';
|
||||
@@ -343,6 +345,13 @@ export async function createContentGenerator(
|
||||
httpOptions.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
const proxyUrl = config.proxy?.trim();
|
||||
const proxyAgent = proxyUrl
|
||||
? baseUrl?.startsWith('http://')
|
||||
? new HttpProxyAgent(proxyUrl)
|
||||
: new HttpsProxyAgent(proxyUrl)
|
||||
: undefined;
|
||||
|
||||
const googleGenAI = new GoogleGenAI({
|
||||
apiKey:
|
||||
config.authType === AuthType.GATEWAY
|
||||
@@ -353,6 +362,13 @@ export async function createContentGenerator(
|
||||
vertexai: config.vertexai ?? config.authType === AuthType.USE_VERTEX_AI,
|
||||
httpOptions,
|
||||
...(apiVersionEnv && { apiVersion: apiVersionEnv }),
|
||||
...(proxyAgent && {
|
||||
googleAuthOptions: {
|
||||
clientOptions: {
|
||||
transporterOptions: { agent: proxyAgent },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return new LoggingContentGenerator(googleGenAI.models, gcConfig);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,10 @@ describe('GitService', () => {
|
||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await service.setupShadowGitRepository();
|
||||
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
|
||||
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(
|
||||
repoDir,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(hoistedMockInit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { spawnAsync } from '../utils/shell-utils.js';
|
||||
import { simpleGit, CheckRepoActions, type SimpleGit } from 'simple-git';
|
||||
import {
|
||||
simpleGit,
|
||||
CheckRepoActions,
|
||||
type SimpleGit,
|
||||
type SimpleGitOptions,
|
||||
} from 'simple-git';
|
||||
import type { Storage } from '../config/storage.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
@@ -19,6 +24,38 @@ import {
|
||||
export const SHADOW_REPO_AUTHOR_NAME = 'Gemini CLI';
|
||||
export const SHADOW_REPO_AUTHOR_EMAIL = 'gemini-cli@google.com';
|
||||
|
||||
/**
|
||||
* Common configuration for the shadow Git repository used for checkpointing.
|
||||
*
|
||||
* We enable all "unsafe" options because the shadow repository is an internal,
|
||||
* isolated state management tool, and we want to ensure it works reliably
|
||||
* regardless of the user's local environment (e.g., PAGER, EDITOR, or SSH settings).
|
||||
*/
|
||||
const SHADOW_REPO_GIT_OPTIONS: Partial<SimpleGitOptions> = {
|
||||
unsafe: {
|
||||
allowUnsafeAlias: true,
|
||||
allowUnsafeAskPass: true,
|
||||
allowUnsafeConfigEnvCount: true,
|
||||
allowUnsafeConfigPaths: true,
|
||||
allowUnsafeCredentialHelper: true,
|
||||
allowUnsafeCustomBinary: true,
|
||||
allowUnsafeDiffExternal: true,
|
||||
allowUnsafeDiffTextConv: true,
|
||||
allowUnsafeEditor: true,
|
||||
allowUnsafeFilter: true,
|
||||
allowUnsafeFsMonitor: true,
|
||||
allowUnsafeGitProxy: true,
|
||||
allowUnsafeGpgProgram: true,
|
||||
allowUnsafeHooksPath: true,
|
||||
allowUnsafeMergeDriver: true,
|
||||
allowUnsafePack: true,
|
||||
allowUnsafePager: true,
|
||||
allowUnsafeProtocolOverride: true,
|
||||
allowUnsafeSshCommand: true,
|
||||
allowUnsafeTemplateDir: true,
|
||||
},
|
||||
};
|
||||
|
||||
export class GitService {
|
||||
private projectRoot: string;
|
||||
private storage: Storage;
|
||||
@@ -101,7 +138,7 @@ export class GitService {
|
||||
|
||||
const shadowRepoEnv = this.getShadowRepoEnv(repoDir);
|
||||
await fs.writeFile(shadowRepoEnv.GIT_CONFIG_SYSTEM, '');
|
||||
const repo = simpleGit(repoDir).env(shadowRepoEnv);
|
||||
const repo = simpleGit(repoDir, SHADOW_REPO_GIT_OPTIONS).env(shadowRepoEnv);
|
||||
let isRepoDefined = false;
|
||||
try {
|
||||
isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
||||
@@ -138,7 +175,7 @@ export class GitService {
|
||||
|
||||
private get shadowGitRepository(): SimpleGit {
|
||||
const repoDir = this.getHistoryDir();
|
||||
return simpleGit(this.projectRoot).env({
|
||||
return simpleGit(this.projectRoot, SHADOW_REPO_GIT_OPTIONS).env({
|
||||
...this.getShadowRepoEnv(repoDir),
|
||||
GIT_DIR: path.join(repoDir, '.git'),
|
||||
GIT_WORK_TREE: this.projectRoot,
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
AggregationTemporality,
|
||||
type ResourceMetrics,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import {
|
||||
FileSpanExporter,
|
||||
FileLogExporter,
|
||||
@@ -13,19 +17,17 @@ import {
|
||||
import { ExportResultCode } from '@opentelemetry/core';
|
||||
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
||||
import type { ReadableLogRecord } from '@opentelemetry/sdk-logs';
|
||||
import {
|
||||
AggregationTemporality,
|
||||
type ResourceMetrics,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
function createMockWriteStream(): {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
writable: boolean;
|
||||
} {
|
||||
return {
|
||||
write: vi.fn((_data: string, cb: (err?: Error | null) => void) => cb()),
|
||||
end: vi.fn((cb: () => void) => cb()),
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,8 +188,51 @@ describe('FileMetricExporter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve forceFlush', async () => {
|
||||
it('should resolve forceFlush after pending writes complete', async () => {
|
||||
let writeFinished = false;
|
||||
mockWriteStream.write.mockImplementation(
|
||||
(_data: string, cb: (err?: Error | null) => void) => {
|
||||
setTimeout(() => {
|
||||
writeFinished = true;
|
||||
cb();
|
||||
}, 50);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
|
||||
|
||||
// Start an export
|
||||
const exportDone = new Promise<void>((resolve) => {
|
||||
exporter.export({ resource: { attributes: {} } } as ResourceMetrics, () =>
|
||||
resolve(),
|
||||
);
|
||||
});
|
||||
|
||||
const flushPromise = exporter.forceFlush();
|
||||
|
||||
expect(writeFinished).toBe(false);
|
||||
await flushPromise;
|
||||
expect(writeFinished).toBe(true);
|
||||
await exportDone;
|
||||
});
|
||||
|
||||
it('should handle write error in forceFlush', async () => {
|
||||
const writeError = new Error('flush failed');
|
||||
mockWriteStream.write.mockImplementation(
|
||||
(_data: string, cb: (err?: Error | null) => void) => cb(writeError),
|
||||
);
|
||||
|
||||
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
|
||||
await expect(exporter.forceFlush()).rejects.toThrow('flush failed');
|
||||
});
|
||||
|
||||
it('should resolve forceFlush immediately if stream is not writable', async () => {
|
||||
const exporter = new FileMetricExporter('/tmp/test-metrics.log');
|
||||
// @ts-expect-error - accessing protected member for test
|
||||
exporter.writeStream.writable = false;
|
||||
|
||||
await expect(exporter.forceFlush()).resolves.toBeUndefined();
|
||||
expect(mockWriteStream.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,27 @@ class FileExporter {
|
||||
return safeJsonStringify(data, 2) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that all pending writes are flushed to the underlying stream.
|
||||
*/
|
||||
forceFlush(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.writeStream.writable) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// write('') will be queued after all previous writes and its callback
|
||||
// will be called when it (and thus all previous writes) are flushed.
|
||||
this.writeStream.write('', (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.writeStream.end(resolve);
|
||||
@@ -86,8 +107,4 @@ export class FileMetricExporter
|
||||
getPreferredAggregationTemporality(): AggregationTemporality {
|
||||
return AggregationTemporality.CUMULATIVE;
|
||||
}
|
||||
|
||||
async forceFlush(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export class GcpTraceExporter extends TraceExporter {
|
||||
resourceFilter: /^gcp\./,
|
||||
});
|
||||
}
|
||||
|
||||
async forceFlush(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -136,6 +136,7 @@ vi.mock('systeminformation', () => ({
|
||||
describe('loggers', () => {
|
||||
const mockLogger = {
|
||||
emit: vi.fn(),
|
||||
enabled: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const mockUiEvent = {
|
||||
addEvent: vi.fn(),
|
||||
|
||||
@@ -199,8 +199,8 @@ ${finalExclusionPatternsForDescription
|
||||
const fullPath = path.join(dir, normalizedP);
|
||||
let exists = false;
|
||||
try {
|
||||
await fsPromises.access(fullPath);
|
||||
exists = true;
|
||||
const st = await fsPromises.stat(fullPath);
|
||||
exists = st.isFile();
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { BINARY_EXTENSIONS } from './ignorePatterns.js';
|
||||
import { createRequire as createModuleRequire } from 'node:module';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_LINES_TEXT_FILE,
|
||||
MAX_LINE_LENGTH_TEXT_FILE,
|
||||
@@ -350,7 +351,9 @@ export async function isEmpty(filePath: string): Promise<boolean> {
|
||||
*/
|
||||
export async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await isBinaryFileCheck(filePath);
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
if (!stats.isFile()) return false;
|
||||
return await isBinaryFileCheck(filePath, stats.size);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to check if file is binary: ${filePath}`,
|
||||
|
||||
@@ -440,7 +440,10 @@ function robustRealpath(p: string, visited = new Set<string>()): string {
|
||||
e &&
|
||||
typeof e === 'object' &&
|
||||
'code' in e &&
|
||||
(e.code === 'ENOENT' || e.code === 'EISDIR')
|
||||
(e.code === 'ENOENT' ||
|
||||
e.code === 'EISDIR' ||
|
||||
e.code === 'ENAMETOOLONG' ||
|
||||
e.code === 'ENOTDIR')
|
||||
) {
|
||||
try {
|
||||
const stat = fs.lstatSync(p);
|
||||
@@ -457,7 +460,10 @@ function robustRealpath(p: string, visited = new Set<string>()): string {
|
||||
lstatError &&
|
||||
typeof lstatError === 'object' &&
|
||||
'code' in lstatError &&
|
||||
(lstatError.code === 'ENOENT' || lstatError.code === 'EISDIR')
|
||||
(lstatError.code === 'ENOENT' ||
|
||||
lstatError.code === 'EISDIR' ||
|
||||
lstatError.code === 'ENAMETOOLONG' ||
|
||||
lstatError.code === 'ENOTDIR')
|
||||
)
|
||||
) {
|
||||
throw lstatError;
|
||||
|
||||
@@ -2238,7 +2238,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
express-rate-limit@8.3.1
|
||||
express-rate-limit@8.5.2
|
||||
(git+https://github.com/express-rate-limit/express-rate-limit.git)
|
||||
|
||||
# MIT License
|
||||
@@ -2264,7 +2264,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
ip-address@10.1.0
|
||||
ip-address@10.2.0
|
||||
(git://github.com/beaugunderson/ip-address.git)
|
||||
|
||||
Copyright (C) 2011 by Beau Gunderson
|
||||
@@ -2289,7 +2289,7 @@ THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
hono@4.12.12
|
||||
hono@4.12.18
|
||||
(git+https://github.com/honojs/hono.git)
|
||||
|
||||
MIT License
|
||||
|
||||
+23
-18
@@ -33,26 +33,31 @@ if (!existsSync(join(root, 'node_modules'))) {
|
||||
// build all workspaces/packages
|
||||
execSync('npm run generate', { stdio: 'inherit', cwd: root });
|
||||
|
||||
// Build core first because everyone depends on it
|
||||
console.log('Building @google/gemini-cli-core...');
|
||||
execSync('npm run build -w @google/gemini-cli-core', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
if (process.env.CI) {
|
||||
console.log('CI environment detected. Building workspaces sequentially...');
|
||||
execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root });
|
||||
} else {
|
||||
// Build core first because everyone depends on it
|
||||
console.log('Building @google/gemini-cli-core...');
|
||||
execSync('npm run build -w @google/gemini-cli-core', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
});
|
||||
|
||||
// Build the rest in parallel
|
||||
console.log('Building other workspaces in parallel...');
|
||||
const workspaceInfo = JSON.parse(
|
||||
execSync('npm query .workspace --json', { cwd: root, encoding: 'utf-8' }),
|
||||
);
|
||||
const parallelWorkspaces = workspaceInfo
|
||||
.map((w) => w.name)
|
||||
.filter((name) => name !== '@google/gemini-cli-core');
|
||||
// Build the rest in parallel
|
||||
console.log('Building other workspaces in parallel...');
|
||||
const workspaceInfo = JSON.parse(
|
||||
execSync('npm query .workspace --json', { cwd: root, encoding: 'utf-8' }),
|
||||
);
|
||||
const parallelWorkspaces = workspaceInfo
|
||||
.map((w) => w.name)
|
||||
.filter((name) => name !== '@google/gemini-cli-core');
|
||||
|
||||
execSync(
|
||||
`npx npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
{ stdio: 'inherit', cwd: root },
|
||||
);
|
||||
execSync(
|
||||
`npx npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
{ stdio: 'inherit', cwd: root },
|
||||
);
|
||||
}
|
||||
|
||||
// also build container image if sandboxing is enabled
|
||||
// skip (-s) npm install + build since we did that above
|
||||
|
||||
Reference in New Issue
Block a user