From 344b87a18f4009c3aeeb99f79ae34804dd7c86ce Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 6 May 2026 14:35:05 +1000 Subject: [PATCH 1/2] Add workspace path expectation guardrails --- test/commands/workspace-path-guard.test.ts | 23 ++++++ test/commands/workspace.interactive.test.ts | 5 +- test/commands/workspace.test.ts | 83 ++++++++++----------- test/core/workspace/foundation.test.ts | 5 +- test/helpers/workspace-paths.ts | 75 +++++++++++++++++++ 5 files changed, 141 insertions(+), 50 deletions(-) create mode 100644 test/commands/workspace-path-guard.test.ts create mode 100644 test/helpers/workspace-paths.ts diff --git a/test/commands/workspace-path-guard.test.ts b/test/commands/workspace-path-guard.test.ts new file mode 100644 index 000000000..71eb5c169 --- /dev/null +++ b/test/commands/workspace-path-guard.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; + +function readWorkspaceCommandTestSource(): string { + return fs.readFileSync(new URL('./workspace.test.ts', import.meta.url), 'utf-8'); +} + +describe('workspace command path expectation guardrails', () => { + it('keeps generated workspace folder expectations behind canonical path helpers', () => { + const source = readWorkspaceCommandTestSource(); + + expect(source).not.toMatch(/\.folders\)\.toEqual\(\s*\[/u); + expect(source).not.toMatch(/expect\(workspaceFolders\)\.toEqual\(\s*\[/u); + }); + + it('keeps opener launch expectations behind canonical path helpers', () => { + const source = readWorkspaceCommandTestSource(); + + expect(source).not.toMatch(/expect\([^)]*Launch\.args\)\.toEqual\(/u); + expect(source).not.toMatch(/expect\(fs\.realpathSync\.native\([^)]*Launch\.cwd\)\)\.toBe/u); + expect(source).not.toMatch(/getWorkspaceCodeWorkspacePath\(expectedExistingPath\(/u); + }); +}); diff --git a/test/commands/workspace.interactive.test.ts b/test/commands/workspace.interactive.test.ts index 1173f1ed8..16599877d 100644 --- a/test/commands/workspace.interactive.test.ts +++ b/test/commands/workspace.interactive.test.ts @@ -9,6 +9,7 @@ import { getWorkspaceLocalStatePath, parseWorkspaceLocalState, } from '../../src/core/workspace/index.js'; +import { expectedExistingPath } from '../helpers/workspace-paths.js'; vi.mock('@inquirer/prompts', () => ({ input: vi.fn(), @@ -88,10 +89,6 @@ describe('workspace command interactive flows', () => { return dir; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - function readLocalState(workspaceName: string) { const workspaceRoot = getManagedWorkspaceRoot(workspaceName); return parseWorkspaceLocalState( diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 366c6f376..f025cb72e 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -25,6 +25,12 @@ import { } from '../../src/core/workspace/index.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { + expectedExistingPath, + expectedWorkspaceCodeWorkspacePath, + expectedWorkspaceFolders, + expectWorkspaceLaunchLog, +} from '../helpers/workspace-paths.js'; describe('workspace command', () => { let tempDir: string; @@ -51,10 +57,6 @@ describe('workspace command', () => { return dir; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - function parseJson(result: RunCLIResult): any { try { return JSON.parse(result.stdout); @@ -197,19 +199,21 @@ describe('workspace command', () => { expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain( 'OpenSpec Workspace Guidance' ); - expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'platform'), 'utf-8')).folders).toEqual([ - { - path: '.', - }, - { - name: 'api', - path: expectedApi, - }, - { - name: 'checkout', - path: expectedCheckout, - }, - ]); + expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'platform'), 'utf-8')).folders).toEqual( + expectedWorkspaceFolders([ + { + path: '.', + }, + { + name: 'api', + path: api, + }, + { + name: 'checkout', + path: checkout, + }, + ]) + ); const list = await runCLI(['workspace', 'ls', '--json'], { cwd: tempDir, env }); expect(list.exitCode).toBe(0); @@ -959,7 +963,6 @@ paths: it('opens a workspace through VS Code editor and agent overrides without changing stored preference', async () => { const api = mkdir('repos/api'); - const expectedApi = expectedExistingPath(api); const web = mkdir('repos/web'); const setup = await setupWorkspace('platform', [`api=${api}`, `web=${web}`], ['--opener', 'editor']); fs.rmSync(web, { recursive: true, force: true }); @@ -977,22 +980,22 @@ paths: const workspaceFolders = JSON.parse( fs.readFileSync(getWorkspaceCodeWorkspacePath(setup.workspace.root, 'platform'), 'utf-8') ).folders; - expect(workspaceFolders).toEqual([ - { - path: '.', - }, - { - name: 'api', - path: expectedApi, - }, - ]); - const editorLaunch = readLaunchLog(code.logPath); - expect(fs.realpathSync.native(editorLaunch.cwd)).toBe( - fs.realpathSync.native(setup.workspace.root) + expect(workspaceFolders).toEqual( + expectedWorkspaceFolders([ + { + path: '.', + }, + { + name: 'api', + path: api, + }, + ]) ); - expect(editorLaunch.args).toEqual([ - getWorkspaceCodeWorkspacePath(expectedExistingPath(setup.workspace.root), 'platform'), - ]); + const editorLaunch = readLaunchLog(code.logPath); + expectWorkspaceLaunchLog(editorLaunch, { + cwd: setup.workspace.root, + args: [{ workspaceFile: { root: setup.workspace.root, name: 'platform' } }], + }); const currentWorkspaceOpen = await runCLI(['workspace', 'open', '--editor', '--no-interactive'], { cwd: path.join(setup.workspace.root, WORKSPACE_CHANGES_DIR_NAME), @@ -1011,14 +1014,10 @@ paths: expect(codexOpen.exitCode).toBe(0); const codexLaunch = readLaunchLog(codex.logPath); - expect(fs.realpathSync.native(codexLaunch.cwd)).toBe( - fs.realpathSync.native(setup.workspace.root) - ); - expect(codexLaunch.args).toEqual([ - '--add-dir', - expectedApi, - 'Open this OpenSpec workspace.', - ]); + expectWorkspaceLaunchLog(codexLaunch, { + cwd: setup.workspace.root, + args: ['--add-dir', { existingPath: api }, 'Open this OpenSpec workspace.'], + }); expect(readLocalState(setup.workspace.root).preferred_opener).toEqual({ kind: 'editor', id: 'vscode', @@ -1116,7 +1115,7 @@ preferred_opener: expect(unavailable.exitCode).toBe(1); expect(unavailable.stderr).toContain("'code' was not found on PATH"); expect(unavailable.stderr).toContain( - getWorkspaceCodeWorkspacePath(expectedExistingPath(platform.workspace.root), 'platform') + expectedWorkspaceCodeWorkspacePath(platform.workspace.root, 'platform') ); }); diff --git a/test/core/workspace/foundation.test.ts b/test/core/workspace/foundation.test.ts index f06ee6cc8..4efdec3dc 100644 --- a/test/core/workspace/foundation.test.ts +++ b/test/core/workspace/foundation.test.ts @@ -48,6 +48,7 @@ import { writeWorkspaceLocalState, writeWorkspaceRegistryState, } from '../../../src/core/workspace/index.js'; +import { expectedExistingPath } from '../../helpers/workspace-paths.js'; describe('workspace foundation', () => { let tempDir: string; @@ -84,10 +85,6 @@ paths: {} return workspaceRoot; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - describe('path helpers', () => { it('exposes the workspace constants', () => { expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace'); diff --git a/test/helpers/workspace-paths.ts b/test/helpers/workspace-paths.ts new file mode 100644 index 000000000..2779ceee1 --- /dev/null +++ b/test/helpers/workspace-paths.ts @@ -0,0 +1,75 @@ +import { expect } from 'vitest'; +import * as fs from 'node:fs'; + +import { getWorkspaceCodeWorkspacePath } from '../../src/core/workspace/index.js'; + +/** + * Workspace commands canonicalize existing filesystem paths before storing, + * reporting, or passing them to openers. On Windows, GitHub runners can expose + * temp paths through short aliases such as RUNNER~1 while Node's native + * realpath expands them to the long user path. Use these helpers for expected + * workspace command paths instead of comparing raw mkdtemp/os.tmpdir strings. + */ +export function expectedExistingPath(existingPath: string): string { + return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; +} + +function equivalentExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); +} + +export function expectedWorkspaceCodeWorkspacePath( + workspaceRoot: string, + workspaceName: string +): string { + return getWorkspaceCodeWorkspacePath(expectedExistingPath(workspaceRoot), workspaceName); +} + +export function expectedWorkspaceFolders(folders: T[]): T[] { + return folders.map((folder) => + folder.path === '.' + ? folder + : { + ...folder, + path: expectedExistingPath(folder.path), + } + ); +} + +type WorkspaceLaunchArgExpectation = + | string + | { + existingPath: string; + } + | { + workspaceFile: { + root: string; + name: string; + }; + }; + +function expectedWorkspaceLaunchArg(arg: WorkspaceLaunchArgExpectation): string { + if (typeof arg === 'string') { + return arg; + } + + if ('existingPath' in arg) { + return expectedExistingPath(arg.existingPath); + } + + return expectedWorkspaceCodeWorkspacePath(arg.workspaceFile.root, arg.workspaceFile.name); +} + +export function expectedWorkspaceLaunchArgs( + args: WorkspaceLaunchArgExpectation[] +): string[] { + return args.map(expectedWorkspaceLaunchArg); +} + +export function expectWorkspaceLaunchLog( + actual: { cwd: string; args: string[] }, + expected: { cwd: string; args: WorkspaceLaunchArgExpectation[] } +): void { + expect(equivalentExistingPath(actual.cwd)).toBe(equivalentExistingPath(expected.cwd)); + expect(actual.args).toEqual(expectedWorkspaceLaunchArgs(expected.args)); +} From a079c4b483153c186f4e3b2331bf7f34dfeeff49 Mon Sep 17 00:00:00 2001 From: TabishB Date: Wed, 6 May 2026 14:50:25 +1000 Subject: [PATCH 2/2] Tighten workspace path guardrail patterns --- test/commands/workspace-path-guard.test.ts | 82 ++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/test/commands/workspace-path-guard.test.ts b/test/commands/workspace-path-guard.test.ts index 71eb5c169..a7e901f4b 100644 --- a/test/commands/workspace-path-guard.test.ts +++ b/test/commands/workspace-path-guard.test.ts @@ -1,23 +1,95 @@ import { describe, expect, it } from 'vitest'; import * as fs from 'node:fs'; +const directPathAssertionMatchers = [ + String.raw`to(?:Strict)?Equal`, + 'toBe', + 'toMatchObject', + String.raw`toContain(?:Equal)?`, + 'toMatch', +].join('|'); +const expectArgumentWithoutNestedExpect = String.raw`(?:(?!expect\()[\s\S])*?`; +const directPathAssertionCall = String.raw`\s*\.(?:${directPathAssertionMatchers})\(`; +const expectWorkspaceFileFolders = String.raw`expect\(\s*${expectArgumentWithoutNestedExpect}\.folders\s*\)`; +const expectWorkspaceFoldersVariable = String.raw`expect\(\s*workspaceFolders\s*\)`; +const expectLaunchArgs = String.raw`expect\(\s*${expectArgumentWithoutNestedExpect}Launch\.args${expectArgumentWithoutNestedExpect}\)`; +const expectLaunchCwd = String.raw`expect\(\s*${expectArgumentWithoutNestedExpect}Launch\.cwd${expectArgumentWithoutNestedExpect}\)`; +const forbiddenWorkspaceFolderAssertionPatterns = [ + sourcePattern(String.raw`${expectWorkspaceFileFolders}${directPathAssertionCall}\s*\[`), + sourcePattern(String.raw`${expectWorkspaceFoldersVariable}${directPathAssertionCall}\s*\[`), +]; +const forbiddenLaunchAssertionPatterns = [ + sourcePattern(String.raw`${expectLaunchArgs}${directPathAssertionCall}`), + sourcePattern(String.raw`${expectLaunchCwd}${directPathAssertionCall}`), + /getWorkspaceCodeWorkspacePath\(\s*expectedExistingPath\(/u, +]; + +function sourcePattern(source: string): RegExp { + return new RegExp(source, 'u'); +} + function readWorkspaceCommandTestSource(): string { return fs.readFileSync(new URL('./workspace.test.ts', import.meta.url), 'utf-8'); } +function matchesAny(patterns: RegExp[], source: string): boolean { + return patterns.some((pattern) => pattern.test(source)); +} + describe('workspace command path expectation guardrails', () => { it('keeps generated workspace folder expectations behind canonical path helpers', () => { const source = readWorkspaceCommandTestSource(); - expect(source).not.toMatch(/\.folders\)\.toEqual\(\s*\[/u); - expect(source).not.toMatch(/expect\(workspaceFolders\)\.toEqual\(\s*\[/u); + expect(matchesAny(forbiddenWorkspaceFolderAssertionPatterns, source)).toBe(false); }); it('keeps opener launch expectations behind canonical path helpers', () => { const source = readWorkspaceCommandTestSource(); - expect(source).not.toMatch(/expect\([^)]*Launch\.args\)\.toEqual\(/u); - expect(source).not.toMatch(/expect\(fs\.realpathSync\.native\([^)]*Launch\.cwd\)\)\.toBe/u); - expect(source).not.toMatch(/getWorkspaceCodeWorkspacePath\(expectedExistingPath\(/u); + expect(matchesAny(forbiddenLaunchAssertionPatterns, source)).toBe(false); + }); + + it('detects direct path assertion shapes that bypass canonical helpers', () => { + const directWorkspaceFolderAssertion = ` + expect( + JSON.parse(fs.readFileSync(pathToWorkspace, 'utf-8')).folders + ).toStrictEqual([ + { name: 'api', path: api }, + ]); + `; + const directWorkspaceFoldersVariableAssertion = ` + expect( workspaceFolders ).toMatchObject([ + { name: 'checkout', path: checkout }, + ]); + `; + const directLaunchArgsAssertion = ` + expect( + JSON.parse(fs.readFileSync(editorLog, 'utf-8')).editorLaunch.args + ).toContain(expectedWorkspaceFile); + `; + const directLaunchCwdAssertion = ` + expect( + fs.realpathSync.native(codexLaunch.cwd) + ).toBe(expectedWorkspaceRoot); + `; + const helperBackedWorkspaceFolderAssertion = ` + expect(JSON.parse(fs.readFileSync(pathToWorkspace, 'utf-8')).folders).toEqual( + expectedWorkspaceFolders([ + { name: 'api', path: api }, + ]) + ); + `; + + expect( + matchesAny(forbiddenWorkspaceFolderAssertionPatterns, directWorkspaceFolderAssertion) + ).toBe(true); + expect( + matchesAny(forbiddenWorkspaceFolderAssertionPatterns, directWorkspaceFoldersVariableAssertion) + ).toBe(true); + expect(matchesAny(forbiddenLaunchAssertionPatterns, directLaunchArgsAssertion)).toBe(true); + expect(matchesAny(forbiddenLaunchAssertionPatterns, directLaunchCwdAssertion)).toBe(true); + expect( + matchesAny(forbiddenWorkspaceFolderAssertionPatterns, helperBackedWorkspaceFolderAssertion) + ).toBe(false); }); });