mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
Merge 8da37013ae into 4b2091d402
This commit is contained in:
commit
3f4e0c0c76
4 changed files with 160 additions and 17 deletions
|
|
@ -1564,6 +1564,56 @@ describe('Server Config (config.ts)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getExtensionSetting', () => {
|
||||
it('returns undefined if the extension does not exist', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([]);
|
||||
expect(config.getExtensionSetting('foo', 'bar')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if the extension has no resolvedSettings', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext-name',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext-id',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
config.getExtensionSetting('my-ext-id', 'some.setting'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the setting value if it exists', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.spyOn(config, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'my-ext-name',
|
||||
version: '1.0',
|
||||
isActive: true,
|
||||
path: '/ext',
|
||||
contextFiles: [],
|
||||
id: 'my-ext-id',
|
||||
resolvedSettings: [
|
||||
{
|
||||
name: 'some.setting',
|
||||
value: 'custom-val',
|
||||
envVar: 'MY_EXT_SOME_SETTING',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(config.getExtensionSetting('my-ext-id', 'some.setting')).toBe(
|
||||
'custom-val',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTruncateToolOutputThreshold', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -2907,6 +2907,27 @@ export class Config implements McpContext, AgentLoopContext {
|
|||
return this._extensionLoader.getExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a setting value for a specific extension.
|
||||
*
|
||||
* @param extensionId - The ID of the extension.
|
||||
* @param settingName - The name of the setting to retrieve.
|
||||
*/
|
||||
getExtensionSetting(
|
||||
extensionId: string,
|
||||
settingName: string,
|
||||
): string | undefined {
|
||||
const ext = this.getExtensions().find(
|
||||
(e) => e.id === extensionId && e.isActive,
|
||||
);
|
||||
if (!ext || !ext.resolvedSettings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setting = ext.resolvedSettings.find((s) => s.name === settingName);
|
||||
return setting?.value;
|
||||
}
|
||||
|
||||
getExtensionLoader(): ExtensionLoader {
|
||||
return this._extensionLoader;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,40 @@ describe('Storage – additional helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkspaceRelativePath', () => {
|
||||
it('resolves a relative path correctly', () => {
|
||||
expect(storage.resolveWorkspaceRelativePath('foo/bar')).toBe(
|
||||
path.join(projectRoot, 'foo/bar'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if homedir path escapes workspace', () => {
|
||||
// In this test, projectRoot is /tmp/project, and homedir is likely outside.
|
||||
// We expect this to throw an error about escaping the project root.
|
||||
expect(() => storage.resolveWorkspaceRelativePath('~/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if path escapes workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('../outside')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an absolute path within workspace', () => {
|
||||
expect(
|
||||
storage.resolveWorkspaceRelativePath(path.join(projectRoot, 'inner')),
|
||||
).toBe(path.join(projectRoot, 'inner'));
|
||||
});
|
||||
|
||||
it('throws for an absolute path outside workspace', () => {
|
||||
expect(() => storage.resolveWorkspaceRelativePath('/tmp/foo')).toThrow(
|
||||
/outside the project root/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlansDir', () => {
|
||||
interface TestCase {
|
||||
name: string;
|
||||
|
|
@ -331,7 +365,7 @@ describe('Storage – additional helpers', () => {
|
|||
name: 'custom absolute path outside throws',
|
||||
customDir: path.resolve('/absolute/path/to/plans'),
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'absolute path that happens to be inside project root',
|
||||
|
|
@ -357,7 +391,7 @@ describe('Storage – additional helpers', () => {
|
|||
name: 'escaping relative path throws',
|
||||
customDir: '../escaped-plans',
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
{
|
||||
name: 'hidden directory starting with ..',
|
||||
|
|
@ -377,7 +411,7 @@ describe('Storage – additional helpers', () => {
|
|||
return () => vi.mocked(fs.realpathSync).mockRestore();
|
||||
},
|
||||
expected: '',
|
||||
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
expectedError: `Path 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -328,22 +328,60 @@ export class Storage {
|
|||
return path.join(this.getProjectTempDir(), 'tracker');
|
||||
}
|
||||
|
||||
getPlansDir(): string {
|
||||
if (this.customPlansDir) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getProjectRoot(),
|
||||
this.customPlansDir,
|
||||
);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
const realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
/**
|
||||
* Resolves a path securely relative to the project root.
|
||||
* Throws if the path attempts to escape the workspace (e.g. via ../).
|
||||
*/
|
||||
resolveWorkspaceRelativePath(customPath: string): string {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
// Normalize tilde to homedir
|
||||
let expandedPath = customPath;
|
||||
if (
|
||||
expandedPath.startsWith('~/') ||
|
||||
(isWindows && expandedPath.startsWith('~\\'))
|
||||
) {
|
||||
const home = homedir();
|
||||
if (home) {
|
||||
expandedPath = path.join(home, expandedPath.slice(2));
|
||||
}
|
||||
} else if (expandedPath === '~') {
|
||||
expandedPath = homedir() || expandedPath;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
const resolvedPath = path.resolve(this.getProjectRoot(), expandedPath);
|
||||
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
|
||||
|
||||
// By enforcing resolveToRealPath, we guarantee symlinks are evaluated.
|
||||
// If the path doesn't exist, we fallback to string normalization (JIT provisioning).
|
||||
let realResolvedPath: string;
|
||||
try {
|
||||
realResolvedPath = resolveToRealPath(resolvedPath);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
realResolvedPath = path.normalize(resolvedPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSubpath(realProjectRoot, realResolvedPath)) {
|
||||
throw new Error(
|
||||
`Path '${customPath}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
getPlansDir(customDir?: string): string {
|
||||
const dirToResolve = customDir ?? this.customPlansDir;
|
||||
if (dirToResolve) {
|
||||
return this.resolveWorkspaceRelativePath(dirToResolve);
|
||||
}
|
||||
return this.getProjectTempPlansDir();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue