Add extension settings to be requested on install (#9802)

This commit is contained in:
christine betts
2025-10-23 11:47:08 -04:00
committed by GitHub
parent bde5d61812
commit 750c0e366f
13 changed files with 641 additions and 20 deletions

View File

@@ -16,6 +16,7 @@ import {
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
source: string;
@@ -69,6 +70,9 @@ export async function handleInstall(args: InstallArgs) {
const name = await installOrUpdateExtension(
installMetadata,
requestConsent,
process.cwd(),
undefined,
promptForSetting,
);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {

View File

@@ -35,6 +35,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { join } from 'node:path';
import type { ExtensionSetting } from './extensions/extensionSettings.js';
const mockGit = {
clone: vi.fn(),
@@ -340,6 +341,36 @@ describe('extension tests', () => {
}
});
it('should resolve environment variables from an extension .env file', () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '$MY_API_KEY',
STATIC_VALUE: 'no-substitution',
},
},
},
});
const envFilePath = path.join(extDir, '.env');
fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n');
const extensions = loadExtensions(new ExtensionEnablementManager());
expect(extensions).toHaveLength(1);
const extension = extensions[0];
const serverConfig = extension.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file');
expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution');
});
it('should handle missing environment variables gracefully', () => {
const userExtensionsDir = path.join(
tempHomeDir,
@@ -1033,6 +1064,186 @@ This extension will run the following MCP servers:
expect(mockRequestConsent).not.toHaveBeenCalled();
});
it('should prompt for settings if promptForSettings', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
const promptForSettingsMock = vi.fn(
async (_: ExtensionSetting): Promise<string> => Promise.resolve(''),
);
await installOrUpdateExtension(
{ source: sourceExtDir, type: 'local' },
async (_) => true,
process.cwd(),
undefined,
promptForSettingsMock,
);
expect(promptForSettingsMock).toHaveBeenCalled();
});
it('should not prompt for settings if promptForSettings is false', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
await installOrUpdateExtension(
{ source: sourceExtDir, type: 'local' },
async (_) => true,
);
});
it('should only prompt for new settings on update, and preserve old settings', async () => {
// 1. Create and install the "old" version of the extension.
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir, // Create it in a temp location first
name: 'my-local-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
],
});
// Install it so it exists in the userExtensionsDir
await installOrUpdateExtension(
{ source: oldSourceExtDir, type: 'local' },
async (_) => true,
process.cwd(),
undefined,
async () => 'old-api-key',
);
const envPath = new ExtensionStorage(
'my-local-extension',
).getEnvFilePath();
expect(fs.existsSync(envPath)).toBe(true);
let envContent = fs.readFileSync(envPath, 'utf-8');
expect(envContent).toContain('MY_API_KEY=old-api-key');
// 2. Create the "new" version of the extension in a new source directory.
const newSourceExtDir = createExtension({
extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location
name: 'my-local-extension', // Same name
version: '1.1.0', // New version
settings: [
{
name: 'API Key',
description: 'Your API key for the service.',
envVar: 'MY_API_KEY',
},
{
name: 'New Setting',
description: 'A new setting.',
envVar: 'NEW_SETTING',
},
],
});
const previousExtensionConfig = loadExtensionConfig({
extensionDir: path.join(userExtensionsDir, 'my-local-extension'),
workspaceDir: process.cwd(),
extensionEnablementManager: new ExtensionEnablementManager(),
});
const promptForSettingsMock = vi.fn(
async (_: ExtensionSetting): Promise<string> => 'new-setting-value',
);
// 3. Call installOrUpdateExtension to perform the update.
await installOrUpdateExtension(
{ source: newSourceExtDir, type: 'local' },
async (_) => true,
process.cwd(),
previousExtensionConfig,
promptForSettingsMock,
);
expect(promptForSettingsMock).toHaveBeenCalledTimes(1);
expect(promptForSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Setting' }),
);
expect(fs.existsSync(envPath)).toBe(true);
envContent = fs.readFileSync(envPath, 'utf-8');
expect(envContent).toContain('MY_API_KEY=old-api-key');
expect(envContent).toContain('NEW_SETTING=new-setting-value');
});
it('should fail auto-update if settings have changed', async () => {
// 1. Install initial version with autoUpdate: true
const oldSourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.0.0',
settings: [
{
name: 'OLD_SETTING',
envVar: 'OLD_SETTING',
description: 'An old setting',
},
],
});
await installOrUpdateExtension(
{ source: oldSourceExtDir, type: 'local', autoUpdate: true },
async () => true,
);
// 2. Create new version with different settings
const newSourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-auto-update-ext',
version: '1.1.0',
settings: [
{
name: 'NEW_SETTING',
envVar: 'NEW_SETTING',
description: 'A new setting',
},
],
});
const previousExtensionConfig = loadExtensionConfig({
extensionDir: path.join(userExtensionsDir, 'my-auto-update-ext'),
workspaceDir: process.cwd(),
extensionEnablementManager: new ExtensionEnablementManager(),
});
// 3. Attempt to update and assert it fails
await expect(
installOrUpdateExtension(
{ source: newSourceExtDir, type: 'local', autoUpdate: true },
async () => true,
process.cwd(),
previousExtensionConfig,
),
).rejects.toThrow(
'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.',
);
});
it('should throw an error for invalid extension names', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,

View File

@@ -35,7 +35,6 @@ import {
type JsonObject,
} from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID, createHash } from 'node:crypto';
import {
cloneFromGit,
@@ -45,15 +44,24 @@ import {
import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import chalk from 'chalk';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import type { ConfirmationRequest } from '../ui/types.js';
import { escapeAnsiCtrlCodes } from '../ui/utils/textUtils.js';
import {
getEnvContents,
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export const EXTENSION_SETTINGS_FILENAME = '.env';
export const INSTALL_WARNING_MESSAGE =
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
/**
* Extension definition as written to disk in gemini-extension.json files.
* This should *not* be referenced outside of the logic for reading files.
@@ -61,12 +69,13 @@ export const INSTALL_WARNING_MESSAGE =
* outside of the loading process that data needs to be stored on the
* GeminiCLIExtension class defined in Core.
*/
interface ExtensionConfig {
export interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
settings?: ExtensionSetting[];
}
export interface ExtensionUpdateInfo {
@@ -93,6 +102,10 @@ export class ExtensionStorage {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
}
getEnvFilePath(): string {
return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME);
}
static getUserExtensionsDir(): string {
const storage = new Storage(os.homedir());
return storage.getExtensionsDir();
@@ -182,7 +195,8 @@ export function loadExtension(
extensionEnablementManager,
});
config = resolveEnvVarsInObject(config);
const customEnv = getEnvContents(new ExtensionStorage(config.name));
config = resolveEnvVarsInObject(config, customEnv);
if (config.mcpServers) {
config.mcpServers = Object.fromEntries(
@@ -371,13 +385,14 @@ export async function installOrUpdateExtension(
requestConsent: (consent: string) => Promise<boolean>,
cwd: string = process.cwd(),
previousExtensionConfig?: ExtensionConfig,
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): Promise<string> {
const isUpdate = !!previousExtensionConfig;
const telemetryConfig = getTelemetryConfig(cwd);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
const extensionEnablementManager = new ExtensionEnablementManager();
// path.join(tempDir, EXTENSION_SETTINGS_FILENAME)
try {
const settings = loadSettings(cwd).merged;
if (!isWorkspaceTrusted(settings).isTrusted) {
@@ -451,6 +466,25 @@ export async function installOrUpdateExtension(
extensionEnablementManager,
});
if (isUpdate && previousExtensionConfig && installMetadata.autoUpdate) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
const newSettings = new Set(
newExtensionConfig.settings?.map((s) => s.name) || [],
);
const settingsAreEqual =
oldSettings.size === newSettings.size &&
[...oldSettings].every((value) => newSettings.has(value));
if (!settingsAreEqual) {
throw new Error(
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
);
}
}
const newExtensionName = newExtensionConfig.name;
if (!isUpdate) {
const installedExtensions = loadExtensions(
@@ -476,12 +510,25 @@ export async function installOrUpdateExtension(
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
previousSettings = getEnvContents(extensionStorage);
await uninstallExtension(newExtensionName, isUpdate, cwd);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
if (requestSetting !== undefined) {
if (isUpdate && previousExtensionConfig) {
await maybePromptForSettings(
newExtensionConfig,
requestSetting,
previousExtensionConfig,
previousSettings,
);
} else if (!isUpdate) {
await maybePromptForSettings(newExtensionConfig, requestSetting);
}
}
if (
installMetadata.type === 'local' ||

View File

@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import {
maybePromptForSettings,
promptForSetting,
type ExtensionSetting,
} from './extensionSettings.js';
import type { ExtensionConfig } from '../extension.js';
import { ExtensionStorage } from '../extension.js';
import prompts from 'prompts';
import * as fsPromises from 'node:fs/promises';
import * as fs from 'node:fs';
vi.mock('prompts');
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
describe('extensionSettings', () => {
let tempHomeDir: string;
let extensionDir: string;
beforeEach(() => {
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Spy and mock the method, but also create the directory so we can write to it.
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(prompts).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('maybePromptForSettings', () => {
const mockRequestSetting = vi.fn(
async (setting: ExtensionSetting) => `mock-${setting.envVar}`,
);
beforeEach(() => {
mockRequestSetting.mockClear();
});
it('should do nothing if settings are undefined', async () => {
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
await maybePromptForSettings(config, mockRequestSetting);
expect(mockRequestSetting).not.toHaveBeenCalled();
});
it('should do nothing if settings are empty', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
await maybePromptForSettings(config, mockRequestSetting);
expect(mockRequestSetting).not.toHaveBeenCalled();
});
it('should call requestSetting for each setting', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2' },
],
};
await maybePromptForSettings(config, mockRequestSetting);
expect(mockRequestSetting).toHaveBeenCalledTimes(2);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
});
it('should write the .env file with the correct content', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{ name: 's2', description: 'd2', envVar: 'VAR2' },
],
};
await maybePromptForSettings(config, mockRequestSetting);
const expectedEnvPath = path.join(extensionDir, '.env');
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
const expectedContent = 'VAR1=mock-VAR1\nVAR2=mock-VAR2\n';
expect(actualContent).toBe(expectedContent);
});
});
describe('promptForSetting', () => {
// it('should use prompts with type "password" for sensitive settings', async () => {
// const setting: ExtensionSetting = {
// name: 'API Key',
// description: 'Your secret key',
// envVar: 'API_KEY',
// sensitive: true,
// };
// vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' });
// const result = await promptForSetting(setting);
// expect(prompts).toHaveBeenCalledWith({
// type: 'password',
// name: 'value',
// message: 'API Key\nYour secret key',
// });
// expect(result).toBe('secret-key');
// });
it('should use prompts with type "text" for non-sensitive settings', async () => {
const setting: ExtensionSetting = {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
// sensitive: false,
};
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
const result = await promptForSetting(setting);
expect(prompts).toHaveBeenCalledWith({
type: 'text',
name: 'value',
message: 'Username\nYour public username',
});
expect(result).toBe('test-user');
});
it('should default to "text" if sensitive is undefined', async () => {
const setting: ExtensionSetting = {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
};
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
const result = await promptForSetting(setting);
expect(prompts).toHaveBeenCalledWith({
type: 'text',
name: 'value',
message: 'Username\nYour public username',
});
expect(result).toBe('test-user');
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as dotenv from 'dotenv';
import { ExtensionStorage } from '../extension.js';
import type { ExtensionConfig } from '../extension.js';
import prompts from 'prompts';
export interface ExtensionSetting {
name: string;
description: string;
envVar: string;
}
export async function maybePromptForSettings(
extensionConfig: ExtensionConfig,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
previousExtensionConfig?: ExtensionConfig,
previousSettings?: Record<string, string>,
): Promise<void> {
const { name: extensionName, settings } = extensionConfig;
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath();
if (!settings || settings.length === 0) {
// No settings for this extension. Clear any existing .env file.
if (fsSync.existsSync(envFilePath)) {
await fs.writeFile(envFilePath, '');
}
return;
}
let settingsToPrompt = settings;
if (previousExtensionConfig) {
const oldSettings = new Set(
previousExtensionConfig.settings?.map((s) => s.name) || [],
);
settingsToPrompt = settingsToPrompt.filter((s) => !oldSettings.has(s.name));
}
const allSettings: Record<string, string> = { ...(previousSettings ?? {}) };
if (settingsToPrompt && settingsToPrompt.length > 0) {
for (const setting of settingsToPrompt) {
const answer = await requestSetting(setting);
allSettings[setting.envVar] = answer;
}
}
const validEnvVars = new Set(settings.map((s) => s.envVar));
const finalSettings: Record<string, string> = {};
for (const [key, value] of Object.entries(allSettings)) {
if (validEnvVars.has(key)) {
finalSettings[key] = value;
}
}
let envContent = '';
for (const [key, value] of Object.entries(finalSettings)) {
envContent += `${key}=${value}\n`;
}
await fs.writeFile(envFilePath, envContent);
}
export async function promptForSetting(
setting: ExtensionSetting,
): Promise<string> {
const response = await prompts({
// type: setting.sensitive ? 'password' : 'text',
type: 'text',
name: 'value',
message: `${setting.name}\n${setting.description}`,
});
return response.value;
}
export function getEnvContents(
extensionStorage: ExtensionStorage,
): Record<string, string> {
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(extensionStorage.getEnvFilePath())) {
const envFile = fsSync.readFileSync(
extensionStorage.getEnvFilePath(),
'utf-8',
);
customEnv = dotenv.parse(envFile);
}
return customEnv;
}

View File

@@ -22,6 +22,7 @@ import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
import { type ExtensionEnablementManager } from './extensionEnablement.js';
import { promptForSetting } from './extensionSettings.js';
export interface ExtensionUpdateInfo {
name: string;
@@ -66,18 +67,19 @@ export async function updateExtension(
const tempDir = await ExtensionStorage.createTmpDir();
try {
const previousExtensionConfig = await loadExtensionConfig({
const previousExtensionConfig = loadExtensionConfig({
extensionDir: extension.path,
workspaceDir: cwd,
extensionEnablementManager,
});
await installOrUpdateExtension(
installMetadata,
requestConsent,
cwd,
previousExtensionConfig,
promptForSetting,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = loadExtension({
extensionDir: updatedExtensionStorage.getExtensionDir(),

View File

@@ -14,6 +14,7 @@ import {
type MCPServerConfig,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionSetting } from '../config/extensions/extensionSettings.js';
export function createExtension({
extensionsDir = 'extensions-dir',
@@ -23,12 +24,13 @@ export function createExtension({
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
settings = undefined as ExtensionSetting[] | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers }),
JSON.stringify({ name, version, contextFileName, mcpServers, settings }),
);
if (addContextFile) {

View File

@@ -17,10 +17,16 @@
* resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api"
* resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR"
*/
export function resolveEnvVarsInString(value: string): string {
export function resolveEnvVarsInString(
value: string,
customEnv?: Record<string, string>,
): string {
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
return value.replace(envVarRegex, (match, varName1, varName2) => {
const varName = varName1 || varName2;
if (customEnv && typeof customEnv[varName] === 'string') {
return customEnv[varName]!;
}
if (process && process.env && typeof process.env[varName] === 'string') {
return process.env[varName]!;
}
@@ -47,8 +53,11 @@ export function resolveEnvVarsInString(value: string): string {
* };
* const resolved = resolveEnvVarsInObject(config);
*/
export function resolveEnvVarsInObject<T>(obj: T): T {
return resolveEnvVarsInObjectInternal(obj, new WeakSet());
export function resolveEnvVarsInObject<T>(
obj: T,
customEnv?: Record<string, string>,
): T {
return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv);
}
/**
@@ -61,6 +70,7 @@ export function resolveEnvVarsInObject<T>(obj: T): T {
function resolveEnvVarsInObjectInternal<T>(
obj: T,
visited: WeakSet<object>,
customEnv?: Record<string, string>,
): T {
if (
obj === null ||
@@ -72,7 +82,7 @@ function resolveEnvVarsInObjectInternal<T>(
}
if (typeof obj === 'string') {
return resolveEnvVarsInString(obj) as unknown as T;
return resolveEnvVarsInString(obj, customEnv) as unknown as T;
}
if (Array.isArray(obj)) {
@@ -84,7 +94,7 @@ function resolveEnvVarsInObjectInternal<T>(
visited.add(obj);
const result = obj.map((item) =>
resolveEnvVarsInObjectInternal(item, visited),
resolveEnvVarsInObjectInternal(item, visited, customEnv),
) as unknown as T;
visited.delete(obj);
return result;
@@ -101,7 +111,11 @@ function resolveEnvVarsInObjectInternal<T>(
const newObj = { ...obj } as T;
for (const key in newObj) {
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
newObj[key] = resolveEnvVarsInObjectInternal(newObj[key], visited);
newObj[key] = resolveEnvVarsInObjectInternal(
newObj[key],
visited,
customEnv,
);
}
}
visited.delete(obj as object);