Compare commits

..

9 Commits

27 changed files with 865 additions and 421 deletions
+16
View File
@@ -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
+7
View File
@@ -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.
+2 -2
View File
@@ -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
+24 -3
View File
@@ -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
+364 -348
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1
View File
@@ -20,6 +20,7 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"posttest": "npm run build",
"typecheck": "tsc --noEmit"
},
"files": [
+8 -1
View File
@@ -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' }),
);
+6
View File
@@ -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
View File
@@ -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",
+35 -3
View File
@@ -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,
};
+14 -7
View File
@@ -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();
}
+22 -1
View File
@@ -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();
});
+40 -3
View File
@@ -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();
});
});
+21 -4
View File
@@ -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(),
+2 -2
View File
@@ -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;
}
+4 -1
View File
@@ -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}`,
+8 -2
View File
@@ -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;
+3 -3
View File
@@ -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
View File
@@ -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