// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; let disableJSDOM = enableJSDOM(); import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; FrontendApplicationConfigProvider.set({}); import { expect } from 'chai'; import { CancellationTokenSource, OS, PreferenceService, ILogger } from '@theia/core'; import { AccessibleRootContribution, GetWorkspaceDirectoryStructure, FileContentFunction, GetWorkspaceFileList, FileDiagnosticProvider, WorkspaceFunctionScope, FindFilesByPattern } from './workspace-functions'; import { bindRootContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { AiConfigurationService, ToolInvocationContext } from '@theia/ai-core'; import { Container } from '@theia/core/shared/inversify'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileOperationError, FileOperationResult, FileStat } from '@theia/filesystem/lib/common/files'; import { URI } from '@theia/core/lib/common/uri'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { ProblemManager } from '@theia/markers/lib/browser'; import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; import { FileSearchService } from '@theia/file-search/lib/common/file-search-service'; import { Minimatch } from 'minimatch'; import { MockLogger } from '@theia/core/lib/common/test/mock-logger'; const makeFileSearchService = ( impl?: (searchPattern: string, options: FileSearchService.Options) => Promise ): FileSearchService & { calls: Array<{ searchPattern: string; options: FileSearchService.Options }> } => { const calls: Array<{ searchPattern: string; options: FileSearchService.Options }> = []; return { calls, find: async (searchPattern: string, options: FileSearchService.Options) => { calls.push({ searchPattern, options }); return impl ? impl(searchPattern, options) : []; } } as unknown as FileSearchService & { calls: Array<{ searchPattern: string; options: FileSearchService.Options }> }; }; /** * A FileSearchService stand-in that mimics ripgrep: given a flat map of file URIs per * root, it returns the files under the requested root that match the include globs and * are not removed by the exclude globs (matched against each file's root-relative path). */ const makeRipgrepLikeSearchService = (filesByRoot: Record) => makeFileSearchService(async (_searchPattern, options) => { const root = options.rootUris?.[0]; if (!root) { return []; } const rootUri = new URI(root); const includes = (options.includePatterns ?? []).map(pattern => new Minimatch(pattern, { dot: true })); const excludes = (options.excludePatterns ?? []).map(pattern => new Minimatch(pattern, { dot: true })); return (filesByRoot[root] ?? []).filter(file => { const relativePath = rootUri.relative(new URI(file))?.toString() ?? ''; const included = includes.length === 0 || includes.some(matcher => matcher.match(relativePath)); const excluded = excludes.some(matcher => matcher.match(relativePath)); return included && !excluded; }); }); const makeTrustAwareReader = (overrides: { [pref: string]: unknown } = {}): AiConfigurationService => ({ get: (name: string, fallback?: T) => (name in overrides ? overrides[name] as T : fallback), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService); const makeEnvVariablesServer = (homeDirUri: string = 'file:///home/test'): EnvVariablesServer => ({ getHomeDirUri: async () => homeDirUri, getExecPath: async () => '', getVariables: async () => [], getValue: async () => undefined, getConfigDirUri: async () => 'file:///home/test/.config' } as unknown as EnvVariablesServer); disableJSDOM(); describe('Workspace Functions Cancellation Tests', () => { let cancellationTokenSource: CancellationTokenSource; let mockCtx: ToolInvocationContext; let container: Container; before(() => { disableJSDOM = enableJSDOM(); }); after(() => { // Disable JSDOM after all tests disableJSDOM(); }); beforeEach(() => { cancellationTokenSource = new CancellationTokenSource(); // Setup mock context mockCtx = { cancellationToken: cancellationTokenSource.token }; // Create a new container for each test container = new Container(); // Mock dependencies const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async () => ({ isDirectory: true, children: [ { isDirectory: true, resource: new URI('file:///workspace/dir'), path: { base: 'dir' } } ], resource: new URI('file:///workspace') }), read: async () => ({ value: { toString: () => 'test content' } }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const mockMonacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace; const mockProblemManager = { findMarkers: () => [], onDidChangeMarkers: () => ({ dispose: () => { } }) } as unknown as ProblemManager; const mockMonacoTextModelService = { createModelReference: async () => ({ object: { lineCount: 10, getText: () => 'test text' }, dispose: () => { } }) } as unknown as MonacoTextModelService; // Register mocks in the container container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(ProblemManager).toConstantValue(mockProblemManager); container.bind(MonacoTextModelService).toConstantValue(mockMonacoTextModelService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeFileSearchService()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceDirectoryStructure).toSelf(); container.bind(FileContentFunction).toSelf(); container.bind(GetWorkspaceFileList).toSelf(); container.bind(FileDiagnosticProvider).toSelf(); container.bind(FindFilesByPattern).toSelf(); }); afterEach(() => { cancellationTokenSource.dispose(); }); it('GetWorkspaceDirectoryStructure should respect cancellation token', async () => { const getDirectoryStructure = container.get(GetWorkspaceDirectoryStructure); cancellationTokenSource.cancel(); const handler = getDirectoryStructure.getTool().handler; const result = await handler(JSON.stringify({}), mockCtx); const jsonResponse = typeof result === 'string' ? JSON.parse(result) : result; expect(jsonResponse.error).to.equal('Operation cancelled by user'); }); it('FileContentFunction should respect cancellation token', async () => { const fileContentFunction = container.get(FileContentFunction); cancellationTokenSource.cancel(); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), mockCtx); const jsonResponse = JSON.parse(result as string); expect(jsonResponse.error).to.equal('Operation cancelled by user'); }); it('GetWorkspaceFileList should respect cancellation token', async () => { const getWorkspaceFileList = container.get(GetWorkspaceFileList); cancellationTokenSource.cancel(); const handler = getWorkspaceFileList.getTool().handler; const result = await handler(JSON.stringify({ path: '' }), mockCtx); expect(result).to.include('Operation cancelled by user'); }); it('GetWorkspaceFileList should check cancellation at multiple points', async () => { const getWorkspaceFileList = container.get(GetWorkspaceFileList); // We'll let it pass the first check then cancel const mockFileService = container.get(FileService); const originalResolve = mockFileService.resolve; // Mock resolve to cancel the token after it's called mockFileService.resolve = async (...args: unknown[]) => { const innerResult = await originalResolve.apply(mockFileService, args); cancellationTokenSource.cancel(); return innerResult; }; const handler = getWorkspaceFileList.getTool().handler; // Use a path that triggers file resolution (root name + subdirectory) const result = await handler(JSON.stringify({ path: 'workspace/dir' }), mockCtx); expect(result).to.include('Operation cancelled by user'); }); it('FileDiagnosticProvider should respect cancellation token', async () => { const fileDiagnosticProvider = container.get(FileDiagnosticProvider); cancellationTokenSource.cancel(); const handler = fileDiagnosticProvider.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), mockCtx); const jsonResponse = JSON.parse(result as string); expect(jsonResponse.error).to.equal('Operation cancelled by user'); }); }); describe('FileContentFunction.getArgumentsShortLabel', () => { let container: Container; let getArgumentsShortLabel: (args: string) => { label: string; hasMore: boolean } | undefined; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async () => ({ isDirectory: true, children: [], resource: new URI('file:///workspace') }), read: async () => ({ value: { toString: () => 'test content' } }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const mockMonacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); const fileContentFunction = container.get(FileContentFunction); const tool = fileContentFunction.getTool(); getArgumentsShortLabel = tool.getArgumentsShortLabel!; }); it('returns label for valid file argument', () => { const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts' })); expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: false }); }); it('returns undefined for invalid JSON', () => { const result = getArgumentsShortLabel('not valid json'); expect(result).to.be.undefined; }); it('returns undefined when file key is missing', () => { const result = getArgumentsShortLabel(JSON.stringify({ path: 'src/index.ts' })); expect(result).to.be.undefined; }); it('returns hasMore true when offset is provided', () => { const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10 })); expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true }); }); it('returns hasMore true when limit is provided', () => { const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', limit: 50 })); expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true }); }); it('returns hasMore true when both offset and limit are provided', () => { const result = getArgumentsShortLabel(JSON.stringify({ file: 'src/index.ts', offset: 10, limit: 50 })); expect(result).to.deep.equal({ label: 'src/index.ts', hasMore: true }); }); }); describe('FileContentFunction handler', () => { let container: Container; let fileContentFunction: FileContentFunction; // Mutable delegates — tests reassign these directly instead of casting the mock object. let mockResolve: () => Promise; let mockRead: () => Promise; let mockReadStream: () => Promise; let mockMonacoWorkspace: MonacoWorkspace; let mockPreferenceService: { get: (path: string, defaultValue: T) => T }; const makeMockStream = (content: string) => { const handlers: Record = {}; // Use setTimeout so the macro-task fires after all pending microtasks // (including the await continuation that registers the listeners). setTimeout(() => { handlers['data']?.(content); handlers['end']?.(); }, 0); return { on(event: string, cb: Function): void { handlers[event] = cb; }, pause(): void { }, resume(): void { }, destroy(): void { }, removeListener(): void { } }; }; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; mockResolve = async () => ({ isFile: true, isDirectory: false, size: 1024, resource: new URI('file:///workspace/test.txt') }); mockRead = async () => ({ value: 'line1\nline2\nline3\nline4\nline5' }); mockReadStream = async () => ({ value: makeMockStream('line1\nline2\nline3\nline4\nline5') }); // The mock object is stable across a test; individual methods delegate to // the mutable variables above so tests can substitute behaviour without // the fragile `(obj as unknown as {…}).method = …` double-cast pattern. const mockFileService = { exists: async () => true, resolve: () => mockResolve(), read: () => mockRead(), readStream: () => mockReadStream(), } as unknown as FileService; mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; mockMonacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); fileContentFunction = container.get(FileContentFunction); }); it('returns file content when file is within size limit', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined); expect(result).to.equal('line1\nline2\nline3\nline4\nline5'); }); it('rejects without reading when on-disk size exceeds limit', async () => { // Stat reports 512 KB; default limit is 256 KB let readCalled = false; mockResolve = async () => ({ isFile: true, isDirectory: false, size: 512 * 1024, resource: new URI('file:///workspace/big.txt') }); mockRead = async () => { readCalled = true; return { value: 'should not be read' }; }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.sizeKB).to.equal(512); expect(readCalled).to.be.false; }); it('returns editor content when file is open in editor and within size limit', async () => { let resolveCalled = false; mockResolve = async () => { resolveCalled = true; return { isFile: true, isDirectory: false, size: 1024, resource: new URI('file:///workspace/open.txt') }; }; mockMonacoWorkspace.getTextDocument = () => ({ getText: () => 'editor content' } as unknown as ReturnType); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined); expect(result).to.equal('editor content'); expect(resolveCalled).to.be.false; }); it('rejects editor content when it exceeds the size limit', async () => { const bigContent = 'x'.repeat(512 * 1024); mockMonacoWorkspace.getTextDocument = () => ({ getText: () => bigContent } as unknown as ReturnType); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'open.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.sizeKB).to.equal(512); }); it('returns sliced content with header when offset and limit are provided', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1, limit: 2 }), undefined); expect(result).to.include('[Lines 2\u20133 of 5 total.'); expect(result).to.include('line2\nline3'); }); it('returns sliced editor content with header when file is open and offset/limit are provided', async () => { mockMonacoWorkspace.getTextDocument = () => ({ getText: () => 'alpha\nbeta\ngamma\ndelta\nepsilon' } as unknown as ReturnType); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'open.txt', offset: 1, limit: 2 }), undefined); expect(result).to.include('[Lines 2\u20133 of 5 total.'); expect(result).to.include('beta\ngamma'); }); it('does not call resolve() or read() for paginated disk reads, uses readStream instead', async () => { // Stat would report huge file, but the streaming path bypasses both stat and read let resolveCalled = false; let readCalled = false; mockResolve = async () => { resolveCalled = true; return { isFile: true, isDirectory: false, size: 512 * 1024, resource: new URI('file:///workspace/big.txt') }; }; mockRead = async () => { readCalled = true; return { value: 'should not be read' }; }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 3 }), undefined); // resolve and read are NOT called in the streaming path expect(resolveCalled).to.be.false; expect(readCalled).to.be.false; expect(result).to.include('line1\nline2\nline3'); }); it('rejects when the requested slice itself exceeds the size limit', async () => { const bigLine = 'x'.repeat(1024); const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n'); mockReadStream = async () => ({ value: makeMockStream(bigContent) }); const handler = fileContentFunction.getTool().handler; // Reading all 300 lines × 1 KB each = ~300 KB, over the 256 KB default limit const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 300 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.resultSizeKB).to.be.greaterThan(256); }); it('returns File not found error when file does not exist', async () => { mockResolve = async () => { throw new Error('File not found'); }; mockRead = async () => { throw new Error('File not found'); }; mockReadStream = async () => { throw new Error('File not found'); }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'nonexistent.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.equal('File not found'); }); it('rejects negative offset', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: -1 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('non-negative integer'); }); it('rejects fractional offset', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: 1.5 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('non-negative integer'); }); it('rejects negative limit', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', limit: -1 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('positive integer'); }); it('rejects zero limit', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', limit: 0 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('positive integer'); }); it('returns content from offset to end when only offset is provided', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: 2 }), undefined); expect(result).to.include('[Lines 3\u20135 of 5 total.'); expect(result).to.include('line3\nline4\nline5'); }); it('returns last line when offset is at boundary', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: 4, limit: 1 }), undefined); expect(result).to.include('[Lines 5\u20135 of 5 total.'); expect(result).to.include('line5'); }); it('returns empty content when offset is beyond end of file', async () => { const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt', offset: 100, limit: 5 }), undefined); // slice beyond end returns empty array → empty joined string expect(result).to.include('[Lines 101\u2013100 of 5 total.'); }); it('uses custom preference value for size limit', async () => { // Set a very small limit of 1 KB mockPreferenceService.get = (_path: string, _defaultValue: T) => 1 as unknown as T; const content = 'x'.repeat(2 * 1024); // 2 KB mockRead = async () => ({ value: content }); mockResolve = async () => ({ isFile: true, isDirectory: false, size: 2 * 1024, resource: new URI('file:///workspace/test.txt') }); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.maxSizeKB).to.equal(1); }); it('falls back to streaming when stat.size is undefined and file is within limit', async () => { // stat does not include a size — the code must not treat this as "0 KB" // but instead stream the file and succeed when content is small. let readCalled = false; mockResolve = async () => ({ isFile: true, isDirectory: false, size: undefined, resource: new URI('file:///workspace/test.txt') }); mockRead = async () => { readCalled = true; return { value: 'should not be used' }; }; // mockReadStream already returns the small 5-line fixture from beforeEach const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined); expect(readCalled).to.be.false; expect(result).to.include('line1'); // Full-file streaming fallback must NOT include the [Lines...] header expect(result).to.not.include('[Lines'); }); it('returns size-limit error (not "File not found") when stat.size is undefined and streamed content exceeds limit', async () => { // stat does not include a size; the streamed content is larger than the limit. mockResolve = async () => ({ isFile: true, isDirectory: false, size: undefined, resource: new URI('file:///workspace/big.txt') }); const bigLine = 'x'.repeat(1024); const bigContent = Array.from({ length: 300 }, () => bigLine).join('\n'); // ~300 KB mockReadStream = async () => ({ value: makeMockStream(bigContent) }); const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'big.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.error).to.include('offset'); expect(parsed.error).not.to.equal('File not found'); }); it('returns size-limit error (not "File not found") when fileService.read throws FILE_TOO_LARGE', async () => { // Simulate a file system provider that enforces its own hard size limit below maxSizeKB. // stat.size is present and within our configured limit, but read() still throws. mockResolve = async () => ({ isFile: true, isDirectory: false, size: 100 * 1024, // 100 KB — under the 256 KB default limit resource: new URI('file:///workspace/test.txt') }); mockRead = async () => { throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE); }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.error).to.include('offset'); expect(parsed.maxSizeKB).to.equal(256); }); it('returns size-limit error (not "File not found") when fileService.read throws FILE_EXCEEDS_MEMORY_LIMIT', async () => { mockResolve = async () => ({ isFile: true, isDirectory: false, size: 100 * 1024, resource: new URI('file:///workspace/test.txt') }); mockRead = async () => { throw new FileOperationError('Exceeds memory limit', FileOperationResult.FILE_EXCEEDS_MEMORY_LIMIT); }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'test.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.error).to.include('offset'); expect(parsed.maxSizeKB).to.equal(256); }); it('returns size-limit error (not "File not found") when readStream throws FILE_TOO_LARGE for paginated read', async () => { // This is the key scenario: files.maxFileSizeMB is lower than the file size, // but the caller is trying to read a chunk with offset/limit. // Before the fix, readStream would throw FILE_TOO_LARGE and the catch block // would return "File not found". mockReadStream = async () => { throw new FileOperationError('File too large', FileOperationResult.FILE_TOO_LARGE); }; const handler = fileContentFunction.getTool().handler; const result = await handler(JSON.stringify({ file: 'big.txt', offset: 0, limit: 50 }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('size limit'); expect(parsed.error).to.include('offset'); expect(parsed.error).not.to.equal('File not found'); expect(parsed.maxSizeKB).to.equal(256); }); }); describe('FindFilesByPattern.getArgumentsShortLabel', () => { let container: Container; let getArgumentsShortLabel: (args: string) => { label: string; hasMore: boolean } | undefined; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async () => ({ isDirectory: true, children: [], resource: new URI('file:///workspace') }), read: async () => ({ value: { toString: () => 'test content' } }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeFileSearchService()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FindFilesByPattern).toSelf(); const findFilesByPattern = container.get(FindFilesByPattern); const tool = findFilesByPattern.getTool(); getArgumentsShortLabel = tool.getArgumentsShortLabel!; }); it('returns label for valid pattern argument', () => { const result = getArgumentsShortLabel(JSON.stringify({ pattern: '**/*.ts' })); expect(result).to.deep.equal({ label: '**/*.ts', hasMore: false }); }); it('returns hasMore true when additional arguments exist', () => { const result = getArgumentsShortLabel(JSON.stringify({ pattern: '**/*.ts', exclude: ['node_modules'] })); expect(result).to.deep.equal({ label: '**/*.ts', hasMore: true }); }); it('returns undefined for invalid JSON', () => { const result = getArgumentsShortLabel('not valid json'); expect(result).to.be.undefined; }); it('returns undefined when pattern key is missing', () => { const result = getArgumentsShortLabel(JSON.stringify({ glob: '**/*.ts' })); expect(result).to.be.undefined; }); }); describe('FindFilesByPattern.findFiles', () => { let container: Container; let findFilesByPattern: FindFilesByPattern; let fileSearchService: ReturnType; let searchResults: string[]; let roots: Array<{ resource: URI }>; let prefs: { [key: string]: unknown }; let allowedExternal: string[]; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); // External hits are rendered with `Path.fsPath()`, which branches on the backend OS, so the POSIX // fixtures below pin it instead of inheriting the OS the tests happen to run on. let originalIsWindows: boolean; beforeEach(() => { originalIsWindows = OS.backend.isWindows; OS.backend.isWindows = false; }); afterEach(() => { OS.backend.isWindows = originalIsWindows; }); beforeEach(() => { container = new Container(); searchResults = []; roots = [{ resource: new URI('file:///workspace') }]; prefs = { 'ai-features.workspaceFunctions.considerGitIgnore': true, 'ai-features.workspaceFunctions.userExcludes': ['node_modules', 'lib'] }; allowedExternal = []; const mockWorkspaceService = { roots: Promise.resolve(roots), tryGetRoots: () => roots, onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => ({ isDirectory: true, children: [], resource: uri }), read: async () => ({ value: { toString: () => '' } }) } as unknown as FileService; const mockPreferenceService = { get: (path: string, defaultValue: T) => (path in prefs ? prefs[path] as T : defaultValue) }; const trustAwareReader = { get: (name: string, fallback?: T) => (name === 'ai-features.workspaceFunctions.allowedExternalPaths' ? (allowedExternal as unknown as T) : fallback), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService; fileSearchService = makeFileSearchService(async () => searchResults); container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(fileSearchService); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FindFilesByPattern).toSelf(); findFilesByPattern = container.get(FindFilesByPattern); }); const call = (args: object, ctx?: ToolInvocationContext) => findFilesByPattern.getTool().handler(JSON.stringify(args), ctx) as Promise; it('returns root-prefixed workspace-relative paths', async () => { searchResults = ['file:///workspace/src/a.ts', 'file:///workspace/src/b.ts']; const result = JSON.parse(await call({ pattern: '**/*.ts' })); expect(result.files).to.deep.equal(['workspace/src/a.ts', 'workspace/src/b.ts']); }); it('passes the glob, user excludes, gitignore flag and root to the search service', async () => { await call({ pattern: '**/*.ts', exclude: ['**/*.spec.ts'] }); expect(fileSearchService.calls).to.have.length(1); const options = fileSearchService.calls[0].options; expect(options.includePatterns).to.deep.equal(['**/*.ts']); expect(options.excludePatterns).to.deep.equal(['node_modules', 'lib', '**/*.spec.ts']); expect(options.useGitIgnore).to.equal(true); expect(options.rootUris).to.deep.equal(['file:///workspace']); expect(options.fuzzyMatch).to.equal(false); }); it('does not use gitignore when the preference is disabled', async () => { prefs['ai-features.workspaceFunctions.considerGitIgnore'] = false; await call({ pattern: '**/*.ts' }); expect(fileSearchService.calls[0].options.useGitIgnore).to.equal(false); }); it('reports an error when no workspace is open', async () => { roots.length = 0; const result = JSON.parse(await call({ pattern: '**/*.ts' })); expect(result.error).to.equal('No workspace has been opened yet'); }); it('respects the cancellation token', async () => { const cts = new CancellationTokenSource(); cts.cancel(); const result = JSON.parse(await call({ pattern: '**/*.ts' }, { cancellationToken: cts.token })); expect(result.error).to.equal('Operation cancelled by user'); }); it('truncates to 200 results and flags truncation', async () => { searchResults = Array.from({ length: 250 }, (_, i) => `file:///workspace/f${i}.ts`); const result = JSON.parse(await call({ pattern: '**/*.ts' })); expect(result.files).to.have.length(200); expect(result.truncated).to.equal(true); }); it('returns absolute paths for an allow-listed external searchRoot', async () => { allowedExternal = ['/external/data']; searchResults = ['file:///external/data/x.ts']; const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data' })); expect(result.files).to.deep.equal(['/external/data/x.ts']); expect(fileSearchService.calls[0].options.rootUris).to.deep.equal(['file:///external/data']); }); it('does not apply gitignore to external roots (it is scoped to workspace roots)', async () => { prefs['ai-features.workspaceFunctions.considerGitIgnore'] = true; allowedExternal = ['/external/data']; await call({ pattern: '**/*.ts', searchRoot: '/external/data' }); const options = fileSearchService.calls[0].options; expect(options.useGitIgnore).to.equal(false); expect(options.excludePatterns).to.include('.git'); }); it('still applies gitignore to workspace roots', async () => { prefs['ai-features.workspaceFunctions.considerGitIgnore'] = true; await call({ pattern: '**/*.ts' }); expect(fileSearchService.calls[0].options.useGitIgnore).to.equal(true); }); it('matches an allow-list entry with a trailing slash against a searchRoot without one', async () => { allowedExternal = ['/external/data/']; // trailing slash, as a user/file-picker may enter it searchResults = ['file:///external/data/x.ts']; const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data' })); expect(result.error).to.be.undefined; expect(result.files).to.deep.equal(['/external/data/x.ts']); }); it('matches an allow-list entry without a trailing slash against a searchRoot that has one', async () => { allowedExternal = ['/external/data']; searchResults = ['file:///external/data/x.ts']; const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: '/external/data/' })); expect(result.error).to.be.undefined; expect(result.files).to.deep.equal(['/external/data/x.ts']); }); describe('on Windows', () => { // The tool promises the returned paths can be passed back to the file tools, so an external // hit must be rendered as a native path and not as the URI-style `/c:/...`. it('returns native paths for an allow-listed external searchRoot', async () => { OS.backend.isWindows = true; allowedExternal = ['C:\\external\\data']; searchResults = ['file:///c%3A/external/data/x.ts']; const result = JSON.parse(await call({ pattern: '**/*.ts', searchRoot: 'C:\\external\\data' })); expect(result.error).to.be.undefined; expect(result.files).to.deep.equal(['C:\\external\\data\\x.ts']); }); }); }); describe('WorkspaceFunctionScope gitignore caching', () => { let container: Container; let scope: WorkspaceFunctionScope; let resolveCount: number; let gitignoreReadCount: number; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); resolveCount = 0; gitignoreReadCount = 0; const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { resolve: async (uri: URI) => { resolveCount++; return { isDirectory: true, resource: uri }; }, read: async (uri: URI) => { if (uri.path.base === '.gitignore') { gitignoreReadCount++; return { value: 'dist/\n' }; } throw new Error('not found'); }, watch: () => ({ dispose: () => { } }), onDidFilesChange: () => ({ dispose: () => { } }) } as unknown as FileService; const mockPreferenceService = { get: (path: string, defaultValue: T) => (path === 'ai-features.workspaceFunctions.considerGitIgnore' ? (true as unknown as T) : defaultValue) }; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); scope = container.get(WorkspaceFunctionScope); }); const stat = (path: string, isDirectory = false) => ({ resource: new URI(path), isDirectory, path: { base: path.split('/').pop() } }) as unknown as FileStat; it('still excludes gitignored paths and keeps others', async () => { expect(await scope.shouldExclude(stat('file:///workspace/dist/a.js'))).to.be.true; expect(await scope.shouldExclude(stat('file:///workspace/src/b.ts'))).to.be.false; }); it('reads .gitignore at most once and issues no per-check resolve RPC', async () => { for (let i = 0; i < 25; i++) { await scope.shouldExclude(stat(`file:///workspace/src/file${i}.ts`)); } expect(gitignoreReadCount).to.equal(1); expect(resolveCount).to.equal(0); }); }); describe('GetWorkspaceFileList resolves the target directory once', () => { let container: Container; let tool: GetWorkspaceFileList; let resolveCount: number; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); resolveCount = 0; const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => { resolveCount++; return { isDirectory: true, resource: uri, children: [ { isDirectory: false, resource: new URI('file:///workspace/sub/a.ts'), path: { base: 'a.ts' } }, { isDirectory: true, resource: new URI('file:///workspace/sub/dir'), path: { base: 'dir' } } ] }; }, read: async () => ({ value: { toString: () => '' } }), watch: () => ({ dispose: () => { } }), onDidFilesChange: () => ({ dispose: () => { } }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, def: T) => def }; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceFileList).toSelf(); tool = container.get(GetWorkspaceFileList); }); it('lists one level and resolves the directory only once', async () => { const result = JSON.parse(await tool.getTool().handler(JSON.stringify({ path: 'workspace/sub' }), undefined) as string); expect(result).to.deep.equal({ 'a.ts': 'file', 'dir': 'directory' }); expect(resolveCount).to.equal(1); }); }); describe('GetWorkspaceDirectoryStructure preserves empty folders', () => { let container: Container; let tool: GetWorkspaceDirectoryStructure; const TREE: Record = { 'file:///workspace': ['file:///workspace/src', 'file:///workspace/empty', 'file:///workspace/node_modules'], 'file:///workspace/src': ['file:///workspace/src/nested'], 'file:///workspace/src/nested': [], 'file:///workspace/empty': [], 'file:///workspace/node_modules': ['file:///workspace/node_modules/pkg'] }; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); const mockWorkspaceService = { roots: Promise.resolve([{ resource: new URI('file:///workspace') }]), tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { resolve: async (uri: URI) => ({ isDirectory: true, resource: uri, children: (TREE[uri.toString()] ?? []).map(child => ({ isDirectory: true, resource: new URI(child), path: { base: child.split('/').pop() } })) }), read: async () => ({ value: { toString: () => '' } }), watch: () => ({ dispose: () => { } }), onDidFilesChange: () => ({ dispose: () => { } }) } as unknown as FileService; const mockPreferenceService = { get: (path: string, def: T) => (path === 'ai-features.workspaceFunctions.userExcludes' ? (['node_modules'] as unknown as T) : def) }; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceDirectoryStructure).toSelf(); tool = container.get(GetWorkspaceDirectoryStructure); }); it('includes empty directories and excludes user-excluded ones', async () => { const result = await tool.getTool().handler(JSON.stringify({}), undefined); expect(result).to.deep.equal({ workspace: { src: { nested: {} }, empty: {} } }); }); }); // ── HEAD: External allowed paths tests ────────────────────────────────────── describe('FileContentFunction external paths', () => { let container: Container; let fileContentFunction: FileContentFunction; let allowedPaths: string[]; let trustedScopeOnly: boolean; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); allowedPaths = []; trustedScopeOnly = false; const mockWorkspaceService = { roots: [{ resource: new URI('file:///workspace') }], tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => ({ isFile: true, isDirectory: false, size: 1024, resource: uri }), read: async (uri: URI) => ({ value: `content-of-${uri.path.toString()}` }), readStream: async () => { throw new Error('not used'); } } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const mockMonacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace; const trustAwareReader = { get: (_name: string, fallback?: T) => (trustedScopeOnly ? fallback : (allowedPaths as unknown as T)), ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test')); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); fileContentFunction = container.get(FileContentFunction); }); const callTool = (file: string) => fileContentFunction.getTool().handler(JSON.stringify({ file }), undefined) as Promise; it('rejects absolute path outside the workspace when allow-list is empty', async () => { const result = await callTool('/etc/hosts'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); expect(parsed.error).to.include('allowedExternalPaths'); }); it('allows absolute path inside an allow-listed directory', async () => { allowedPaths = ['/external/configs']; const result = await callTool('/external/configs/myapp.json'); expect(result).to.equal('content-of-/external/configs/myapp.json'); }); it('allows file:// URI inside an allow-listed directory', async () => { allowedPaths = ['file:///external/configs']; const result = await callTool('file:///external/configs/myapp.json'); expect(result).to.equal('content-of-/external/configs/myapp.json'); }); it('rejects sibling that shares the prefix but is not a child', async () => { // /external/configs-other must NOT be matched by /external/configs allowedPaths = ['/external/configs']; const result = await callTool('/external/configs-other/myapp.json'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); it('rejects path with .. traversal', async () => { allowedPaths = ['/external/configs']; const result = await callTool('/external/configs/../secrets.txt'); const parsed = JSON.parse(result); expect(parsed.error).to.include('Invalid path'); }); it('expands ~ in allow-list entries', async () => { allowedPaths = ['~/configs']; const result = await callTool('/home/test/configs/myapp.json'); expect(result).to.equal('content-of-/home/test/configs/myapp.json'); }); it('expands ~ in tool input paths', async () => { allowedPaths = ['/home/test/configs']; const result = await callTool('~/configs/myapp.json'); expect(result).to.equal('content-of-/home/test/configs/myapp.json'); }); it('expands ~ in both allow-list entries and tool input paths', async () => { allowedPaths = ['~/configs']; const result = await callTool('~/configs/myapp.json'); expect(result).to.equal('content-of-/home/test/configs/myapp.json'); }); it('rejects ~-prefixed tool input that escapes via .. traversal', async () => { allowedPaths = ['/home/test/configs']; const result = await callTool('~/configs/../secrets.txt'); const parsed = JSON.parse(result); expect(parsed.error).to.include('Invalid path'); }); it('still allows workspace-relative paths', async () => { allowedPaths = []; const result = await callTool('src/index.ts'); expect(result).to.equal('content-of-/workspace/src/index.ts'); }); it('accepts Windows-style absolute paths in the allow-list', async () => { // Mixed: backslash separators in the entry, forward-slash in the file argument allowedPaths = ['C:\\external\\configs']; const result = await callTool('C:/external/configs/myapp.json'); // The mock returns content keyed off the resolved URI's path. Both forms // normalize to the same `file:///c:/external/configs/myapp.json`. expect(result).to.match(/^content-of-/); expect(result).to.include('external/configs/myapp.json'); }); it('accepts Windows drive paths as the tool argument', async () => { allowedPaths = ['C:/external']; const result = await callTool('C:\\external\\file.txt'); expect(result).to.match(/^content-of-/); expect(result).to.include('external/file.txt'); }); it('drops workspace-scoped allow-list values when workspace is untrusted', async () => { // simulate trust-aware reader behavior: when untrusted, only user/default scope is returned trustedScopeOnly = true; const result = await callTool('/external/configs/myapp.json'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); }); describe('WorkspaceFunctionScope accessible root contributions', () => { let container: Container; let fileContentFunction: FileContentFunction; let contributedRoots: URI[]; let contributionFails: boolean; let rootQueries: number; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); container.bind(ILogger).to(MockLogger); contributedRoots = []; contributionFails = false; rootQueries = 0; const mockWorkspaceService = { roots: [{ resource: new URI('file:///workspace') }], tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => ({ isFile: true, isDirectory: false, size: 1024, resource: uri }), read: async (uri: URI) => ({ value: `content-of-${uri.path.toString()}` }) } as unknown as FileService; const contribution: AccessibleRootContribution = { getRoots: async () => { rootQueries++; if (contributionFails) { throw new Error('cannot resolve roots'); } return contributedRoots; } }; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue({ get: (_path: string, defaultValue: T) => defaultValue }); container.bind(MonacoWorkspace).toConstantValue({ getTextDocument: () => undefined } as unknown as MonacoWorkspace); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); bindRootContributionProvider(container, AccessibleRootContribution); container.bind(AccessibleRootContribution).toConstantValue(contribution); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); fileContentFunction = container.get(FileContentFunction); }); const callTool = (file: string) => fileContentFunction.getTool().handler(JSON.stringify({ file }), undefined) as Promise; it('allows a file under a contributed root although the allow-list is empty', async () => { contributedRoots = [new URI('file:///config/workspace-metadata/uuid/memory')]; const result = await callTool('/config/workspace-metadata/uuid/memory/wiki/index.md'); expect(result).to.equal('content-of-/config/workspace-metadata/uuid/memory/wiki/index.md'); }); it('rejects a sibling that only shares the contributed root\'s prefix', async () => { contributedRoots = [new URI('file:///config/memory')]; const parsed = JSON.parse(await callTool('/config/memory-other/wiki/index.md')); expect(parsed.error).to.include('not allowed'); }); it('matches a contributed root that carries a trailing separator', async () => { contributedRoots = [new URI('file:///config/memory/')]; const result = await callTool('/config/memory/wiki/index.md'); expect(result).to.equal('content-of-/config/memory/wiki/index.md'); }); it('re-queries the contributions, so a root that moves needs no invalidation', async () => { contributedRoots = [new URI('file:///config/first/memory')]; expect(await callTool('/config/first/memory/notes.md')).to.match(/^content-of-/); contributedRoots = [new URI('file:///config/second/memory')]; expect(JSON.parse(await callTool('/config/first/memory/notes.md')).error).to.include('not allowed'); expect(await callTool('/config/second/memory/notes.md')).to.match(/^content-of-/); expect(rootQueries).to.equal(3); }); it('keeps checking the other sources when a contribution fails', async () => { contributionFails = true; const parsed = JSON.parse(await callTool('/config/memory/wiki/index.md')); expect(parsed.error).to.include('not allowed'); // The workspace itself is unaffected by the broken contribution. expect(await callTool('src/index.ts')).to.equal('content-of-/workspace/src/index.ts'); }); it('is not consulted for paths inside the workspace', async () => { expect(await callTool('src/index.ts')).to.equal('content-of-/workspace/src/index.ts'); expect(rootQueries).to.equal(0); }); }); describe('GetWorkspaceFileList / GetWorkspaceDirectoryStructure with external paths', () => { let container: Container; let allowedPaths: string[]; const FS_TREE: Record = { 'file:///workspace': { isDirectory: true, children: ['file:///workspace/a.ts', 'file:///workspace/sub'] }, 'file:///workspace/a.ts': { isDirectory: false }, 'file:///workspace/sub': { isDirectory: true, children: [] }, 'file:///external/configs': { isDirectory: true, children: ['file:///external/configs/myapp.json', 'file:///external/configs/sub'] }, 'file:///external/configs/myapp.json': { isDirectory: false }, 'file:///external/configs/sub': { isDirectory: true, children: [] } }; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); allowedPaths = []; const mockWorkspaceService = { roots: [{ resource: new URI('file:///workspace') }], tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => { const node = FS_TREE[uri.toString()]; if (!node) { throw new Error('not found'); } return { isDirectory: node.isDirectory, isFile: !node.isDirectory, resource: uri, children: node.children?.map(child => ({ isDirectory: !!FS_TREE[child]?.isDirectory, isFile: !FS_TREE[child]?.isDirectory, resource: new URI(child), path: { base: child.split('/').pop() } })) ?? [] }; }, read: async () => ({ value: '' }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const trustAwareReader = { get: (_name: string, _fallback?: T) => allowedPaths as unknown as T, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(GetWorkspaceFileList).toSelf(); container.bind(GetWorkspaceDirectoryStructure).toSelf(); }); it('GetWorkspaceFileList rejects absolute path outside the allow-list', async () => { const tool = container.get(GetWorkspaceFileList).getTool(); const result = await tool.handler(JSON.stringify({ path: '/external/configs' }), undefined) as string; const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); it('GetWorkspaceFileList lists an allow-listed external directory', async () => { allowedPaths = ['/external/configs']; const tool = container.get(GetWorkspaceFileList).getTool(); const result = await tool.handler(JSON.stringify({ path: '/external/configs' }), undefined); expect(JSON.parse(result as string)).to.deep.equal({ 'myapp.json': 'file', 'sub': 'directory' }); }); it('GetWorkspaceFileList still lists workspace-relative paths', async () => { const tool = container.get(GetWorkspaceFileList).getTool(); const result = await tool.handler(JSON.stringify({ path: '' }), undefined); expect(JSON.parse(result as string)).to.deep.equal({ workspace: 'directory' }); }); it('GetWorkspaceDirectoryStructure rejects external root not in the allow-list', async () => { const tool = container.get(GetWorkspaceDirectoryStructure).getTool(); const result = await tool.handler(JSON.stringify({ root: '/external/configs' }), undefined) as Record; expect(result.error).to.include('not allowed'); }); it('GetWorkspaceDirectoryStructure walks an allow-listed external root', async () => { allowedPaths = ['/external/configs']; const tool = container.get(GetWorkspaceDirectoryStructure).getTool(); const result = await tool.handler(JSON.stringify({ root: '/external/configs' }), undefined); expect(result).to.deep.equal({ sub: {} }); }); it('GetWorkspaceDirectoryStructure preserves prior workspace-only behavior when root omitted', async () => { const tool = container.get(GetWorkspaceDirectoryStructure).getTool(); const result = await tool.handler('', undefined); expect(result).to.deep.equal({ workspace: { sub: {} } }); }); }); describe('FindFilesByPattern with searchRoot', () => { let container: Container; let findFilesByPattern: FindFilesByPattern; let allowedPaths: string[]; const FILES_BY_ROOT: Record = { 'file:///workspace': ['file:///workspace/a.ts'], 'file:///external/configs': ['file:///external/configs/myapp.json', 'file:///external/configs/other.txt'] }; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); // External hits are rendered with `Path.fsPath()`, which branches on the backend OS, so the POSIX // fixtures below pin it instead of inheriting the OS the tests happen to run on. let originalIsWindows: boolean; beforeEach(() => { originalIsWindows = OS.backend.isWindows; OS.backend.isWindows = false; }); afterEach(() => { OS.backend.isWindows = originalIsWindows; }); beforeEach(() => { container = new Container(); allowedPaths = []; const mockWorkspaceService = { roots: [{ resource: new URI('file:///workspace') }], tryGetRoots: () => [{ resource: new URI('file:///workspace') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => ({ isDirectory: true, children: [], resource: uri }), read: async () => ({ value: { toString: () => '' } }) } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const trustAwareReader = { get: (_name: string, _fallback?: T) => allowedPaths as unknown as T, ready: Promise.resolve(), onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(FileSearchService).toConstantValue(makeRipgrepLikeSearchService(FILES_BY_ROOT)); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FindFilesByPattern).toSelf(); findFilesByPattern = container.get(FindFilesByPattern); }); const callTool = (args: object) => findFilesByPattern.getTool().handler(JSON.stringify(args), undefined) as Promise; it('searches the workspace by default and returns relative paths', async () => { const result = await callTool({ pattern: '**/*.ts' }); const parsed = JSON.parse(result); expect(parsed.files).to.deep.equal(['workspace/a.ts']); }); it('rejects searchRoot that is not in the allow-list', async () => { const result = await callTool({ pattern: '**/*.json', searchRoot: '/external/configs' }); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); it('searches an allow-listed external root and returns absolute paths', async () => { allowedPaths = ['/external/configs']; const result = await callTool({ pattern: '**/*.json', searchRoot: '/external/configs' }); const parsed = JSON.parse(result); expect(parsed.files).to.deep.equal(['/external/configs/myapp.json']); }); it('honors exclude patterns when using an external searchRoot', async () => { allowedPaths = ['/external/configs']; const result = await callTool({ pattern: '**/*', exclude: ['**/*.txt'], searchRoot: '/external/configs' }); const parsed = JSON.parse(result); expect(parsed.files).to.deep.equal(['/external/configs/myapp.json']); }); }); describe('WorkspaceFunctionScope path-traversal hardening', () => { let container: Container; let fileContentFunction: FileContentFunction; let allowedPaths: string[]; let trustReady: Promise; let trustAwareGet: (name: string, fallback?: T) => T | undefined; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); allowedPaths = []; trustReady = Promise.resolve(); trustAwareGet = (_name: string, _fallback?: T) => allowedPaths as unknown as T; const mockWorkspaceService = { roots: [{ resource: new URI('file:///workspace/project') }], tryGetRoots: () => [{ resource: new URI('file:///workspace/project') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; const mockFileService = { exists: async () => true, resolve: async (uri: URI) => ({ isFile: true, isDirectory: false, size: 1024, resource: uri }), read: async (uri: URI) => ({ value: `content-of-${uri.path.toString()}` }), readStream: async () => { throw new Error('not used'); } } as unknown as FileService; const mockPreferenceService = { get: (_path: string, defaultValue: T) => defaultValue }; const mockMonacoWorkspace = { getTextDocument: () => undefined } as unknown as MonacoWorkspace; const trustAwareReader = { get: (name: string, fallback?: T) => trustAwareGet(name, fallback), get ready(): Promise { return trustReady; }, onDidChangeTrust: () => ({ dispose: () => { /* noop */ } }) } as unknown as AiConfigurationService; container.bind(ILogger).to(MockLogger); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue(mockFileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService); container.bind(MonacoWorkspace).toConstantValue(mockMonacoWorkspace); container.bind(AiConfigurationService).toConstantValue(trustAwareReader); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer('file:///home/test')); container.bind(WorkspaceFunctionScope).toSelf(); container.bind(FileContentFunction).toSelf(); container.bind(FileDiagnosticProvider).toSelf(); const mockProblemManager = { findMarkers: () => [], onDidChangeMarkers: () => ({ dispose: () => { /* noop */ } }) } as unknown as ProblemManager; container.bind(ProblemManager).toConstantValue(mockProblemManager); const mockMonacoTextModelService = { createModelReference: async () => ({ object: { lineCount: 10, getText: () => '' }, dispose: () => { /* noop */ } }) } as unknown as MonacoTextModelService; container.bind(MonacoTextModelService).toConstantValue(mockMonacoTextModelService); fileContentFunction = container.get(FileContentFunction); }); const callContent = (file: string) => fileContentFunction.getTool().handler(JSON.stringify({ file }), undefined) as Promise; // Item 1 — URL-encoded and literal `..` in file:// URIs must be normalized // before the allow-list check; otherwise the path-component prefix admits // the traversal. it('rejects file:// URI with literal .. traversal', async () => { allowedPaths = ['/external/configs']; const result = await callContent('file:///external/configs/../secrets.txt'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); it('rejects file:// URI with percent-encoded %2e%2e traversal', async () => { allowedPaths = ['/external/configs']; const result = await callContent('file:///external/configs/%2e%2e/secrets.txt'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); it('rejects file:// URI with upper-case percent-encoded traversal', async () => { allowedPaths = ['/external/configs']; const result = await callContent('file:///external/configs/%2E%2E/secrets.txt'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); // Item 4 — A filename that contains `..` as a substring but is not a // traversal segment must be accepted. it('accepts a valid filename whose name contains ".." as a substring', async () => { allowedPaths = ['/external/configs']; const result = await callContent('/external/configs/my..file.json'); expect(result).to.equal('content-of-/external/configs/my..file.json'); }); // Item 3 — Non-file allow-list entries are documented as unsupported. it('rejects http:// allow-list entry even if target path appears to match', async () => { allowedPaths = ['http://example.com/external/configs']; const result = await callContent('/external/configs/myapp.json'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); // Item 7 — getAllowedExternalUris must await AiConfigurationService.ready // so that preference reads see the resolved trust state. it('awaits AiConfigurationService.ready before reading the allow-list', async () => { let preferenceVisible = false; let resolveReady: () => void = () => { /* noop */ }; trustReady = new Promise(r => { resolveReady = r; }); trustAwareGet = (_name: string, _fallback?: T) => (preferenceVisible ? ['/external/configs'] : []) as unknown as T; const promise = callContent('/external/configs/myapp.json'); // Simulate trust state becoming available after a microtask: the // allow-list is only readable once `ready` resolves. await Promise.resolve(); preferenceVisible = true; resolveReady(); const result = await promise; expect(result).to.equal('content-of-/external/configs/myapp.json'); }); // Item 2 — On case-insensitive filesystems (Windows, macOS by default) // the allow-list match must be case-insensitive. Stub OS.backend flags // for the test and restore them afterwards. describe('case-insensitive match on case-insensitive filesystems', () => { let originalIsWindows: boolean; let originalIsOSX: boolean; beforeEach(() => { originalIsWindows = OS.backend.isWindows; originalIsOSX = OS.backend.isOSX; }); afterEach(() => { OS.backend.isWindows = originalIsWindows; OS.backend.isOSX = originalIsOSX; }); it('admits a case-mismatched Windows target when allow-list entry is mixed-case', async () => { OS.backend.isWindows = true; OS.backend.isOSX = false; allowedPaths = ['C:\\External\\Configs']; const result = await callContent('c:/external/configs/myapp.json'); // The mock returns the URI's path; both forms canonicalize to the // same file:///c:/... URI (drive letter is lowercased by URI.fromFilePath), // but the path segments differ in case. expect(result).to.match(/^content-of-/); expect(result.toLowerCase()).to.include('external/configs/myapp.json'); }); it('admits a case-mismatched macOS target when allow-list entry is mixed-case', async () => { OS.backend.isWindows = false; OS.backend.isOSX = true; allowedPaths = ['/Users/safi/External/Configs']; const result = await callContent('/users/safi/external/configs/myapp.json'); expect(result).to.match(/^content-of-/); expect(result.toLowerCase()).to.include('/users/safi/external/configs/myapp.json'); }); it('keeps Linux case-sensitive: rejects a case-mismatched target', async () => { OS.backend.isWindows = false; OS.backend.isOSX = false; allowedPaths = ['/home/safi/External/Configs']; const result = await callContent('/home/safi/external/configs/myapp.json'); const parsed = JSON.parse(result); expect(parsed.error).to.include('not allowed'); }); }); // getFileDiagnostics goes through the same `resolveAccessiblePath` gate as every other tool, so a // sibling that merely shares the workspace root's string prefix must not pass. it('FileDiagnosticProvider rejects sibling-prefix path outside the workspace', async () => { const diag = container.get(FileDiagnosticProvider); const handler = diag.getTool().handler; // Workspace root is file:///workspace/project, so file:///workspace/project-other/file.txt is // outside it — and a `..` segment is refused before it can even be resolved. const result = await handler(JSON.stringify({ file: '../project-other/file.txt' }), undefined); const parsed = JSON.parse(result as string); expect(parsed.error).to.include('Invalid path'); }); }); // ── Branch: Multi-root workspace tests ────────────────────────────────────── describe('WorkspaceFunctionScope Multi-Root Tests', () => { let container: Container; let workspaceScope: WorkspaceFunctionScope; let disableJSDOMInner: () => void; before(() => { disableJSDOMInner = enableJSDOM(); }); after(() => { disableJSDOMInner(); }); beforeEach(() => { container = new Container(); container.bind(ILogger).to(MockLogger); }); describe('getRootMapping', () => { it('returns single root with its basename', () => { const mockWorkspaceService = { tryGetRoots: () => [{ resource: new URI('file:///home/user/my-project') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const mapping = workspaceScope.getRootMapping(); expect(mapping.size).to.equal(1); expect(mapping.get('my-project')?.toString()).to.equal('file:///home/user/my-project'); }); it('returns multiple roots with their basenames', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') }, { resource: new URI('file:///home/user/shared') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const mapping = workspaceScope.getRootMapping(); expect(mapping.size).to.equal(3); expect(mapping.get('frontend')?.toString()).to.equal('file:///home/user/frontend'); expect(mapping.get('backend')?.toString()).to.equal('file:///home/user/backend'); expect(mapping.get('shared')?.toString()).to.equal('file:///home/user/shared'); }); it('uses first-wins for duplicate basenames (sorted by URI)', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///alice/app') }, { resource: new URI('file:///bob/app') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const mapping = workspaceScope.getRootMapping(); // Only one entry for 'app' — the first by URI sort order wins. // The other root is still reachable via resolveRelativePath fallback. expect(mapping.size).to.equal(1); expect(mapping.get('app')?.toString()).to.equal('file:///alice/app'); }); }); describe('toWorkspaceRelativePath', () => { it('returns root-prefixed path for file in single root', () => { const mockWorkspaceService = { tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const uri = new URI('file:///home/user/project/src/index.ts'); const result = workspaceScope.toWorkspaceRelativePath(uri); expect(result).to.equal('project/src/index.ts'); }); it('returns root-prefixed path for file in multi-root workspace', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const frontendFile = new URI('file:///home/user/frontend/src/App.tsx'); expect(workspaceScope.toWorkspaceRelativePath(frontendFile)).to.equal('frontend/src/App.tsx'); const backendFile = new URI('file:///home/user/backend/src/server.ts'); expect(workspaceScope.toWorkspaceRelativePath(backendFile)).to.equal('backend/src/server.ts'); }); it('returns undefined for file outside workspace', () => { const mockWorkspaceService = { tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const uri = new URI('file:///etc/passwd'); const result = workspaceScope.toWorkspaceRelativePath(uri); expect(result).to.be.undefined; }); it('returns just root name for the root directory itself', () => { const mockWorkspaceService = { tryGetRoots: () => [{ resource: new URI('file:///home/user/project') }], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const uri = new URI('file:///home/user/project'); const result = workspaceScope.toWorkspaceRelativePath(uri); // When the URI is the root itself, it returns just the root name expect(result).to.equal('project'); }); }); describe('resolveRelativePath', () => { function createScope(roots: string[]): WorkspaceFunctionScope { const rootObjects = roots.map(r => ({ resource: new URI(r) })); const mockWorkspaceService = { tryGetRoots: () => rootObjects, onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); return container.get(WorkspaceFunctionScope); } describe('Phase 1 — root+relative', () => { it('resolves path with matching root prefix', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('backend/src/server.ts'); expect(result.toString()).to.equal('file:///home/user/backend/src/server.ts'); }); it('returns the root URI when path is just a root name', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('frontend'); expect(result.toString()).to.equal('file:///home/user/frontend'); }); it('root name takes priority over subdirectory with same name', () => { // Root named 'src' exists, AND another root has a 'src/' subdirectory workspaceScope = createScope(['file:///home/user/src', 'file:///home/user/app']); const result = workspaceScope.resolveRelativePath('src/index.ts'); expect(result.toString()).to.equal('file:///home/user/src/index.ts'); }); }); describe('Phase 2 — supra-relative', () => { it('resolves when root basename appears with matching preceding components', () => { workspaceScope = createScope(['file:///home/user/backend']); // 'user/backend/src/file.ts' — 'backend' matches root basename, 'user' matches preceding component const result = workspaceScope.resolveRelativePath('user/backend/src/file.ts'); expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts'); }); it('resolves with full preceding path match', () => { workspaceScope = createScope(['file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('home/user/backend/src/file.ts'); expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts'); }); it('does not match when preceding components differ', () => { // Must use multi-root so Phase 3 (single-root fallback) doesn't short-circuit workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); // 'other/backend/src/file.ts' — 'other' does NOT match any suffix of 'home/user' expect(() => workspaceScope.resolveRelativePath('other/backend/src/file.ts')).to.throw(/Could not resolve path/); }); it('resolves with no preceding components (just root basename at start)', () => { // This should be caught by Phase 1 (root+relative) since the first segment IS the root name workspaceScope = createScope(['file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('backend/src/file.ts'); expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts'); }); it('resolves supra-relative path in multi-root workspace', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('user/backend/src/file.ts'); expect(result.toString()).to.equal('file:///home/user/backend/src/file.ts'); }); }); describe('Phase 3 — single-root fallback', () => { it('resolves unprefixed path relative to the single root', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('src/index.ts'); expect(result.toString()).to.equal('file:///home/user/project/src/index.ts'); }); it('resolves new file path relative to single root', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('new-file.ts'); expect(result.toString()).to.equal('file:///home/user/project/new-file.ts'); }); }); describe('Phase 4 — error for unresolvable multi-root paths', () => { it('throws when unprefixed path cannot be resolved in multi-root', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); expect(() => workspaceScope.resolveRelativePath('src/index.ts')).to.throw(/Could not resolve path/); }); it('error message lists available roots', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); try { workspaceScope.resolveRelativePath('src/index.ts'); expect.fail('Expected an error'); } catch (e: unknown) { const error = e as Error; expect(error.message).to.include('frontend'); expect(error.message).to.include('backend'); } }); it('does not throw when root prefix is provided in multi-root', () => { workspaceScope = createScope(['file:///home/user/frontend', 'file:///home/user/backend']); const result = workspaceScope.resolveRelativePath('frontend/src/index.ts'); expect(result.toString()).to.equal('file:///home/user/frontend/src/index.ts'); }); }); describe('normalization', () => { it('normalizes backslash paths', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('project\\src\\index.ts'); expect(result.toString()).to.equal('file:///home/user/project/src/index.ts'); }); it('resolves .. segments', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('project/src/../src/index.ts'); expect(result.toString()).to.equal('file:///home/user/project/src/index.ts'); }); it('normalizes redundant separators', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('project/src//index.ts'); expect(result.path.toString()).to.include('project/src/index.ts'); }); it('handles trailing slashes', () => { workspaceScope = createScope(['file:///home/user/project']); const result = workspaceScope.resolveRelativePath('project/src/'); expect(result.path.toString()).to.include('project/src'); }); }); describe('duplicate root basenames', () => { it('round-trips the mapped root correctly', () => { workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']); const mapping = workspaceScope.getRootMapping(); expect(mapping.size).to.equal(1); expect(mapping.get('app')?.toString()).to.equal('file:///workspace/a/app'); // Round-trip for the mapped root const uri = mapping.get('app')!.resolve('src/index.ts'); const relPath = workspaceScope.toWorkspaceRelativePath(uri)!; expect(relPath).to.equal('app/src/index.ts'); const resolved = workspaceScope.resolveRelativePath(relPath); expect(resolved.toString()).to.equal(uri.toString()); }); it('unmapped root returns undefined from toWorkspaceRelativePath', () => { workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']); const uri = new URI('file:///workspace/b/app/src/index.ts'); const relPath = workspaceScope.toWorkspaceRelativePath(uri); expect(relPath).to.be.undefined; }); it('resolves app/path to the mapped root (Phase 1)', () => { workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']); // 'app' maps to file:///workspace/a/app (first by URI sort) const result = workspaceScope.resolveRelativePath('app/src/index.ts'); expect(result.toString()).to.equal('file:///workspace/a/app/src/index.ts'); }); // The forms the external file change notice names files by: a root name for the mapped root, a uri for the other. it('resolves a uri for the unmapped root', async () => { workspaceScope = createScope(['file:///workspace/a/app', 'file:///workspace/b/app']); const unmapped = 'file:///workspace/b/app/src/index.ts'; expect((await workspaceScope.resolveAccessiblePath(unmapped)).toString()).to.equal(unmapped); }); }); }); describe('getRootMapping duplicate basename stability', () => { it('assigns the same root to a name regardless of tryGetRoots order', () => { // First ordering const mockWorkspaceServiceA = { tryGetRoots: () => [ { resource: new URI('file:///workspace/b/app') }, { resource: new URI('file:///workspace/a/app') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceA); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); const scopeA = container.get(WorkspaceFunctionScope); const mappingA = scopeA.getRootMapping(); // Reverse ordering in a fresh container const container2 = new Container(); const mockWorkspaceServiceB = { tryGetRoots: () => [ { resource: new URI('file:///workspace/a/app') }, { resource: new URI('file:///workspace/b/app') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container2.bind(ILogger).to(MockLogger); container2.bind(WorkspaceService).toConstantValue(mockWorkspaceServiceB); container2.bind(FileService).toConstantValue({} as FileService); container2.bind(PreferenceService).toConstantValue({ get: () => false }); container2.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container2.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container2.bind(WorkspaceFunctionScope).toSelf(); const scopeB = container2.get(WorkspaceFunctionScope); const mappingB = scopeB.getRootMapping(); // Both orderings should produce the same winner for 'app' // (first by URI sort order: file:///workspace/a/app) expect(mappingA.size).to.equal(1); expect(mappingB.size).to.equal(1); expect(mappingA.get('app')?.toString()).to.equal(mappingB.get('app')?.toString()); expect(mappingA.get('app')?.toString()).to.equal('file:///workspace/a/app'); }); }); describe('getContainingRoot', () => { it('returns the most specific root for nested roots', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///projects/monorepo') }, { resource: new URI('file:///projects/monorepo/packages/shared') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); // File in nested root should resolve to the more specific root const fileInNested = new URI('file:///projects/monorepo/packages/shared/src/utils.ts'); const result = workspaceScope.getContainingRoot(fileInNested); expect(result?.toString()).to.equal('file:///projects/monorepo/packages/shared'); // File in parent root (outside nested) should resolve to parent const fileInParent = new URI('file:///projects/monorepo/src/main.ts'); const resultParent = workspaceScope.getContainingRoot(fileInParent); expect(resultParent?.toString()).to.equal('file:///projects/monorepo'); }); it('returns undefined for file outside all workspace roots', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/project') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const externalFile = new URI('file:///tmp/outside.ts'); const result = workspaceScope.getContainingRoot(externalFile); expect(result).to.be.undefined; }); }); describe('round-trip consistency', () => { it('toWorkspaceRelativePath and resolveRelativePath are inverses', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); const originalUri = new URI('file:///home/user/backend/src/index.ts'); const relativePath = workspaceScope.toWorkspaceRelativePath(originalUri)!; const resolvedUri = workspaceScope.resolveRelativePath(relativePath); expect(resolvedUri.toString()).to.equal(originalUri.toString()); }); it('paths from toWorkspaceRelativePath always have a root prefix that resolves directly', () => { const mockWorkspaceService = { tryGetRoots: () => [ { resource: new URI('file:///home/user/frontend') }, { resource: new URI('file:///home/user/backend') } ], onWorkspaceChanged: () => ({ dispose: () => { } }) } as unknown as WorkspaceService; container.bind(WorkspaceService).toConstantValue(mockWorkspaceService); container.bind(FileService).toConstantValue({} as FileService); container.bind(PreferenceService).toConstantValue({ get: () => false }); container.bind(AiConfigurationService).toConstantValue(makeTrustAwareReader()); container.bind(EnvVariablesServer).toConstantValue(makeEnvVariablesServer()); container.bind(WorkspaceFunctionScope).toSelf(); workspaceScope = container.get(WorkspaceFunctionScope); // Test for each root const roots = workspaceScope.getRootMapping(); for (const [rootName, rootUri] of roots) { const fileUri = rootUri.resolve('src/app.ts'); const relPath = workspaceScope.toWorkspaceRelativePath(fileUri)!; expect(relPath.startsWith(rootName + '/')).to.be.true; const resolved = workspaceScope.resolveRelativePath(relPath); expect(resolved.toString()).to.equal(fileUri.toString()); } }); }); });