From 1415118c933a6adbb9abcee35e36521ead9ad7a9 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 8 Aug 2026 20:31:33 +0200 Subject: [PATCH 01/11] fix(core): align mock workspace path containment --- .../test-utils/mockWorkspaceContext.test.ts | 43 +++++++++++++++++++ .../src/test-utils/mockWorkspaceContext.ts | 22 +++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/test-utils/mockWorkspaceContext.test.ts diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts new file mode 100644 index 00000000000..ac94e29ca02 --- /dev/null +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; + +describe('createMockWorkspaceContext', () => { + it('accepts missing descendants under a workspace root', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + }); + + it('does not treat a similarly prefixed sibling as inside the workspace', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling/file.txt`)).toBe( + false, + ); + }); + + it('checks additional workspace directories', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const additionalDir = mkdtempSync( + path.join(os.tmpdir(), 'qwen-workspace-'), + ); + const workspace = createMockWorkspaceContext(rootDir, [additionalDir]); + + expect( + workspace.isPathWithinWorkspace(path.join(additionalDir, 'missing.txt')), + ).toBe(true); + }); +}); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts index 67c614e9f53..66d497b6458 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -5,6 +5,8 @@ */ import { vi } from 'vitest'; +import { realpathNearestExisting } from '../utils/paths.js'; +import { isPathWithinRoot } from '../utils/workspaceContext.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; /** @@ -18,16 +20,26 @@ export function createMockWorkspaceContext( additionalDirs: string[] = [], ): WorkspaceContext { const allDirs = [rootDir, ...additionalDirs]; + const canonicalDirs = allDirs.map(canonicalizeForContainment); const mockWorkspaceContext = { addDirectory: vi.fn(), getDirectories: vi.fn().mockReturnValue(allDirs), - isPathWithinWorkspace: vi - .fn() - .mockImplementation((path: string) => - allDirs.some((dir) => path.startsWith(dir)), - ), + isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => { + const canonicalPath = canonicalizeForContainment(path); + return canonicalDirs.some((dir) => isPathWithinRoot(canonicalPath, dir)); + }), } as unknown as WorkspaceContext; return mockWorkspaceContext; } + +function canonicalizeForContainment(inputPath: string): string { + try { + return realpathNearestExisting(inputPath); + } catch { + // Some tests stub filesystem stat calls; retain the old lexical behavior + // when canonicalization is unavailable in that mocked environment. + return inputPath; + } +} From 3ef14cc7c6bade9f63b610feabce21e1ad4399b8 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 8 Aug 2026 22:53:20 +0200 Subject: [PATCH 02/11] fix(core): reject symlink cycles in workspace mock --- .../test-utils/mockWorkspaceContext.test.ts | 18 +++++++- .../src/test-utils/mockWorkspaceContext.ts | 41 +++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index ac94e29ca02..d0c1d4e9406 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -6,7 +6,7 @@ import os from 'node:os'; import path from 'node:path'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; @@ -40,4 +40,20 @@ describe('createMockWorkspaceContext', () => { workspace.isPathWithinWorkspace(path.join(additionalDir, 'missing.txt')), ).toBe(true); }); + + it('rejects paths through a symlink cycle', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const cyclePath = path.join(rootDir, 'cycle'); + symlinkSync('cycle', cyclePath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(cyclePath, 'file.txt')), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts index 66d497b6458..642af629517 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -5,9 +5,10 @@ */ import { vi } from 'vitest'; -import { realpathNearestExisting } from '../utils/paths.js'; +import { isNodeError } from '../utils/errors.js'; import { isPathWithinRoot } from '../utils/workspaceContext.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; +import * as fs from 'node:fs'; /** * Creates a mock WorkspaceContext for testing @@ -26,8 +27,14 @@ export function createMockWorkspaceContext( addDirectory: vi.fn(), getDirectories: vi.fn().mockReturnValue(allDirs), isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => { - const canonicalPath = canonicalizeForContainment(path); - return canonicalDirs.some((dir) => isPathWithinRoot(canonicalPath, dir)); + try { + const canonicalPath = canonicalizeForContainment(path); + return canonicalDirs.some((dir) => + isPathWithinRoot(canonicalPath, dir), + ); + } catch { + return false; + } }), } as unknown as WorkspaceContext; @@ -36,10 +43,30 @@ export function createMockWorkspaceContext( function canonicalizeForContainment(inputPath: string): string { try { - return realpathNearestExisting(inputPath); - } catch { - // Some tests stub filesystem stat calls; retain the old lexical behavior - // when canonicalization is unavailable in that mocked environment. + const resolved = fs.realpathSync(inputPath); + return typeof resolved === 'string' ? resolved : inputPath; + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (error.path && isFileSymlink(error.path)) { + throw error; + } + return error.path ?? inputPath; + } + + if (isNodeError(error)) { + throw error; + } + + // Some tests stub filesystem calls; retain lexical behavior for those + // mocked environments. return inputPath; } } + +function isFileSymlink(filePath: string): boolean { + try { + return !fs.readlinkSync(filePath).endsWith('/'); + } catch { + return false; + } +} From 27e1098e11365fe37246a77c0c02b790eda6bf66 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 8 Aug 2026 22:57:23 +0200 Subject: [PATCH 03/11] test(core): cover workspace mock path parity --- .../mockWorkspaceContext.fallback.test.ts | 39 +++++++++++++++++++ .../test-utils/mockWorkspaceContext.test.ts | 34 ++++++++++++++++ .../src/test-utils/mockWorkspaceContext.ts | 7 ++-- 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts diff --git a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts new file mode 100644 index 00000000000..703bb08d627 --- /dev/null +++ b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { afterAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + realpathSync: vi.fn(() => { + throw new TypeError('mocked filesystem call'); + }), + }; +}); + +import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; + +const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + +describe('createMockWorkspaceContext filesystem fallback', () => { + it('uses lexical containment when canonicalization is unavailable', () => { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false); + }); +}); + +afterAll(() => { + rmSync(rootDir, { recursive: true, force: true }); +}); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index d0c1d4e9406..4a9c36527a6 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -41,6 +41,40 @@ describe('createMockWorkspaceContext', () => { ).toBe(true); }); + it('canonicalizes workspace aliases for containment checks', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const aliasDir = path.join( + os.tmpdir(), + `qwen-workspace-alias-${Date.now()}`, + ); + symlinkSync(rootDir, aliasDir); + + try { + const workspace = createMockWorkspaceContext(aliasDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + } finally { + rmSync(aliasDir, { force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('rejects dangling leaf symlinks', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const danglingPath = path.join(rootDir, 'dangling'); + symlinkSync(path.join(rootDir, 'missing-target'), danglingPath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(danglingPath)).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + it('rejects paths through a symlink cycle', () => { const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); const cyclePath = path.join(rootDir, 'cycle'); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts index 642af629517..7b7a194a883 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -21,7 +21,6 @@ export function createMockWorkspaceContext( additionalDirs: string[] = [], ): WorkspaceContext { const allDirs = [rootDir, ...additionalDirs]; - const canonicalDirs = allDirs.map(canonicalizeForContainment); const mockWorkspaceContext = { addDirectory: vi.fn(), @@ -29,9 +28,9 @@ export function createMockWorkspaceContext( isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => { try { const canonicalPath = canonicalizeForContainment(path); - return canonicalDirs.some((dir) => - isPathWithinRoot(canonicalPath, dir), - ); + return allDirs + .map(canonicalizeForContainment) + .some((dir) => isPathWithinRoot(canonicalPath, dir)); } catch { return false; } From 6b799ee3a2d381dce2990da71041e70ea132a46f Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sun, 9 Aug 2026 14:28:32 +0200 Subject: [PATCH 04/11] fix(core): share workspace path resolution semantics --- .../mockWorkspaceContext.fallback.test.ts | 26 +++++++- .../test-utils/mockWorkspaceContext.test.ts | 62 +++++++++++++++---- .../src/test-utils/mockWorkspaceContext.ts | 40 ++++++------ packages/core/src/utils/workspaceContext.ts | 61 ++++++++++-------- 4 files changed, 126 insertions(+), 63 deletions(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts index 703bb08d627..0d0590e0cd0 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts @@ -9,12 +9,16 @@ import path from 'node:path'; import { mkdtempSync, rmSync } from 'node:fs'; import { afterAll, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; + vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, realpathSync: vi.fn(() => { - throw new TypeError('mocked filesystem call'); + const error = new Error('mocked missing path') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; }), }; }); @@ -32,6 +36,26 @@ describe('createMockWorkspaceContext filesystem fallback', () => { ).toBe(true); expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false); }); + + it('uses lexical containment when ENOENT has no path', () => { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + }); + + it('uses lexical containment for non-Node filesystem stubs', () => { + vi.mocked(fs.realpathSync).mockImplementation(() => { + throw new TypeError('mocked filesystem call'); + }); + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false); + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + }); }); afterAll(() => { diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index 4a9c36527a6..0f46a61cb31 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -6,27 +6,35 @@ import os from 'node:os'; import path from 'node:path'; -import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; describe('createMockWorkspaceContext', () => { it('accepts missing descendants under a workspace root', () => { const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); - const workspace = createMockWorkspaceContext(rootDir); + try { + const workspace = createMockWorkspaceContext(rootDir); - expect( - workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), - ).toBe(true); + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } }); it('does not treat a similarly prefixed sibling as inside the workspace', () => { const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); - const workspace = createMockWorkspaceContext(rootDir); + try { + const workspace = createMockWorkspaceContext(rootDir); - expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling/file.txt`)).toBe( - false, - ); + expect( + workspace.isPathWithinWorkspace(`${rootDir}-sibling/file.txt`), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } }); it('checks additional workspace directories', () => { @@ -34,11 +42,18 @@ describe('createMockWorkspaceContext', () => { const additionalDir = mkdtempSync( path.join(os.tmpdir(), 'qwen-workspace-'), ); - const workspace = createMockWorkspaceContext(rootDir, [additionalDir]); + try { + const workspace = createMockWorkspaceContext(rootDir, [additionalDir]); - expect( - workspace.isPathWithinWorkspace(path.join(additionalDir, 'missing.txt')), - ).toBe(true); + expect( + workspace.isPathWithinWorkspace( + path.join(additionalDir, 'missing.txt'), + ), + ).toBe(true); + } finally { + rmSync(additionalDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } }); it('canonicalizes workspace aliases for containment checks', () => { @@ -75,6 +90,27 @@ describe('createMockWorkspaceContext', () => { } }); + it('resolves existing candidate paths before checking containment', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const outsideDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-outside-')); + const insidePath = path.join(rootDir, 'inside.txt'); + const outsidePath = path.join(outsideDir, 'outside.txt'); + const escapePath = path.join(rootDir, 'escape'); + writeFileSync(insidePath, 'inside'); + writeFileSync(outsidePath, 'outside'); + symlinkSync(outsidePath, escapePath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(insidePath)).toBe(true); + expect(workspace.isPathWithinWorkspace(escapePath)).toBe(false); + } finally { + rmSync(outsideDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + it('rejects paths through a symlink cycle', () => { const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); const cyclePath = path.join(rootDir, 'cycle'); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts index 7b7a194a883..7c8dbc096c2 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -6,9 +6,11 @@ import { vi } from 'vitest'; import { isNodeError } from '../utils/errors.js'; -import { isPathWithinRoot } from '../utils/workspaceContext.js'; +import { + isPathWithinRoot, + resolveWorkspacePath, +} from '../utils/workspaceContext.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; -import * as fs from 'node:fs'; /** * Creates a mock WorkspaceContext for testing @@ -28,9 +30,16 @@ export function createMockWorkspaceContext( isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => { try { const canonicalPath = canonicalizeForContainment(path); - return allDirs - .map(canonicalizeForContainment) - .some((dir) => isPathWithinRoot(canonicalPath, dir)); + return allDirs.some((dir) => { + try { + return isPathWithinRoot( + canonicalPath, + canonicalizeForContainment(dir), + ); + } catch { + return false; + } + }); } catch { return false; } @@ -42,17 +51,12 @@ export function createMockWorkspaceContext( function canonicalizeForContainment(inputPath: string): string { try { - const resolved = fs.realpathSync(inputPath); - return typeof resolved === 'string' ? resolved : inputPath; + return resolveWorkspacePath(inputPath); } catch (error: unknown) { - if (isNodeError(error) && error.code === 'ENOENT') { - if (error.path && isFileSymlink(error.path)) { - throw error; - } - return error.path ?? inputPath; - } - if (isNodeError(error)) { + if (error.code === 'ENOENT' && !error.path) { + return inputPath; + } throw error; } @@ -61,11 +65,3 @@ function canonicalizeForContainment(inputPath: string): string { return inputPath; } } - -function isFileSymlink(filePath: string): boolean { - try { - return !fs.readlinkSync(filePath).endsWith('/'); - } catch { - return false; - } -} diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index fcb5fa91ae1..074b0bd68ea 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -273,25 +273,7 @@ export class WorkspaceContext { if (cached !== undefined) { return cached; } - let resolved: string; - try { - resolved = fs.realpathSync(pathToCheck); - } catch (e: unknown) { - if ( - isNodeError(e) && - e.code === 'ENOENT' && - e.path && - // realpathSync does not set e.path correctly for symlinks to - // non-existent files. - !this.isFileSymlink(e.path) - ) { - // If it doesn't exist, e.path contains the fully resolved path. - resolved = e.path; - } else { - // Don't cache exceptions — the path may exist on retry. - throw e; - } - } + const resolved = resolveWorkspacePath(pathToCheck); if ( this.resolvedPathCache.size >= WorkspaceContext.RESOLVED_PATH_CACHE_MAX ) { @@ -302,16 +284,41 @@ export class WorkspaceContext { this.resolvedPathCache.set(pathToCheck, resolved); return resolved; } +} - /** - * Checks if a file path is a symbolic link that points to a file. - */ - private isFileSymlink(filePath: string): boolean { - try { - return !fs.readlinkSync(filePath).endsWith('/'); - } catch (_error) { - return false; +/** + * Resolves a workspace path using the same missing-path and symlink semantics + * used by WorkspaceContext containment checks. + */ +export function resolveWorkspacePath(pathToCheck: string): string { + try { + const resolved = fs.realpathSync(pathToCheck); + return typeof resolved === 'string' ? resolved : pathToCheck; + } catch (error: unknown) { + if ( + isNodeError(error) && + error.code === 'ENOENT' && + error.path && + !isFileSymlink(error.path) + ) { + if ( + error.path !== pathToCheck && + pathToCheck.startsWith(`${error.path}${path.sep}`) + ) { + return pathToCheck; + } + return error.path; } + + throw error; + } +} + +function isFileSymlink(filePath: string): boolean { + try { + return !fs.readlinkSync(filePath).endsWith('/'); + } catch (_error) { + return false; } } From 3e25aa7f0db0bae98b383e5bf9e024c0c2dc76e9 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Tue, 11 Aug 2026 02:05:01 +0200 Subject: [PATCH 05/11] Update packages/core/src/test-utils/mockWorkspaceContext.test.ts Co-authored-by: jinye --- packages/core/src/test-utils/mockWorkspaceContext.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index 0f46a61cb31..bd29dbf9919 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -70,9 +70,7 @@ describe('createMockWorkspaceContext', () => { expect( workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), ).toBe(true); - } finally { - rmSync(aliasDir, { force: true }); - rmSync(rootDir, { recursive: true, force: true }); + rmSync(aliasDir, { recursive: true, force: true }); } }); From 76e8224d8ed1b6a04fe6777a37ae34cf97ad2020 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Tue, 11 Aug 2026 02:05:28 +0200 Subject: [PATCH 06/11] Update packages/core/src/utils/workspaceContext.ts Co-authored-by: jinye --- packages/core/src/utils/workspaceContext.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 074b0bd68ea..a9c850730ca 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -274,8 +274,6 @@ export class WorkspaceContext { return cached; } const resolved = resolveWorkspacePath(pathToCheck); - if ( - this.resolvedPathCache.size >= WorkspaceContext.RESOLVED_PATH_CACHE_MAX ) { // FIFO eviction: drop the oldest insertion (Map preserves insert order). const oldest = this.resolvedPathCache.keys().next().value; From af13809bcc6d9287aa7bff6bb2a34d9ede71e4a9 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Tue, 11 Aug 2026 02:11:41 +0200 Subject: [PATCH 07/11] fix(core): harden workspace path regression tests --- .../core/src/test-utils/mockWorkspaceContext.test.ts | 2 ++ packages/core/src/utils/workspaceContext.test.ts | 12 +++++++++++- packages/core/src/utils/workspaceContext.ts | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index bd29dbf9919..0860ee7553b 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -70,7 +70,9 @@ describe('createMockWorkspaceContext', () => { expect( workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), ).toBe(true); + } finally { rmSync(aliasDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); } }); diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 7b41be841ea..83ce1c80ffb 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { WorkspaceContext } from './workspaceContext.js'; +import { resolveWorkspacePath, WorkspaceContext } from './workspaceContext.js'; describe('WorkspaceContext with real filesystem', () => { let tempDir: string; @@ -158,6 +158,16 @@ describe('WorkspaceContext with real filesystem', () => { ); }); + it('should preserve paths with missing intermediate components', () => { + const workspaceContext = new WorkspaceContext(cwd); + const nonExistentPath = path.join(cwd, 'missing', 'nested.txt'); + + expect(resolveWorkspacePath(nonExistentPath)).toBe(nonExistentPath); + expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe( + true, + ); + }); + describe('with symbolic link', () => { describe('in the workspace', () => { let realDir: string; diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index a9c850730ca..074b0bd68ea 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -274,6 +274,8 @@ export class WorkspaceContext { return cached; } const resolved = resolveWorkspacePath(pathToCheck); + if ( + this.resolvedPathCache.size >= WorkspaceContext.RESOLVED_PATH_CACHE_MAX ) { // FIFO eviction: drop the oldest insertion (Map preserves insert order). const oldest = this.resolvedPathCache.keys().next().value; From bc3121709e712e40f096861c4e9efd4c831a8c19 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Tue, 11 Aug 2026 02:13:09 +0200 Subject: [PATCH 08/11] test(core): isolate workspace mock filesystem stubs --- .../mockWorkspaceContext.fallback.test.ts | 18 ++++++++++++------ .../test-utils/mockWorkspaceContext.test.ts | 16 ++++++++++++++++ packages/core/src/utils/workspaceContext.ts | 5 +++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts index 0d0590e0cd0..18c88b84944 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts @@ -7,19 +7,21 @@ import os from 'node:os'; import path from 'node:path'; import { mkdtempSync, rmSync } from 'node:fs'; -import { afterAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; +const throwMissingPath = vi.hoisted(() => () => { + const error = new Error('mocked missing path') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; +}); + vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, - realpathSync: vi.fn(() => { - const error = new Error('mocked missing path') as NodeJS.ErrnoException; - error.code = 'ENOENT'; - throw error; - }), + realpathSync: vi.fn(throwMissingPath), }; }); @@ -58,6 +60,10 @@ describe('createMockWorkspaceContext filesystem fallback', () => { }); }); +afterEach(() => { + vi.mocked(fs.realpathSync).mockImplementation(throwMissingPath); +}); + afterAll(() => { rmSync(rootDir, { recursive: true, force: true }); }); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index 0860ee7553b..89ef9f0e89b 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -126,4 +126,20 @@ describe('createMockWorkspaceContext', () => { rmSync(rootDir, { recursive: true, force: true }); } }); + + it('ignores an invalid workspace root when another root is valid', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const cyclePath = path.join(rootDir, 'cycle'); + const candidatePath = path.join(rootDir, 'inside.txt'); + symlinkSync('cycle', cyclePath); + writeFileSync(candidatePath, 'inside'); + + try { + const workspace = createMockWorkspaceContext(cyclePath, [rootDir]); + + expect(workspace.isPathWithinWorkspace(candidatePath)).toBe(true); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 074b0bd68ea..cd18096631e 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -299,6 +299,8 @@ export function resolveWorkspacePath(pathToCheck: string): string { isNodeError(error) && error.code === 'ENOENT' && error.path && + // realpathSync does not set error.path correctly for symlinks to + // non-existent files. !isFileSymlink(error.path) ) { if ( @@ -314,6 +316,9 @@ export function resolveWorkspacePath(pathToCheck: string): string { } } +/** + * Checks if a file path is a symbolic link that points to a file. + */ function isFileSymlink(filePath: string): boolean { try { return !fs.readlinkSync(filePath).endsWith('/'); From a7f9b8bda3517ac616ca6f87b1a05d8004453b04 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Tue, 11 Aug 2026 19:07:32 +0200 Subject: [PATCH 09/11] Update packages/core/src/utils/workspaceContext.ts Co-authored-by: qwen-code-ci-bot --- packages/core/src/utils/workspaceContext.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index cd18096631e..9d9cc0f8e16 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -303,6 +303,9 @@ export function resolveWorkspacePath(pathToCheck: string): string { // non-existent files. !isFileSymlink(error.path) ) { + // error.path is the canonical prefix up to the first missing component; + // when it is an un-rewritten prefix of the input, keep the full path so + // trailing non-existent components are preserved. if ( error.path !== pathToCheck && pathToCheck.startsWith(`${error.path}${path.sep}`) From 815f8836f59527ba110015fb774ef3bdf519e3aa Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Tue, 11 Aug 2026 19:09:33 +0200 Subject: [PATCH 10/11] fix(core): preserve symlinked missing path ancestry --- .../test-utils/mockWorkspaceContext.test.ts | 25 +++++++++++++++ packages/core/src/utils/workspaceContext.ts | 32 ++++++++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts index 89ef9f0e89b..04f6b537454 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.test.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -76,6 +76,31 @@ describe('createMockWorkspaceContext', () => { } }); + it('does not collapse missing paths below a symlinked ancestor', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const aliasDir = path.join( + os.tmpdir(), + `qwen-workspace-alias-${Date.now()}`, + ); + symlinkSync(rootDir, aliasDir); + + try { + const workspaceRoot = path.join(aliasDir, 'ghost', 'workspace'); + const siblingPath = path.join( + aliasDir, + 'ghost', + 'completely-different', + 'file.txt', + ); + const workspace = createMockWorkspaceContext(workspaceRoot); + + expect(workspace.isPathWithinWorkspace(siblingPath)).toBe(false); + } finally { + rmSync(aliasDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + it('rejects dangling leaf symlinks', () => { const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); const danglingPath = path.join(rootDir, 'dangling'); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index cd18096631e..cbb31207079 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -303,16 +303,38 @@ export function resolveWorkspacePath(pathToCheck: string): string { // non-existent files. !isFileSymlink(error.path) ) { + return resolveMissingPath(pathToCheck); + } + + throw error; + } +} + +function resolveMissingPath(pathToCheck: string): string { + const missingTail: string[] = []; + let ancestor = pathToCheck; + + while (true) { + try { + const resolvedAncestor = fs.realpathSync(ancestor); + return path.join(resolvedAncestor, ...missingTail); + } catch (error: unknown) { if ( - error.path !== pathToCheck && - pathToCheck.startsWith(`${error.path}${path.sep}`) + !isNodeError(error) || + error.code !== 'ENOENT' || + !error.path || + isFileSymlink(error.path) ) { + throw error; + } + + const parent = path.dirname(ancestor); + if (parent === ancestor) { return pathToCheck; } - return error.path; + missingTail.unshift(path.basename(ancestor)); + ancestor = parent; } - - throw error; } } From f2fc21c50952334f2f422a4e7249348290b1709a Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Tue, 11 Aug 2026 20:56:58 +0200 Subject: [PATCH 11/11] refactor(core): centralize missing path error checks --- packages/core/src/utils/workspaceContext.ts | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index cbb31207079..f287be6d514 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -295,14 +295,7 @@ export function resolveWorkspacePath(pathToCheck: string): string { const resolved = fs.realpathSync(pathToCheck); return typeof resolved === 'string' ? resolved : pathToCheck; } catch (error: unknown) { - if ( - isNodeError(error) && - error.code === 'ENOENT' && - error.path && - // realpathSync does not set error.path correctly for symlinks to - // non-existent files. - !isFileSymlink(error.path) - ) { + if (isResolvableMissingPathError(error)) { return resolveMissingPath(pathToCheck); } @@ -319,12 +312,7 @@ function resolveMissingPath(pathToCheck: string): string { const resolvedAncestor = fs.realpathSync(ancestor); return path.join(resolvedAncestor, ...missingTail); } catch (error: unknown) { - if ( - !isNodeError(error) || - error.code !== 'ENOENT' || - !error.path || - isFileSymlink(error.path) - ) { + if (!isResolvableMissingPathError(error)) { throw error; } @@ -338,6 +326,17 @@ function resolveMissingPath(pathToCheck: string): string { } } +function isResolvableMissingPathError(error: unknown): boolean { + return ( + isNodeError(error) && + error.code === 'ENOENT' && + !!error.path && + // realpathSync does not set error.path correctly for symlinks to + // non-existent files. + !isFileSymlink(error.path) + ); +} + /** * Checks if a file path is a symbolic link that points to a file. */