diff --git a/packages/cli/src/config/extension-manager-hydration.test.ts b/packages/cli/src/config/extension-manager-hydration.test.ts new file mode 100644 index 0000000000..6746a1a74a --- /dev/null +++ b/packages/cli/src/config/extension-manager-hydration.test.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger, coreEvents } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + // Use actual implementations for loading skills and agents to test hydration + loadAgentsFromDirectory: actual.loadAgentsFromDirectory, + loadSkillsFromDir: actual.loadSkillsFromDir, + }; +}); + +describe('ExtensionManager hydration', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should hydrate skill body with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-skill'); + const extensionName = 'skill-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'API Key', + description: 'API Key', + envVar: 'MY_API_KEY', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create skill with variable + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + `--- +name: my-skill +description: test +--- +Use key: \${MY_API_KEY} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MY_API_KEY') return 'secret-123'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.skills).toHaveLength(1); + expect(extension.skills![0].body).toContain('Use key: secret-123'); + }); + + it('should hydrate agent system prompt with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-agent'); + const extensionName = 'agent-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Model Name', + description: 'Model', + envVar: 'MODEL_NAME', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + // Create agent with variable + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'my-agent.md'), + `--- +name: my-agent +description: test +--- +System using model: \${MODEL_NAME} +`, + ); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'MODEL_NAME') return 'gemini-pro'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.agents).toHaveLength(1); + const agent = extension.agents![0]; + if (agent.kind === 'local') { + expect(agent.promptConfig.systemPrompt).toContain( + 'System using model: gemini-pro', + ); + } else { + throw new Error('Expected local agent'); + } + }); + + it('should hydrate hooks with extension settings', async () => { + const sourceDir = path.join(tempDir, 'source-ext-hooks'); + const extensionName = 'hooks-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Hook Command', + description: 'Cmd', + envVar: 'HOOK_CMD', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const hooksDir = path.join(extensionPath, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + BeforeTool: [ + { + hooks: [ + { + type: 'command', + command: 'echo $HOOK_CMD', + }, + ], + }, + ], + }, + }), + ); + + // Enable hooks in settings + extensionManager = new ExtensionManager({ + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + tools: { enableHooks: true }, + hooksConfig: { enabled: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + + await extensionManager.loadExtensions(); + + extensionManager.setRequestSetting(async (setting) => { + if (setting.envVar === 'HOOK_CMD') return 'hello-world'; + return ''; + }); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.hooks).toBeDefined(); + expect(extension.hooks?.BeforeTool).toHaveLength(1); + expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe( + 'hello-world', + ); + }); + + it('should pick up new settings after restartExtension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-restart'); + const extensionName = 'restart-hydration-ext'; + createExtension({ + extensionsDir: sourceDir, + name: extensionName, + version: '1.0.0', + settings: [ + { + name: 'Value', + description: 'Val', + envVar: 'MY_VALUE', + }, + ], + installMetadata: { + type: 'local', + source: path.join(sourceDir, extensionName), + }, + }); + const extensionPath = path.join(sourceDir, extensionName); + + const skillsDir = path.join(extensionPath, 'skills'); + const skillSubdir = path.join(skillsDir, 'my-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + '---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}', + ); + + await extensionManager.loadExtensions(); + + // Initial setting + extensionManager.setRequestSetting(async () => 'first'); + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + expect(extension.skills![0].body).toContain('Value is: first'); + + const { updateSetting, ExtensionSettingScope } = await import( + './extensions/extensionSettings.js' + ); + const extensionConfig = + await extensionManager.loadExtensionConfig(extensionPath); + + const mockRequestSetting = vi.fn().mockResolvedValue('second'); + await updateSetting( + extensionConfig, + extension.id, + 'MY_VALUE', + mockRequestSetting, + ExtensionSettingScope.USER, + ); + + await extensionManager.restartExtension(extension); + + const reloadedExtension = extensionManager + .getExtensions() + .find((e) => e.name === extensionName)!; + expect(reloadedExtension.skills![0].body).toContain('Value is: second'); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 8dbbfe305b..7e05df554a 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -57,6 +57,7 @@ import { INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, type JsonObject, + type VariableContext, } from './extensions/variables.js'; import { getEnvContents, @@ -538,12 +539,14 @@ Would you like to attempt to install via "git clone" instead?`, extensionId, ExtensionSettingScope.USER, ); - workspaceSettings = await getScopedEnvContents( - config, - extensionId, - ExtensionSettingScope.WORKSPACE, - this.workspaceDir, - ); + if (isWorkspaceTrusted(this.settings).isTrusted) { + workspaceSettings = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + this.workspaceDir, + ); + } } const customEnv = { ...userSettings, ...workspaceSettings }; @@ -612,24 +615,63 @@ Would you like to attempt to install via "git clone" instead?`, ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); + const hydrationContext: VariableContext = { + extensionPath: effectiveExtensionPath, + workspacePath: this.workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + ...customEnv, + }; + let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; if ( this.settings.tools.enableHooks && this.settings.hooksConfig.enabled ) { - hooks = await this.loadExtensionHooks(effectiveExtensionPath, { - extensionPath: effectiveExtensionPath, - workspacePath: this.workspaceDir, - }); + hooks = await this.loadExtensionHooks( + effectiveExtensionPath, + hydrationContext, + ); } - const skills = await loadSkillsFromDir( + // Hydrate hooks with extension settings as environment variables + if (hooks && config.settings) { + const hookEnv: Record = {}; + for (const setting of config.settings) { + const value = customEnv[setting.envVar]; + if (value !== undefined) { + hookEnv[setting.envVar] = value; + } + } + + if (Object.keys(hookEnv).length > 0) { + for (const eventName of Object.keys(hooks)) { + const eventHooks = hooks[eventName as HookEventName]; + if (eventHooks) { + for (const definition of eventHooks) { + for (const hook of definition.hooks) { + // Merge existing env with new env vars, giving extension settings precedence. + hook.env = { ...hook.env, ...hookEnv }; + } + } + } + } + } + } + + let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); + skills = skills.map((skill) => + recursivelyHydrateStrings(skill, hydrationContext), + ); const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); + agentLoadResult.agents = agentLoadResult.agents.map((agent) => + recursivelyHydrateStrings(agent, hydrationContext), + ); // Log errors but don't fail the entire extension load for (const error of agentLoadResult.errors) { @@ -671,6 +713,14 @@ Would you like to attempt to install via "git clone" instead?`, } } + override async restartExtension( + extension: GeminiCLIExtension, + ): Promise { + const extensionDir = extension.path; + await this.unloadExtension(extension); + await this.loadExtension(extensionDir); + } + /** * Removes `extension` from the list of extensions and stops it if * appropriate. @@ -720,7 +770,7 @@ Would you like to attempt to install via "git clone" instead?`, private async loadExtensionHooks( extensionDir: string, - context: { extensionPath: string; workspacePath: string }, + context: VariableContext, ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 78506a9738..2ac28b2021 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -24,7 +24,7 @@ export type JsonValue = | JsonArray; export type VariableContext = { - [key in keyof typeof VARIABLE_SCHEMA]?: string; + [key: string]: string | undefined; }; export function validateVariables( @@ -33,7 +33,7 @@ export function validateVariables( ) { for (const key in schema) { const definition = schema[key]; - if (definition.required && !variables[key as keyof VariableContext]) { + if (definition.required && !variables[key]) { throw new Error(`Missing required variable: ${key}`); } } @@ -43,30 +43,33 @@ export function hydrateString(str: string, context: VariableContext): string { validateVariables(context, VARIABLE_SCHEMA); const regex = /\${(.*?)}/g; return str.replace(regex, (match, key) => - context[key as keyof VariableContext] == null - ? match - : (context[key as keyof VariableContext] as string), + context[key] == null ? match : context[key], ); } -export function recursivelyHydrateStrings( - obj: JsonValue, +export function recursivelyHydrateStrings( + obj: T, values: VariableContext, -): JsonValue { +): T { if (typeof obj === 'string') { - return hydrateString(obj, values); + return hydrateString(obj, values) as unknown as T; } if (Array.isArray(obj)) { - return obj.map((item) => recursivelyHydrateStrings(item, values)); + return obj.map((item) => + recursivelyHydrateStrings(item, values), + ) as unknown as T; } if (typeof obj === 'object' && obj !== null) { - const newObj: JsonObject = {}; + const newObj: Record = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { - newObj[key] = recursivelyHydrateStrings(obj[key], values); + newObj[key] = recursivelyHydrateStrings( + (obj as Record)[key], + values, + ); } } - return newObj; + return newObj as T; } return obj; } diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 33d0404e6b..2a54313d8c 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -267,6 +267,7 @@ export class HookRunner { ...sanitizeEnvironment(process.env, this.config.sanitizationConfig), GEMINI_PROJECT_DIR: input.cwd, CLAUDE_PROJECT_DIR: input.cwd, // For compatibility + ...hookConfig.env, }; const child = spawn( diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index fbcb6dd51d..04616a18af 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -59,6 +59,7 @@ export interface CommandHookConfig { description?: string; timeout?: number; source?: ConfigSource; + env?: Record; } export type HookConfig = CommandHookConfig;