mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
9 Commits
pr_20100
...
fix_punycode
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff7738b5d | |||
| 1ad26adb2b | |||
| af5aec69da | |||
| dae67983a8 | |||
| 70856d5a6e | |||
| cec45a1ebc | |||
| 767d80e768 | |||
| 3e5e608a22 | |||
| 0bc2d3ab16 |
@@ -29,6 +29,7 @@ they appear in the UI.
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
|
||||
|
||||
@@ -142,6 +142,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
request" errors.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.maxAttempts`** (number):
|
||||
- **Description:** Maximum number of attempts for requests to the main chat
|
||||
model. Cannot exceed 10.
|
||||
- **Default:** `10`
|
||||
|
||||
- **`general.debugKeystrokeLogging`** (boolean):
|
||||
- **Description:** Enable debug logging of keystrokes to the console.
|
||||
- **Default:** `false`
|
||||
|
||||
@@ -163,7 +163,8 @@ Each server configuration supports the following properties:
|
||||
- **`args`** (string[]): Command-line arguments for Stdio transport
|
||||
- **`headers`** (object): Custom HTTP headers when using `url` or `httpUrl`
|
||||
- **`env`** (object): Environment variables for the server process. Values can
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax
|
||||
reference environment variables using `$VAR_NAME` or `${VAR_NAME}` syntax (all
|
||||
platforms), or `%VAR_NAME%` (Windows only).
|
||||
- **`cwd`** (string): Working directory for Stdio transport
|
||||
- **`timeout`** (number): Request timeout in milliseconds (default: 600,000ms =
|
||||
10 minutes)
|
||||
@@ -184,6 +185,63 @@ Each server configuration supports the following properties:
|
||||
Service Account to impersonate. Used with
|
||||
`authProviderType: 'service_account_impersonation'`.
|
||||
|
||||
### Environment variable expansion
|
||||
|
||||
Gemini CLI automatically expands environment variables in the `env` block of
|
||||
your MCP server configuration. This allows you to securely reference variables
|
||||
defined in your shell or environment without hardcoding sensitive information
|
||||
directly in your `settings.json` file.
|
||||
|
||||
The expansion utility supports:
|
||||
|
||||
- **POSIX/Bash syntax:** `$VARIABLE_NAME` or `${VARIABLE_NAME}` (supported on
|
||||
all platforms)
|
||||
- **Windows syntax:** `%VARIABLE_NAME%` (supported only when running on Windows)
|
||||
|
||||
If a variable is not defined in the current environment, it resolves to an empty
|
||||
string.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
"env": {
|
||||
"API_KEY": "$MY_EXTERNAL_TOKEN",
|
||||
"LOG_LEVEL": "$LOG_LEVEL",
|
||||
"TEMP_DIR": "%TEMP%"
|
||||
}
|
||||
```
|
||||
|
||||
### Security and environment sanitization
|
||||
|
||||
To protect your credentials, Gemini CLI performs environment sanitization when
|
||||
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:
|
||||
|
||||
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
|
||||
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
|
||||
`*KEY*`, `*AUTH*`, `*CREDENTIAL*`.
|
||||
- Certificates and private key patterns.
|
||||
|
||||
#### 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`.
|
||||
Explicitly defined variables (including those from extensions) are trusted and
|
||||
are **not** subjected to the automatic redaction process.
|
||||
|
||||
This follows the security principle that if a variable is explicitly configured
|
||||
by the user for a specific server, it constitutes informed consent to share that
|
||||
specific data with that server.
|
||||
|
||||
> **Note:** Even when explicitly defined, you should avoid hardcoding secrets.
|
||||
> Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to
|
||||
> securely pull the value from your host environment at runtime.
|
||||
|
||||
### OAuth support for remote MCP servers
|
||||
|
||||
The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using
|
||||
@@ -738,7 +796,9 @@ The MCP integration tracks several states:
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
containing API keys or tokens. See
|
||||
[Security and environment sanitization](#security-and-environment-sanitization)
|
||||
for details on how Gemini CLI protects your credentials.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
|
||||
Generated
+382
-428
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ import {
|
||||
CoderAgentEvent,
|
||||
getPersistedState,
|
||||
setPersistedState,
|
||||
getContextIdFromMetadata,
|
||||
getAgentSettingsFromMetadata,
|
||||
} from '../types.js';
|
||||
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
@@ -117,8 +119,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
const config = await this.getConfig(agentSettings, sdkTask.id);
|
||||
const contextId: string =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(metadata['_contextId'] as string) || sdkTask.contextId;
|
||||
getContextIdFromMetadata(metadata) || sdkTask.contextId;
|
||||
const runtimeTask = await Task.create(
|
||||
sdkTask.id,
|
||||
contextId,
|
||||
@@ -141,8 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettingsInput?: AgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = agentSettingsInput || ({} as AgentSettings);
|
||||
const agentSettings: AgentSettings = agentSettingsInput || {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent,
|
||||
workspacePath: process.cwd(),
|
||||
};
|
||||
const config = await this.getConfig(agentSettings, taskId);
|
||||
const runtimeTask = await Task.create(
|
||||
taskId,
|
||||
@@ -292,8 +295,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const contextId: string =
|
||||
userMessage.contextId ||
|
||||
sdkTask?.contextId ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(sdkTask?.metadata?.['_contextId'] as string) ||
|
||||
getContextIdFromMetadata(sdkTask?.metadata) ||
|
||||
uuidv4();
|
||||
|
||||
logger.info(
|
||||
@@ -388,10 +390,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
}
|
||||
} else {
|
||||
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = userMessage.metadata?.[
|
||||
'coderAgent'
|
||||
] as AgentSettings;
|
||||
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
|
||||
try {
|
||||
wrapper = await this.createTask(
|
||||
taskId,
|
||||
|
||||
@@ -513,7 +513,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
@@ -533,7 +536,10 @@ describe('Task', () => {
|
||||
{
|
||||
request: { callId: '1' },
|
||||
status: 'awaiting_approval',
|
||||
confirmationDetails: { onConfirm: onConfirmSpy },
|
||||
confirmationDetails: {
|
||||
type: 'edit',
|
||||
onConfirm: onConfirmSpy,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCall[];
|
||||
|
||||
|
||||
@@ -59,6 +59,33 @@ import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||
|
||||
type UnionKeys<T> = T extends T ? keyof T : never;
|
||||
|
||||
type ConfirmationType = ToolCallConfirmationDetails['type'];
|
||||
|
||||
const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [
|
||||
'edit',
|
||||
'exec',
|
||||
'mcp',
|
||||
'info',
|
||||
'ask_user',
|
||||
'exit_plan_mode',
|
||||
] as const;
|
||||
|
||||
function isToolCallConfirmationDetails(
|
||||
value: unknown,
|
||||
): value is ToolCallConfirmationDetails {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('onConfirm' in value) ||
|
||||
typeof value.onConfirm !== 'function' ||
|
||||
!('type' in value) ||
|
||||
typeof value.type !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type);
|
||||
}
|
||||
|
||||
export class Task {
|
||||
id: string;
|
||||
contextId: string;
|
||||
@@ -376,11 +403,10 @@ export class Task {
|
||||
}
|
||||
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
this.pendingToolConfirmationDetails.set(
|
||||
tc.request.callId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
tc.confirmationDetails as ToolCallConfirmationDetails,
|
||||
);
|
||||
const details = tc.confirmationDetails;
|
||||
if (isToolCallConfirmationDetails(details)) {
|
||||
this.pendingToolConfirmationDetails.set(tc.request.callId, details);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send an update if the status has actually changed.
|
||||
@@ -412,11 +438,12 @@ export class Task {
|
||||
);
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
|
||||
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
this.pendingToolConfirmationDetails.delete(tc.request.callId);
|
||||
const details = tc.confirmationDetails;
|
||||
if (isToolCallConfirmationDetails(details)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
this.pendingToolConfirmationDetails.delete(tc.request.callId);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
@@ -466,15 +493,13 @@ export class Task {
|
||||
T extends ToolCall | AnyDeclarativeTool,
|
||||
K extends UnionKeys<T>,
|
||||
>(from: T, ...fields: K[]): Partial<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const ret = {} as Pick<T, K>;
|
||||
const ret: Partial<T> = {};
|
||||
for (const field of fields) {
|
||||
if (field in from) {
|
||||
if (field in from && from[field] !== undefined) {
|
||||
ret[field] = from[field];
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return ret as Partial<T>;
|
||||
return ret;
|
||||
}
|
||||
|
||||
private toolStatusMessage(
|
||||
@@ -485,8 +510,11 @@ export class Task {
|
||||
const messageParts: Part[] = [];
|
||||
|
||||
// Create a serializable version of the ToolCall (pick necessary
|
||||
// properties/avoid methods causing circular reference errors)
|
||||
const serializableToolCall: Partial<ToolCall> = this._pickFields(
|
||||
// properties/avoid methods causing circular reference errors).
|
||||
// Type allows tool to be Partial<AnyDeclarativeTool> for serialization.
|
||||
const serializableToolCall: Partial<Omit<ToolCall, 'tool'>> & {
|
||||
tool?: Partial<AnyDeclarativeTool>;
|
||||
} = this._pickFields(
|
||||
tc,
|
||||
'request',
|
||||
'status',
|
||||
@@ -496,8 +524,7 @@ export class Task {
|
||||
);
|
||||
|
||||
if (tc.tool) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
serializableToolCall.tool = this._pickFields(
|
||||
const toolFields = this._pickFields(
|
||||
tc.tool,
|
||||
'name',
|
||||
'displayName',
|
||||
@@ -507,7 +534,8 @@ export class Task {
|
||||
'canUpdateOutput',
|
||||
'schema',
|
||||
'parameterSchema',
|
||||
) as AnyDeclarativeTool;
|
||||
);
|
||||
serializableToolCall.tool = toolFields;
|
||||
}
|
||||
|
||||
messageParts.push({
|
||||
@@ -530,8 +558,15 @@ export class Task {
|
||||
old_string: string,
|
||||
new_string: string,
|
||||
): Promise<string> {
|
||||
// Validate path to prevent path traversal vulnerabilities
|
||||
const resolvedPath = path.resolve(this.config.getTargetDir(), file_path);
|
||||
const pathError = this.config.validatePathAccess(resolvedPath, 'read');
|
||||
if (pathError) {
|
||||
throw new Error(`Path validation failed: ${pathError}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(file_path, 'utf8');
|
||||
const currentContent = await fs.readFile(resolvedPath, 'utf8');
|
||||
return this._applyReplacement(
|
||||
currentContent,
|
||||
old_string,
|
||||
@@ -625,15 +660,32 @@ export class Task {
|
||||
request.args['old_string'] &&
|
||||
request.args['new_string']
|
||||
) {
|
||||
const newContent = await this.getProposedContent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['file_path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['old_string'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['new_string'] as string,
|
||||
);
|
||||
return { ...request, args: { ...request.args, newContent } };
|
||||
const filePath = request.args['file_path'];
|
||||
const oldString = request.args['old_string'];
|
||||
const newString = request.args['new_string'];
|
||||
if (
|
||||
typeof filePath === 'string' &&
|
||||
typeof oldString === 'string' &&
|
||||
typeof newString === 'string'
|
||||
) {
|
||||
// Resolve and validate path to prevent path traversal (user-controlled file_path).
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
filePath,
|
||||
);
|
||||
const pathError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
);
|
||||
if (!pathError) {
|
||||
const newContent = await this.getProposedContent(
|
||||
resolvedPath,
|
||||
oldString,
|
||||
newString,
|
||||
);
|
||||
return { ...request, args: { ...request.args, newContent } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}),
|
||||
@@ -725,10 +777,17 @@ export class Task {
|
||||
break;
|
||||
case GeminiEventType.Error:
|
||||
default: {
|
||||
// Block scope for lexical declaration
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const errorEvent = event as ServerGeminiErrorEvent; // Type assertion
|
||||
const errorMessage = errorEvent.value?.error
|
||||
// Use type guard instead of unsafe type assertion
|
||||
let errorEvent: ServerGeminiErrorEvent | undefined;
|
||||
if (
|
||||
event.type === GeminiEventType.Error &&
|
||||
event.value &&
|
||||
typeof event.value === 'object' &&
|
||||
'error' in event.value
|
||||
) {
|
||||
errorEvent = event;
|
||||
}
|
||||
const errorMessage = errorEvent?.value?.error
|
||||
? getErrorMessage(errorEvent.value.error)
|
||||
: 'Unknown error from LLM stream';
|
||||
logger.error(
|
||||
@@ -737,7 +796,7 @@ export class Task {
|
||||
);
|
||||
|
||||
let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`;
|
||||
if (errorEvent.value?.error) {
|
||||
if (errorEvent?.value?.error) {
|
||||
errMessage = parseAndFormatApiError(errorEvent.value.error);
|
||||
}
|
||||
this.cancelPendingTools(`LLM stream error: ${errorMessage}`);
|
||||
@@ -814,12 +873,11 @@ export class Task {
|
||||
|
||||
// If `edit` tool call, pass updated payload if presesent
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
const payload = part.data['newContent']
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
newContent: part.data['newContent'] as string,
|
||||
} as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
const newContent = part.data['newContent'];
|
||||
const payload =
|
||||
typeof newContent === 'string'
|
||||
? ({ newContent } as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
this.skipFinalTrueAfterInlineEdit = !!payload;
|
||||
try {
|
||||
await confirmationDetails.onConfirm(confirmationOutcome, payload);
|
||||
|
||||
@@ -122,11 +122,60 @@ export type PersistedTaskMetadata = { [k: string]: unknown };
|
||||
|
||||
export const METADATA_KEY = '__persistedState';
|
||||
|
||||
function isAgentSettings(value: unknown): value is AgentSettings {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'kind' in value &&
|
||||
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
|
||||
'workspacePath' in value &&
|
||||
typeof value.workspacePath === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedStateMetadata(
|
||||
value: unknown,
|
||||
): value is PersistedStateMetadata {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'_agentSettings' in value &&
|
||||
'_taskState' in value &&
|
||||
isAgentSettings(value._agentSettings)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPersistedState(
|
||||
metadata: PersistedTaskMetadata,
|
||||
): PersistedStateMetadata | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
|
||||
const state = metadata?.[METADATA_KEY];
|
||||
if (isPersistedStateMetadata(state)) {
|
||||
return state;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getContextIdFromMetadata(
|
||||
metadata: PersistedTaskMetadata | undefined,
|
||||
): string | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const contextId = metadata['_contextId'];
|
||||
return typeof contextId === 'string' ? contextId : undefined;
|
||||
}
|
||||
|
||||
export function getAgentSettingsFromMetadata(
|
||||
metadata: PersistedTaskMetadata | undefined,
|
||||
): AgentSettings | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
const coderAgent = metadata['coderAgent'];
|
||||
if (isAgentSettings(coderAgent)) {
|
||||
return coderAgent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function setPersistedState(
|
||||
|
||||
@@ -71,6 +71,7 @@ export function createMockConfig(
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
|
||||
|
||||
@@ -352,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should correctly match tool annotations', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
// Add a manual rule with annotations to the config
|
||||
config.rules = config.rules || [];
|
||||
config.rules.push({
|
||||
toolAnnotations: { readOnlyHint: true },
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// A tool with readOnlyHint=true should be ALLOWED
|
||||
const roCall = { name: 'some_tool', args: {} };
|
||||
const roMeta = { readOnlyHint: true };
|
||||
expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// A tool without the hint (or with false) should follow default decision (ASK_USER)
|
||||
const rwMeta = { readOnlyHint: false };
|
||||
expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(['write_file', 'replace'])(
|
||||
'Plan Mode policy for %s',
|
||||
(toolName) => {
|
||||
|
||||
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Run from HOME directory (tempDir is mocked as HOME in beforeEach)
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: tempDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Create a symlink to the home directory
|
||||
const symlinkDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-cli-symlink-${Date.now()}`,
|
||||
);
|
||||
fs.symlinkSync(tempDir, symlinkDir, 'dir');
|
||||
|
||||
try {
|
||||
// Run from symlink to HOME directory
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: symlinkDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
} finally {
|
||||
// Clean up symlink
|
||||
fs.unlinkSync(symlinkDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
const potentialWorkspacePoliciesDir = new Storage(
|
||||
cwd,
|
||||
).getWorkspacePoliciesDir();
|
||||
const storage = new Storage(cwd);
|
||||
|
||||
// If we are in the home directory (or rather, our target Gemini dir is the global one),
|
||||
// don't treat it as a workspace to avoid loading global policies twice.
|
||||
if (storage.isWorkspaceHomeDir()) {
|
||||
return { workspacePoliciesDir: undefined };
|
||||
}
|
||||
|
||||
const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
const integrityResult = await integrityManager.checkIntegrity(
|
||||
'workspace',
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
const pathMod = await import('node:path');
|
||||
const fsMod = await import('node:fs');
|
||||
|
||||
// Helper to resolve paths using the test's mocked environment
|
||||
const testResolve = (p: string | undefined) => {
|
||||
if (!p) return '';
|
||||
try {
|
||||
// Use the mocked fs.realpathSync if available, otherwise fallback
|
||||
return fsMod.realpathSync(pathMod.resolve(p));
|
||||
} catch {
|
||||
return pathMod.resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a smarter mock for isWorkspaceHomeDir
|
||||
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
|
||||
function (this: Storage) {
|
||||
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
|
||||
// Pick up the mocked home directory specifically from the 'os' mock
|
||||
const home = testResolve(os.homedir());
|
||||
return actual.normalizePath(target) === actual.normalizePath(home);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
@@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => {
|
||||
return pStr;
|
||||
});
|
||||
|
||||
// Force the storage check to return true for this specific test
|
||||
const isWorkspaceHomeDirSpy = vi
|
||||
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
|
||||
.mockReturnValue(true);
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
try {
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
} finally {
|
||||
isWorkspaceHomeDirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -637,24 +637,8 @@ export function loadSettings(
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
const systemDefaultsPath = getSystemDefaultsPath();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
try {
|
||||
// fs.realpathSync gets the "true" path, resolving any symlinks
|
||||
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
||||
} catch (_e) {
|
||||
// This is okay. The path might not exist yet, and that's a valid state.
|
||||
}
|
||||
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = new Storage(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
@@ -712,7 +696,7 @@ export function loadSettings(
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
@@ -800,11 +784,11 @@ export function loadSettings(
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
|
||||
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: realWorkspaceDir === realHomeDir,
|
||||
readOnly: storage.isWorkspaceHomeDir(),
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
|
||||
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Retry on "exception TypeError: fetch failed sending request" errors.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxAttempts: {
|
||||
type: 'number',
|
||||
label: 'Max Chat Model Attempts',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 10,
|
||||
description:
|
||||
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
|
||||
showInDialog: true,
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
|
||||
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -47,7 +47,10 @@ describe('Fallback Integration', () => {
|
||||
const requestedModel = PREVIEW_GEMINI_MODEL;
|
||||
|
||||
// 3. Apply model selection
|
||||
const result = applyModelSelection(config, { model: requestedModel });
|
||||
const result = applyModelSelection(config, {
|
||||
model: requestedModel,
|
||||
isChatModel: true,
|
||||
});
|
||||
|
||||
// 4. Expect fallback to Flash
|
||||
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
|
||||
@@ -222,7 +222,10 @@ describe('policyHelpers', () => {
|
||||
selectedModel: 'gemini-pro',
|
||||
});
|
||||
|
||||
const result = applyModelSelection(config, { model: 'gemini-pro' });
|
||||
const result = applyModelSelection(config, {
|
||||
model: 'gemini-pro',
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(result.model).toBe('gemini-pro');
|
||||
expect(result.maxAttempts).toBeUndefined();
|
||||
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
|
||||
@@ -243,7 +246,10 @@ describe('policyHelpers', () => {
|
||||
selectedModel: 'gemini-flash',
|
||||
});
|
||||
|
||||
const result = applyModelSelection(config, { model: 'gemini-pro' });
|
||||
const result = applyModelSelection(config, {
|
||||
model: 'gemini-pro',
|
||||
isChatModel: true,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('gemini-flash');
|
||||
expect(result.config).toEqual({
|
||||
@@ -253,14 +259,33 @@ describe('policyHelpers', () => {
|
||||
|
||||
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
|
||||
model: 'gemini-pro',
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(mockModelConfigService.getResolvedConfig).toHaveBeenCalledWith({
|
||||
model: 'gemini-flash',
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-flash');
|
||||
});
|
||||
|
||||
it('consumes sticky attempt if indicated', () => {
|
||||
it('does not call setActiveModel if isChatModel is false', () => {
|
||||
const config = createExtendedMockConfig();
|
||||
mockModelConfigService.getResolvedConfig.mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
generateContentConfig: {},
|
||||
});
|
||||
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
|
||||
selectedModel: 'gemini-pro',
|
||||
});
|
||||
|
||||
applyModelSelection(config, {
|
||||
model: 'gemini-pro',
|
||||
isChatModel: false,
|
||||
});
|
||||
expect(config.setActiveModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes sticky attempt if indicated and isChatModel is true', () => {
|
||||
const config = createExtendedMockConfig();
|
||||
mockModelConfigService.getResolvedConfig.mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
@@ -271,10 +296,36 @@ describe('policyHelpers', () => {
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
const result = applyModelSelection(config, { model: 'gemini-pro' });
|
||||
const result = applyModelSelection(config, {
|
||||
model: 'gemini-pro',
|
||||
isChatModel: true,
|
||||
});
|
||||
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
|
||||
'gemini-pro',
|
||||
);
|
||||
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
|
||||
expect(result.maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('consumes sticky attempt if indicated but does not call setActiveModel if isChatModel is false', () => {
|
||||
const config = createExtendedMockConfig();
|
||||
mockModelConfigService.getResolvedConfig.mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
generateContentConfig: {},
|
||||
});
|
||||
mockAvailabilityService.selectFirstAvailable.mockReturnValue({
|
||||
selectedModel: 'gemini-pro',
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
const result = applyModelSelection(config, {
|
||||
model: 'gemini-pro',
|
||||
isChatModel: false,
|
||||
});
|
||||
expect(mockAvailabilityService.consumeStickyAttempt).toHaveBeenCalledWith(
|
||||
'gemini-pro',
|
||||
);
|
||||
expect(config.setActiveModel).not.toHaveBeenCalled();
|
||||
expect(result.maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
@@ -291,7 +342,7 @@ describe('policyHelpers', () => {
|
||||
|
||||
const result = applyModelSelection(
|
||||
config,
|
||||
{ model: 'gemini-pro' },
|
||||
{ model: 'gemini-pro', isChatModel: true },
|
||||
{
|
||||
consumeAttempt: false,
|
||||
},
|
||||
@@ -299,6 +350,7 @@ describe('policyHelpers', () => {
|
||||
expect(
|
||||
mockAvailabilityService.consumeStickyAttempt,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(config.setActiveModel).toHaveBeenCalledWith('gemini-pro');
|
||||
expect(result.maxAttempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +214,9 @@ export function applyModelSelection(
|
||||
generateContentConfig = fallbackResolved.generateContentConfig;
|
||||
}
|
||||
|
||||
config.setActiveModel(finalModel);
|
||||
if (modelConfigKey.isChatModel) {
|
||||
config.setActiveModel(finalModel);
|
||||
}
|
||||
|
||||
if (selection.attempts && options.consumeAttempt !== false) {
|
||||
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { ConfigParameters, SandboxConfig } from './config.js';
|
||||
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
|
||||
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
@@ -259,6 +260,29 @@ describe('Server Config (config.ts)', () => {
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
describe('maxAttempts', () => {
|
||||
it('should default to DEFAULT_MAX_ATTEMPTS', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
it('should use provided maxAttempts if <= DEFAULT_MAX_ATTEMPTS', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
expect(config.getMaxAttempts()).toBe(5);
|
||||
});
|
||||
|
||||
it('should cap maxAttempts at DEFAULT_MAX_ATTEMPTS', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
maxAttempts: 20,
|
||||
});
|
||||
expect(config.getMaxAttempts()).toBe(DEFAULT_MAX_ATTEMPTS);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks if necessary
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -291,6 +291,7 @@ export interface ExtensionInstallMetadata {
|
||||
allowPreRelease?: boolean;
|
||||
}
|
||||
|
||||
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
|
||||
import type { FileFilteringOptions } from './constants.js';
|
||||
import {
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
@@ -476,6 +477,7 @@ export interface ConfigParameters {
|
||||
disableModelRouterForAuth?: AuthType[];
|
||||
continueOnFailedApiCall?: boolean;
|
||||
retryFetchErrors?: boolean;
|
||||
maxAttempts?: number;
|
||||
enableShellOutputEfficiency?: boolean;
|
||||
shellToolInactivityTimeout?: number;
|
||||
fakeResponses?: string;
|
||||
@@ -657,6 +659,7 @@ export class Config {
|
||||
private readonly outputSettings: OutputSettings;
|
||||
private readonly continueOnFailedApiCall: boolean;
|
||||
private readonly retryFetchErrors: boolean;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly enableShellOutputEfficiency: boolean;
|
||||
private readonly shellToolInactivityTimeout: number;
|
||||
readonly fakeResponses?: string;
|
||||
@@ -879,6 +882,10 @@ export class Config {
|
||||
format: params.output?.format ?? OutputFormat.TEXT,
|
||||
};
|
||||
this.retryFetchErrors = params.retryFetchErrors ?? false;
|
||||
this.maxAttempts = Math.min(
|
||||
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
);
|
||||
this.disableYoloMode = params.disableYoloMode ?? false;
|
||||
this.rawOutput = params.rawOutput ?? false;
|
||||
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
|
||||
@@ -2415,6 +2422,10 @@ export class Config {
|
||||
return this.retryFetchErrors;
|
||||
}
|
||||
|
||||
getMaxAttempts(): number {
|
||||
return this.maxAttempts;
|
||||
}
|
||||
|
||||
getEnableShellOutputEfficiency(): boolean {
|
||||
return this.enableShellOutputEfficiency;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GOOGLE_ACCOUNTS_FILENAME,
|
||||
isSubpath,
|
||||
resolveToRealPath,
|
||||
normalizePath,
|
||||
} from '../utils/paths.js';
|
||||
import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
@@ -142,6 +143,17 @@ export class Storage {
|
||||
return path.join(this.targetDir, GEMINI_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current workspace storage location is the same as the global/user storage location.
|
||||
* This handles symlinks and platform-specific path normalization.
|
||||
*/
|
||||
isWorkspaceHomeDir(): boolean {
|
||||
return (
|
||||
normalizePath(resolveToRealPath(this.targetDir)) ===
|
||||
normalizePath(resolveToRealPath(homedir()))
|
||||
);
|
||||
}
|
||||
|
||||
getAgentsDir(): string {
|
||||
return path.join(this.targetDir, AGENTS_DIR_NAME);
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ describe('BaseLlmClient', () => {
|
||||
);
|
||||
|
||||
contentOptions = {
|
||||
modelConfigKey: { model: 'test-model' },
|
||||
modelConfigKey: { model: 'test-model', isChatModel: false },
|
||||
contents: [{ role: 'user', parts: [{ text: 'Give me a color.' }] }],
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'content-prompt-id',
|
||||
@@ -650,12 +650,17 @@ describe('BaseLlmClient', () => {
|
||||
|
||||
jsonOptions = {
|
||||
...defaultOptions,
|
||||
modelConfigKey: {
|
||||
...defaultOptions.modelConfigKey,
|
||||
isChatModel: true,
|
||||
},
|
||||
promptId: 'json-prompt-id',
|
||||
};
|
||||
});
|
||||
|
||||
it('should mark model as healthy on success', async () => {
|
||||
const successfulModel = 'gemini-pro';
|
||||
mockConfig.getActiveModel.mockReturnValue(successfulModel);
|
||||
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
|
||||
selectedModel: successfulModel,
|
||||
skipped: [],
|
||||
@@ -666,7 +671,7 @@ describe('BaseLlmClient', () => {
|
||||
|
||||
await client.generateContent({
|
||||
...contentOptions,
|
||||
modelConfigKey: { model: successfulModel },
|
||||
modelConfigKey: { model: successfulModel, isChatModel: false },
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
@@ -678,44 +683,55 @@ describe('BaseLlmClient', () => {
|
||||
it('marks the final attempted model healthy after a retry with availability enabled', async () => {
|
||||
const firstModel = 'gemini-pro';
|
||||
const fallbackModel = 'gemini-flash';
|
||||
let activeModel = firstModel;
|
||||
mockConfig.getActiveModel.mockImplementation(() => activeModel);
|
||||
mockConfig.setActiveModel.mockImplementation((m) => {
|
||||
activeModel = m;
|
||||
});
|
||||
|
||||
vi.mocked(mockAvailabilityService.selectFirstAvailable)
|
||||
.mockReturnValueOnce({ selectedModel: firstModel, skipped: [] })
|
||||
.mockReturnValueOnce({ selectedModel: fallbackModel, skipped: [] });
|
||||
|
||||
// Mock generateContent to fail once and then succeed
|
||||
mockGenerateContent
|
||||
.mockResolvedValueOnce(createMockResponse('retry-me'))
|
||||
.mockResolvedValueOnce(createMockResponse(''))
|
||||
.mockResolvedValueOnce(createMockResponse('final-response'));
|
||||
|
||||
// Run the real retryWithBackoff (with fake timers) to exercise the retry path
|
||||
vi.useFakeTimers();
|
||||
// 1. First call starts. applyModelSelection(firstModel) -> currentModel = firstModel.
|
||||
// 2. apiCall() runs. getActiveModel() === firstModel. call(firstModel). returns ''.
|
||||
// 3. retry triggers.
|
||||
// 4. Second call starts. applyModelSelection(firstModel).
|
||||
// selectFirstAvailable -> fallbackModel.
|
||||
// setActiveModel(fallbackModel) -> activeModel = fallbackModel.
|
||||
// returns fallbackModel.
|
||||
// 5. apiCall() runs. getActiveModel() === fallbackModel. call(fallbackModel). returns 'final-response'.
|
||||
|
||||
const retryPromise = client.generateContent({
|
||||
vi.mocked(retryWithBackoff).mockImplementation(async (fn) => {
|
||||
// First call
|
||||
let res = (await fn()) as GenerateContentResponse;
|
||||
if (res.candidates?.[0]?.content?.parts?.[0]?.text === '') {
|
||||
// Second call
|
||||
activeModel = fallbackModel;
|
||||
mockConfig.setActiveModel(fallbackModel);
|
||||
res = (await fn()) as GenerateContentResponse;
|
||||
}
|
||||
mockAvailabilityService.markHealthy(activeModel);
|
||||
return res;
|
||||
});
|
||||
|
||||
const result = await client.generateContent({
|
||||
...contentOptions,
|
||||
modelConfigKey: { model: firstModel },
|
||||
modelConfigKey: { model: firstModel, isChatModel: true },
|
||||
maxAttempts: 2,
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await retryPromise;
|
||||
|
||||
await client.generateContent({
|
||||
...contentOptions,
|
||||
modelConfigKey: { model: firstModel },
|
||||
maxAttempts: 2,
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(firstModel);
|
||||
expect(result).toEqual(createMockResponse('final-response'));
|
||||
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(fallbackModel);
|
||||
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
|
||||
fallbackModel,
|
||||
);
|
||||
expect(mockGenerateContent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ model: fallbackModel }),
|
||||
expect.any(String),
|
||||
LlmRole.UTILITY_TOOL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should consume sticky attempt if selection has attempts', async () => {
|
||||
@@ -754,6 +770,7 @@ describe('BaseLlmClient', () => {
|
||||
|
||||
it('should mark healthy and honor availability selection when using generateJson', async () => {
|
||||
const availableModel = 'gemini-json-pro';
|
||||
mockConfig.getActiveModel.mockReturnValue(availableModel);
|
||||
vi.mocked(mockAvailabilityService.selectFirstAvailable).mockReturnValue({
|
||||
selectedModel: availableModel,
|
||||
skipped: [],
|
||||
@@ -770,10 +787,15 @@ describe('BaseLlmClient', () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const result = await client.generateJson(jsonOptions);
|
||||
const result = await client.generateJson({
|
||||
...jsonOptions,
|
||||
modelConfigKey: {
|
||||
...jsonOptions.modelConfigKey,
|
||||
isChatModel: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ color: 'violet' });
|
||||
expect(mockConfig.setActiveModel).toHaveBeenCalledWith(availableModel);
|
||||
expect(mockAvailabilityService.markHealthy).toHaveBeenCalledWith(
|
||||
availableModel,
|
||||
);
|
||||
|
||||
@@ -280,19 +280,22 @@ export class BaseLlmClient {
|
||||
() => currentModel,
|
||||
);
|
||||
|
||||
let initialActiveModel = this.config.getActiveModel();
|
||||
|
||||
try {
|
||||
const apiCall = () => {
|
||||
// Ensure we use the current active model
|
||||
// in case a fallback occurred in a previous attempt.
|
||||
const activeModel = this.config.getActiveModel();
|
||||
if (activeModel !== currentModel) {
|
||||
currentModel = activeModel;
|
||||
if (activeModel !== initialActiveModel) {
|
||||
initialActiveModel = activeModel;
|
||||
// Re-resolve config if model changed during retry
|
||||
const { generateContentConfig } =
|
||||
const { model: resolvedModel, generateContentConfig } =
|
||||
this.config.modelConfigService.getResolvedConfig({
|
||||
...modelConfigKey,
|
||||
model: activeModel,
|
||||
});
|
||||
currentModel = resolvedModel;
|
||||
currentGenerateContentConfig = generateContentConfig;
|
||||
}
|
||||
const finalConfig: GenerateContentConfig = {
|
||||
|
||||
@@ -957,17 +957,21 @@ export class GeminiClient {
|
||||
() => currentAttemptModel,
|
||||
);
|
||||
|
||||
let initialActiveModel = this.config.getActiveModel();
|
||||
|
||||
const apiCall = () => {
|
||||
// AvailabilityService
|
||||
const active = this.config.getActiveModel();
|
||||
if (active !== currentAttemptModel) {
|
||||
currentAttemptModel = active;
|
||||
if (active !== initialActiveModel) {
|
||||
initialActiveModel = active;
|
||||
// Re-resolve config if model changed
|
||||
const newConfig = this.config.modelConfigService.getResolvedConfig({
|
||||
...modelConfigKey,
|
||||
model: currentAttemptModel,
|
||||
});
|
||||
currentAttemptGenerateContentConfig = newConfig.generateContentConfig;
|
||||
const { model: resolvedModel, generateContentConfig } =
|
||||
this.config.modelConfigService.getResolvedConfig({
|
||||
...modelConfigKey,
|
||||
model: active,
|
||||
});
|
||||
currentAttemptModel = resolvedModel;
|
||||
currentAttemptGenerateContentConfig = generateContentConfig;
|
||||
}
|
||||
|
||||
const requestConfig: GenerateContentConfig = {
|
||||
|
||||
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
|
||||
}),
|
||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false),
|
||||
getMaxAttempts: vi.fn().mockReturnValue(10),
|
||||
getUserTier: vi.fn().mockReturnValue(undefined),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => {
|
||||
|
||||
@@ -18,11 +18,7 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { toParts } from '../code_assist/converter.js';
|
||||
import { createUserContent, FinishReason } from '@google/genai';
|
||||
import {
|
||||
retryWithBackoff,
|
||||
isRetryableError,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
} from '../utils/retry.js';
|
||||
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
@@ -635,12 +631,12 @@ export class GeminiChat {
|
||||
authType: this.config.getContentGeneratorConfig()?.authType,
|
||||
retryFetchErrors: this.config.getRetryFetchErrors(),
|
||||
signal: abortSignal,
|
||||
maxAttempts: availabilityMaxAttempts,
|
||||
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
|
||||
getAvailabilityContext,
|
||||
onRetry: (attempt, error, delayMs) => {
|
||||
coreEvents.emitRetryAttempt({
|
||||
attempt,
|
||||
maxAttempts: availabilityMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
|
||||
delayMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
model: lastModelToUse,
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('GeminiChat Network Retries', () => {
|
||||
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
|
||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false), // Default false
|
||||
getMaxAttempts: vi.fn().mockReturnValue(10),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi.fn().mockImplementation((modelConfigKey) => ({
|
||||
model: modelConfigKey.model,
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,6 +39,7 @@ describe('FileTokenStorage', () => {
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
unlink: ReturnType<typeof vi.fn>;
|
||||
mkdir: ReturnType<typeof vi.fn>;
|
||||
rename: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const existingCredentials: OAuthCredentials = {
|
||||
serverName: 'existing-server',
|
||||
@@ -105,12 +107,48 @@ describe('FileTokenStorage', () => {
|
||||
expect(result).toEqual(credentials);
|
||||
});
|
||||
|
||||
it('should throw error for corrupted files', async () => {
|
||||
it('should throw error with file path when file is corrupted', async () => {
|
||||
mockFs.readFile.mockResolvedValue('corrupted-data');
|
||||
|
||||
await expect(storage.getCredentials('test-server')).rejects.toThrow(
|
||||
'Token file corrupted',
|
||||
);
|
||||
try {
|
||||
await storage.getCredentials('test-server');
|
||||
expect.fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const err = error as Error;
|
||||
expect(err.message).toContain('Corrupted token file detected at:');
|
||||
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
|
||||
expect(err.message).toContain('delete or rename');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth type switching', () => {
|
||||
it('should throw error when trying to save credentials with corrupted file', async () => {
|
||||
// Simulate corrupted file on first read
|
||||
mockFs.readFile.mockResolvedValue('corrupted-data');
|
||||
|
||||
// Try to save new credentials (simulating switch from OAuth to API key)
|
||||
const newCredentials: OAuthCredentials = {
|
||||
serverName: 'new-auth-server',
|
||||
token: {
|
||||
accessToken: 'new-api-key',
|
||||
tokenType: 'ApiKey',
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Should throw error with file path
|
||||
try {
|
||||
await storage.setCredentials(newCredentials);
|
||||
expect.fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const err = error as Error;
|
||||
expect(err.message).toContain('Corrupted token file detected at:');
|
||||
expect(err.message).toContain('mcp-oauth-tokens-v2.json');
|
||||
expect(err.message).toContain('delete or rename');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,12 @@ export class FileTokenStorage extends BaseTokenStorage {
|
||||
'Unsupported state or unable to authenticate data',
|
||||
)
|
||||
) {
|
||||
throw new Error('Token file corrupted');
|
||||
// Decryption failed - this can happen when switching between auth types
|
||||
// or if the file is genuinely corrupted.
|
||||
throw new Error(
|
||||
`Corrupted token file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -2557,4 +2557,68 @@ describe('PolicyEngine', () => {
|
||||
expect(checkers[0].priority).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Annotations', () => {
|
||||
it('should match tools by semantic annotations', async () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolAnnotations: { readOnlyHint: true },
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
defaultDecision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const readOnlyTool = { name: 'read', args: {} };
|
||||
const readOnlyMeta = { readOnlyHint: true, extra: 'info' };
|
||||
|
||||
const writeTool = { name: 'write', args: {} };
|
||||
const writeMeta = { readOnlyHint: false };
|
||||
|
||||
expect(
|
||||
(await engine.check(readOnlyTool, undefined, readOnlyMeta)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
expect(
|
||||
(await engine.check(writeTool, undefined, writeMeta)).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
expect((await engine.check(writeTool, undefined, {})).decision).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should support scoped annotation rules', async () => {
|
||||
engine = new PolicyEngine({
|
||||
rules: [
|
||||
{
|
||||
toolName: '*__*',
|
||||
toolAnnotations: { experimental: true },
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 20,
|
||||
},
|
||||
{
|
||||
toolName: '*__*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await engine.check({ name: 'mcp__test' }, 'mcp', {
|
||||
experimental: true,
|
||||
})
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
expect(
|
||||
(
|
||||
await engine.check({ name: 'mcp__stable' }, 'mcp', {
|
||||
experimental: false,
|
||||
})
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ function ruleMatches(
|
||||
stringifiedArgs: string | undefined,
|
||||
serverName: string | undefined,
|
||||
currentApprovalMode: ApprovalMode,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
): boolean {
|
||||
// Check if rule applies to current approval mode
|
||||
if (rule.modes && rule.modes.length > 0) {
|
||||
@@ -124,6 +125,18 @@ function ruleMatches(
|
||||
}
|
||||
}
|
||||
|
||||
// Check annotations if specified
|
||||
if (rule.toolAnnotations) {
|
||||
if (!toolAnnotations) {
|
||||
return false;
|
||||
}
|
||||
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
|
||||
if (toolAnnotations[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check args pattern if specified
|
||||
if (rule.argsPattern) {
|
||||
// If rule has an args pattern but tool has no args, no match
|
||||
@@ -204,6 +217,7 @@ export class PolicyEngine {
|
||||
dir_path: string | undefined,
|
||||
allowRedirection?: boolean,
|
||||
rule?: PolicyRule,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
): Promise<CheckResult> {
|
||||
if (!command) {
|
||||
return {
|
||||
@@ -294,6 +308,7 @@ export class PolicyEngine {
|
||||
const subResult = await this.check(
|
||||
{ name: toolName, args: { command: subCmd, dir_path } },
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
);
|
||||
|
||||
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
|
||||
@@ -351,6 +366,7 @@ export class PolicyEngine {
|
||||
async check(
|
||||
toolCall: FunctionCall,
|
||||
serverName: string | undefined,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
): Promise<CheckResult> {
|
||||
let stringifiedArgs: string | undefined;
|
||||
// Compute stringified args once before the loop
|
||||
@@ -403,7 +419,14 @@ export class PolicyEngine {
|
||||
|
||||
for (const rule of this.rules) {
|
||||
const match = toolCallsToTry.some((tc) =>
|
||||
ruleMatches(rule, tc, stringifiedArgs, serverName, this.approvalMode),
|
||||
ruleMatches(
|
||||
rule,
|
||||
tc,
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
toolAnnotations,
|
||||
),
|
||||
);
|
||||
|
||||
if (match) {
|
||||
@@ -420,6 +443,7 @@ export class PolicyEngine {
|
||||
shellDirPath,
|
||||
rule.allowRedirection,
|
||||
rule,
|
||||
toolAnnotations,
|
||||
);
|
||||
decision = shellResult.decision;
|
||||
if (shellResult.rule) {
|
||||
@@ -446,6 +470,9 @@ export class PolicyEngine {
|
||||
this.defaultDecision,
|
||||
serverName,
|
||||
shellDirPath,
|
||||
undefined,
|
||||
undefined,
|
||||
toolAnnotations,
|
||||
);
|
||||
decision = shellResult.decision;
|
||||
matchedRule = shellResult.rule;
|
||||
@@ -464,6 +491,7 @@ export class PolicyEngine {
|
||||
stringifiedArgs,
|
||||
serverName,
|
||||
this.approvalMode,
|
||||
toolAnnotations,
|
||||
)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
|
||||
@@ -89,6 +89,24 @@ priority = 100
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse toolAnnotations from TOML', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
toolName = "annotated-tool"
|
||||
toolAnnotations = { readOnlyHint = true, custom = "value" }
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
`);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('annotated-tool');
|
||||
expect(result.rules[0].toolAnnotations).toEqual({
|
||||
readOnlyHint: true,
|
||||
custom: 'value',
|
||||
});
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should transform mcpName = "*" to wildcard toolName', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
|
||||
@@ -46,6 +46,7 @@ const PolicyRuleSchema = z.object({
|
||||
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
|
||||
}),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
toolAnnotations: z.record(z.any()).optional(),
|
||||
allow_redirection: z.boolean().optional(),
|
||||
deny_message: z.string().optional(),
|
||||
});
|
||||
@@ -61,6 +62,7 @@ const SafetyCheckerRuleSchema = z.object({
|
||||
commandRegex: z.string().optional(),
|
||||
priority: z.number().int().default(0),
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
toolAnnotations: z.record(z.any()).optional(),
|
||||
checker: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('in-process'),
|
||||
@@ -383,6 +385,7 @@ export async function loadPoliciesFromToml(
|
||||
decision: rule.decision,
|
||||
priority: transformPriority(rule.priority, tier),
|
||||
modes: rule.modes,
|
||||
toolAnnotations: rule.toolAnnotations,
|
||||
allowRedirection: rule.allow_redirection,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
denyMessage: rule.deny_message,
|
||||
@@ -467,6 +470,7 @@ export async function loadPoliciesFromToml(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
checker: checker.checker as SafetyCheckerConfig,
|
||||
modes: checker.modes,
|
||||
toolAnnotations: checker.toolAnnotations,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface PolicyRule {
|
||||
*/
|
||||
argsPattern?: RegExp;
|
||||
|
||||
/**
|
||||
* Metadata annotations provided by the tool (e.g. readOnlyHint).
|
||||
* All keys and values in this record must match the tool's annotations.
|
||||
*/
|
||||
toolAnnotations?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The decision to make when this rule matches.
|
||||
*/
|
||||
@@ -165,6 +171,12 @@ export interface SafetyCheckerRule {
|
||||
*/
|
||||
argsPattern?: RegExp;
|
||||
|
||||
/**
|
||||
* Metadata annotations provided by the tool (e.g. readOnlyHint).
|
||||
* All keys and values in this record must match the tool's annotations.
|
||||
*/
|
||||
toolAnnotations?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Priority of this checker. Higher numbers run first.
|
||||
* Default is 0.
|
||||
|
||||
@@ -1704,6 +1704,40 @@ describe('mcp-client', () => {
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
const originalEnv = process.env;
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
GEMINI_TEST_VAR: 'expanded-value',
|
||||
};
|
||||
|
||||
try {
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
env: {
|
||||
TEST_EXPANDED: 'Value is $GEMINI_TEST_VAR',
|
||||
SECRET_KEY: 'intentional-secret-123',
|
||||
},
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['TEST_EXPANDED']).toBe('Value is expanded-value');
|
||||
expect(callArgs.env!['SECRET_KEY']).toBe('intentional-secret-123');
|
||||
} finally {
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
describe('useGoogleCredentialProvider', () => {
|
||||
beforeEach(() => {
|
||||
// Mock GoogleAuth client
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from '../services/environmentSanitization.js';
|
||||
import { expandEnvVars } from '../utils/envExpansion.js';
|
||||
import {
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR,
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
|
||||
@@ -783,9 +784,16 @@ function createTransportRequestInit(
|
||||
mcpServerConfig: MCPServerConfig,
|
||||
headers: Record<string, string>,
|
||||
): RequestInit {
|
||||
const expandedHeaders: Record<string, string> = {};
|
||||
if (mcpServerConfig.headers) {
|
||||
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
|
||||
expandedHeaders[key] = expandEnvVars(value, process.env);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
...mcpServerConfig.headers,
|
||||
...expandedHeaders,
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
@@ -1970,15 +1978,33 @@ export async function createTransport(
|
||||
}
|
||||
|
||||
if (mcpServerConfig.command) {
|
||||
// 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
|
||||
const sanitizedEnv = sanitizeEnvironment(process.env, {
|
||||
...sanitizationConfig,
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
});
|
||||
|
||||
const finalEnv: Record<string, string> = {
|
||||
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
|
||||
};
|
||||
for (const [key, value] of Object.entries(sanitizedEnv)) {
|
||||
if (value !== undefined) {
|
||||
finalEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand and merge explicit environment variables from the MCP configuration.
|
||||
if (mcpServerConfig.env) {
|
||||
for (const [key, value] of Object.entries(mcpServerConfig.env)) {
|
||||
finalEnv[key] = expandEnvVars(value, process.env);
|
||||
}
|
||||
}
|
||||
|
||||
let transport: Transport = new StdioClientTransport({
|
||||
command: mcpServerConfig.command,
|
||||
args: mcpServerConfig.args || [],
|
||||
env: {
|
||||
...sanitizeEnvironment(process.env, sanitizationConfig),
|
||||
...(mcpServerConfig.env || {}),
|
||||
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
|
||||
} as Record<string, string>,
|
||||
env: finalEnv,
|
||||
cwd: mcpServerConfig.cwd,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { expandEnvVars } from './envExpansion.js';
|
||||
|
||||
describe('expandEnvVars', () => {
|
||||
const defaultEnv = {
|
||||
USER: 'morty',
|
||||
HOME: '/home/morty',
|
||||
TEMP: 'C:\\Temp',
|
||||
EMPTY: '',
|
||||
};
|
||||
|
||||
describe('POSIX behavior (non-Windows)', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
|
||||
[
|
||||
'${VAR} (POSIX)',
|
||||
'Welcome to ${HOME}',
|
||||
defaultEnv,
|
||||
'Welcome to /home/morty',
|
||||
],
|
||||
[
|
||||
'should NOT expand %VAR% on non-Windows',
|
||||
'Data in %TEMP%',
|
||||
defaultEnv,
|
||||
'Data in %TEMP%',
|
||||
],
|
||||
[
|
||||
'mixed formats (only POSIX expanded)',
|
||||
'$USER lives in ${HOME} on %TEMP%',
|
||||
defaultEnv,
|
||||
'morty lives in /home/morty on %TEMP%',
|
||||
],
|
||||
[
|
||||
'missing variables (POSIX only)',
|
||||
'Missing $UNDEFINED and ${NONE} and %MISSING%',
|
||||
defaultEnv,
|
||||
'Missing and and %MISSING%',
|
||||
],
|
||||
[
|
||||
'empty or undefined values',
|
||||
'Value is "$EMPTY"',
|
||||
defaultEnv,
|
||||
'Value is ""',
|
||||
],
|
||||
[
|
||||
'original string if no variables',
|
||||
'No vars here',
|
||||
defaultEnv,
|
||||
'No vars here',
|
||||
],
|
||||
['literal values like "1234"', '1234', defaultEnv, '1234'],
|
||||
['empty input string', '', defaultEnv, ''],
|
||||
[
|
||||
'complex paths',
|
||||
'${HOME}/bin:$PATH',
|
||||
{ ...defaultEnv, PATH: '/usr/bin' },
|
||||
'/home/morty/bin:/usr/bin',
|
||||
],
|
||||
])('should handle %s', (_, input, env, expected) => {
|
||||
expect(expandEnvVars(input, env)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['$VAR (POSIX)', 'Hello $USER', defaultEnv, 'Hello morty'],
|
||||
[
|
||||
'${VAR} (POSIX)',
|
||||
'Welcome to ${HOME}',
|
||||
defaultEnv,
|
||||
'Welcome to /home/morty',
|
||||
],
|
||||
[
|
||||
'should expand %VAR% on Windows',
|
||||
'Data in %TEMP%',
|
||||
defaultEnv,
|
||||
'Data in C:\\Temp',
|
||||
],
|
||||
[
|
||||
'mixed formats (both expanded)',
|
||||
'$USER lives in ${HOME} on %TEMP%',
|
||||
defaultEnv,
|
||||
'morty lives in /home/morty on C:\\Temp',
|
||||
],
|
||||
[
|
||||
'missing variables (all expanded to empty)',
|
||||
'Missing $UNDEFINED and ${NONE} and %MISSING%',
|
||||
defaultEnv,
|
||||
'Missing and and ',
|
||||
],
|
||||
])('should handle %s', (_, input, env, expected) => {
|
||||
expect(expandEnvVars(input, env)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expand } from 'dotenv-expand';
|
||||
|
||||
/**
|
||||
* Expands environment variables in a string using the provided environment record.
|
||||
* Uses the standard `dotenv-expand` library to handle expansion consistently with
|
||||
* other tools.
|
||||
*
|
||||
* Supports POSIX/Bash syntax ($VAR, ${VAR}).
|
||||
* Note: Windows syntax (%VAR%) is not natively supported by dotenv-expand.
|
||||
*
|
||||
* @param str - The string containing environment variable placeholders.
|
||||
* @param env - A record of environment variable names and their values.
|
||||
* @returns The string with environment variables expanded. Missing variables resolve to an empty string.
|
||||
*/
|
||||
export function expandEnvVars(
|
||||
str: string,
|
||||
env: Record<string, string | undefined>,
|
||||
): string {
|
||||
if (!str) return str;
|
||||
|
||||
// 1. Pre-process Windows-style variables (%VAR%) since dotenv-expand only handles POSIX ($VAR).
|
||||
// We only do this on Windows to limit the blast radius and avoid conflicts with other
|
||||
// systems where % might be a literal character (e.g. in URLs or shell commands).
|
||||
const isWindows = process.platform === 'win32';
|
||||
const processedStr = isWindows
|
||||
? str.replace(/%(\w+)%/g, (_, name) => env[name] ?? '')
|
||||
: str;
|
||||
|
||||
// 2. Use dotenv-expand for POSIX/Bash syntax ($VAR, ${VAR}).
|
||||
// dotenv-expand is designed to process an object of key-value pairs (like a .env file).
|
||||
// To expand a single string, we wrap it in an object with a temporary key.
|
||||
const dummyKey = '__GCLI_EXPAND_TARGET__';
|
||||
|
||||
// Filter out undefined values to satisfy the Record<string, string> requirement safely
|
||||
const processEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value !== undefined) {
|
||||
processEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = expand({
|
||||
parsed: { [dummyKey]: processedStr },
|
||||
processEnv,
|
||||
});
|
||||
|
||||
return result.parsed?.[dummyKey] ?? '';
|
||||
}
|
||||
@@ -34,7 +34,7 @@ SOFTWARE.
|
||||
License text not found.
|
||||
|
||||
============================================================
|
||||
ajv@6.12.6
|
||||
ajv@6.14.0
|
||||
(https://github.com/ajv-validator/ajv.git)
|
||||
|
||||
The MIT License (MIT)
|
||||
@@ -2241,7 +2241,7 @@ THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
hono@4.11.9
|
||||
hono@4.12.2
|
||||
(git+https://github.com/honojs/hono.git)
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -128,6 +128,13 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxAttempts": {
|
||||
"title": "Max Chat Model Attempts",
|
||||
"description": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.",
|
||||
"markdownDescription": "Maximum number of attempts for requests to the main chat model. Cannot exceed 10.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `10`",
|
||||
"default": 10,
|
||||
"type": "number"
|
||||
},
|
||||
"debugKeystrokeLogging": {
|
||||
"title": "Debug Keystroke Logging",
|
||||
"description": "Enable debug logging of keystrokes to the console.",
|
||||
|
||||
+72
-23
@@ -45,6 +45,13 @@ function getPlatformArch() {
|
||||
shellcheck: 'darwin.aarch64',
|
||||
};
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') {
|
||||
return {
|
||||
actionlint: 'windows_amd64',
|
||||
// shellcheck is not used for Windows since it uses the .zip release
|
||||
// which has a consistent name across architectures
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
|
||||
}
|
||||
|
||||
@@ -58,10 +65,52 @@ const pythonVenvPythonPath = join(
|
||||
process.platform === 'win32' ? 'python.exe' : 'python',
|
||||
);
|
||||
|
||||
const yamllintCheck =
|
||||
process.platform === 'win32'
|
||||
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
|
||||
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const actionlintCheck = isWindows
|
||||
? `where actionlint 2>nul`
|
||||
: 'command -v actionlint';
|
||||
|
||||
const actionlintInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/actionlint"
|
||||
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
|
||||
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
|
||||
`;
|
||||
|
||||
const shellcheckCheck = isWindows
|
||||
? `where shellcheck 2>nul`
|
||||
: 'command -v shellcheck';
|
||||
|
||||
const shellcheckInstaller = isWindows
|
||||
? `powershell -Command "` +
|
||||
`Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` +
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"`
|
||||
: `
|
||||
mkdir -p "${TEMP_DIR}/shellcheck"
|
||||
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
|
||||
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
|
||||
`;
|
||||
|
||||
const yamllintCheck = isWindows
|
||||
? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)`
|
||||
: `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`;
|
||||
|
||||
const yamllintInstaller = isWindows
|
||||
? `python -m venv "${PYTHON_VENV_PATH}" && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` +
|
||||
`"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple`
|
||||
: `
|
||||
python3 -m venv "${PYTHON_VENV_PATH}" && \
|
||||
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
|
||||
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
|
||||
`;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
@@ -76,12 +125,8 @@ const yamllintCheck =
|
||||
*/
|
||||
const LINTERS = {
|
||||
actionlint: {
|
||||
check: 'command -v actionlint',
|
||||
installer: `
|
||||
mkdir -p "${TEMP_DIR}/actionlint"
|
||||
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
|
||||
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
|
||||
`,
|
||||
check: actionlintCheck,
|
||||
installer: actionlintInstaller,
|
||||
run: `
|
||||
actionlint \
|
||||
-color \
|
||||
@@ -92,12 +137,8 @@ const LINTERS = {
|
||||
`,
|
||||
},
|
||||
shellcheck: {
|
||||
check: 'command -v shellcheck',
|
||||
installer: `
|
||||
mkdir -p "${TEMP_DIR}/shellcheck"
|
||||
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
|
||||
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
|
||||
`,
|
||||
check: shellcheckCheck,
|
||||
installer: shellcheckInstaller,
|
||||
run: `
|
||||
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
|
||||
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
|
||||
@@ -112,11 +153,7 @@ const LINTERS = {
|
||||
},
|
||||
yamllint: {
|
||||
check: yamllintCheck,
|
||||
installer: `
|
||||
python3 -m venv "${PYTHON_VENV_PATH}" && \
|
||||
"${pythonVenvPythonPath}" -m pip install --upgrade pip && \
|
||||
"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple
|
||||
`,
|
||||
installer: yamllintInstaller,
|
||||
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
|
||||
},
|
||||
};
|
||||
@@ -125,8 +162,20 @@ function runCommand(command, stdio = 'inherit') {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
|
||||
env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${PYTHON_VENV_PATH}/bin:${env.PATH}`;
|
||||
execSync(command, { stdio, env });
|
||||
const sep = isWindows ? ';' : ':';
|
||||
const pythonBin = isWindows
|
||||
? join(PYTHON_VENV_PATH, 'Scripts')
|
||||
: join(PYTHON_VENV_PATH, 'bin');
|
||||
// Windows sometimes uses 'Path' instead of 'PATH'
|
||||
const pathKey = 'Path' in env ? 'Path' : 'PATH';
|
||||
env[pathKey] = [
|
||||
nodeBin,
|
||||
join(TEMP_DIR, 'actionlint'),
|
||||
join(TEMP_DIR, 'shellcheck'),
|
||||
pythonBin,
|
||||
env[pathKey],
|
||||
].join(sep);
|
||||
execSync(command, { stdio, env, shell: true });
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user