This commit is contained in:
Mahima Shanware 2026-04-20 15:54:36 -04:00 committed by GitHub
commit 3f4e0c0c76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 17 deletions

View file

@ -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();

View file

@ -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;
}

View file

@ -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)}'.`,
},
];

View file

@ -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();
}