Merge branch 'main' into restart-resume

This commit is contained in:
Jack Wotherspoon
2026-03-09 19:13:51 +01:00
committed by GitHub
18 changed files with 1253 additions and 64 deletions
@@ -124,4 +124,30 @@ describe('recursivelyHydrateStrings', () => {
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual(obj);
});
it('should not allow prototype pollution via __proto__', () => {
const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}');
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe(
false,
);
});
it('should not allow prototype pollution via constructor', () => {
const payload = JSON.parse(
'{"constructor": {"prototype": {"polluted": "yes"}}}',
);
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
});
it('should not allow prototype pollution via prototype', () => {
const payload = JSON.parse('{"prototype": {"polluted": "yes"}}');
const result = recursivelyHydrateStrings(payload, context);
expect(result.polluted).toBeUndefined();
});
});
@@ -8,6 +8,16 @@ import * as path from 'node:path';
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
import { GEMINI_DIR } from '@google/gemini-cli-core';
/**
* Represents a set of keys that will be considered invalid while unmarshalling
* JSON in recursivelyHydrateStrings.
*/
const UNMARSHALL_KEY_IGNORE_LIST: Set<string> = new Set<string>([
'__proto__',
'constructor',
'prototype',
]);
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';
@@ -65,7 +75,10 @@ export function recursivelyHydrateStrings<T>(
if (typeof obj === 'object' && obj !== null) {
const newObj: Record<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (
!UNMARSHALL_KEY_IGNORE_LIST.has(key) &&
Object.prototype.hasOwnProperty.call(obj, key)
) {
newObj[key] = recursivelyHydrateStrings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
@@ -142,6 +142,14 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({
},
}));
vi.mock('../ui/commands/upgradeCommand.js', () => ({
upgradeCommand: {
name: 'upgrade',
description: 'Upgrade command',
kind: 'BUILT_IN',
},
}));
describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
@@ -163,6 +171,9 @@ describe('BuiltinCommandLoader', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@@ -172,6 +183,27 @@ describe('BuiltinCommandLoader', () => {
});
});
it('should include upgrade command when authType is login_with_google', async () => {
const { AuthType } = await import('@google/gemini-cli-core');
(mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
});
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const upgradeCmd = commands.find((c) => c.name === 'upgrade');
expect(upgradeCmd).toBeDefined();
});
it('should exclude upgrade command when authType is NOT login_with_google', async () => {
(mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({
authType: 'other',
});
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const upgradeCmd = commands.find((c) => c.name === 'upgrade');
expect(upgradeCmd).toBeUndefined();
});
it('should correctly pass the config object to restore command factory', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
await loader.loadCommands(new AbortController().signal);
@@ -364,6 +396,9 @@ describe('BuiltinCommandLoader profile', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
} as unknown as Config;
});
@@ -16,6 +16,7 @@ import {
isNightly,
startupProfiler,
getAdminErrorMessage,
AuthType,
} from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
@@ -59,6 +60,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -223,6 +225,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
...(this.config?.getContentGeneratorConfig()?.authType ===
AuthType.LOGIN_WITH_GOOGLE
? [upgradeCommand]
: []),
];
handle?.end();
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { upgradeCommand } from './upgradeCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
AuthType,
openBrowserSecurely,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
openBrowserSecurely: vi.fn(),
UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist',
};
});
describe('upgradeCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
},
},
} as unknown as CommandContext);
});
it('should have the correct name and description', () => {
expect(upgradeCommand.name).toBe('upgrade');
expect(upgradeCommand.description).toBe(
'Upgrade your Gemini Code Assist tier for higher limits',
);
});
it('should call openBrowserSecurely with UPGRADE_URL_PAGE when logged in with Google', async () => {
if (!upgradeCommand.action) {
throw new Error('The upgrade command must have an action.');
}
await upgradeCommand.action(mockContext, '');
expect(openBrowserSecurely).toHaveBeenCalledWith(UPGRADE_URL_PAGE);
});
it('should return an error message when NOT logged in with Google', async () => {
vi.mocked(
mockContext.services.config!.getContentGeneratorConfig,
).mockReturnValue({
authType: AuthType.USE_GEMINI,
});
if (!upgradeCommand.action) {
throw new Error('The upgrade command must have an action.');
}
const result = await upgradeCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'The /upgrade command is only available when logged in with Google.',
});
expect(openBrowserSecurely).not.toHaveBeenCalled();
});
it('should return an error message if openBrowserSecurely fails', async () => {
vi.mocked(openBrowserSecurely).mockRejectedValue(
new Error('Failed to open'),
);
if (!upgradeCommand.action) {
throw new Error('The upgrade command must have an action.');
}
const result = await upgradeCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to open upgrade page: Failed to open',
});
});
});
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
openBrowserSecurely,
UPGRADE_URL_PAGE,
} from '@google/gemini-cli-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
/**
* Command to open the upgrade page for Gemini Code Assist.
* Only intended to be shown/available when the user is logged in with Google.
*/
export const upgradeCommand: SlashCommand = {
name: 'upgrade',
kind: CommandKind.BUILT_IN,
description: 'Upgrade your Gemini Code Assist tier for higher limits',
autoExecute: true,
action: async (context) => {
const authType =
context.services.config?.getContentGeneratorConfig()?.authType;
if (authType !== AuthType.LOGIN_WITH_GOOGLE) {
// This command should ideally be hidden if not logged in with Google,
// but we add a safety check here just in case.
return {
type: 'message',
messageType: 'error',
content:
'The /upgrade command is only available when logged in with Google.',
};
}
try {
await openBrowserSecurely(UPGRADE_URL_PAGE);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to open upgrade page: ${error instanceof Error ? error.message : String(error)}`,
};
}
return undefined;
},
};
@@ -734,6 +734,55 @@ describe('SettingsUtils', () => {
);
expect(result).toBe('false');
});
it('should display objects as JSON strings, not "[object Object]"', () => {
vi.mocked(getSettingsSchema).mockReturnValue({
experimental: {
type: 'object',
label: 'Experimental',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Experimental settings',
showInDialog: false,
properties: {
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Gemma model router settings',
showInDialog: true,
},
},
},
} as unknown as SettingsSchemaType);
// Test with empty object (default)
const emptySettings = makeMockSettings({});
const emptyResult = getDisplayValue(
'experimental.gemmaModelRouter',
emptySettings,
emptySettings,
);
expect(emptyResult).toBe('{}');
expect(emptyResult).not.toBe('[object Object]');
// Test with object containing values
const settings = makeMockSettings({
experimental: {
gemmaModelRouter: { enabled: true, host: 'localhost' },
},
});
const result = getDisplayValue(
'experimental.gemmaModelRouter',
settings,
settings,
);
expect(result).toBe('{"enabled":true,"host":"localhost"}*');
expect(result).not.toContain('[object Object]');
});
});
describe('getDisplayValue with units', () => {
+8 -1
View File
@@ -284,7 +284,14 @@ export function getDisplayValue(
let valueString = String(value);
if (definition?.type === 'enum' && definition.options) {
// Handle object types by stringifying them
if (
definition?.type === 'object' &&
value !== null &&
typeof value === 'object'
) {
valueString = JSON.stringify(value);
} else if (definition?.type === 'enum' && definition.options) {
const option = definition.options?.find((option) => option.value === value);
valueString = option?.label ?? `${value}`;
}