diff --git a/config/scripts/run-internal-dev-setup.mjs b/config/scripts/run-internal-dev-setup.mjs index e585e4a775d..5076017339e 100644 --- a/config/scripts/run-internal-dev-setup.mjs +++ b/config/scripts/run-internal-dev-setup.mjs @@ -20,11 +20,27 @@ function quoteWindowsArg(value) { return `"${value.replace(/"/g, '""')}"` } +// Why: under a Git Bash setup runner, Orca exports ORCA_WORKTREE_PATH in MSYS form (/c/...), which +// cmd.exe cannot resolve. This is the migration pattern for any setup script feeding a native exe. +function posixShellPathToNativeWindowsPath(value) { + const driveMatch = value.match(/^\/([A-Za-z])\/(.*)$/) + if (driveMatch) { + return `${driveMatch[1].toUpperCase()}:\\${driveMatch[2].replace(/\//g, '\\')}` + } + return value +} + function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) { if (platform === 'win32') { + const nativeWorktreePath = posixShellPathToNativeWindowsPath(worktreePath) spawn( env.ComSpec || 'cmd.exe', - ['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`], + [ + '/d', + '/s', + '/c', + `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(nativeWorktreePath)}` + ], { stdio: 'inherit', windowsVerbatimArguments: true diff --git a/src/main/git-bash.test.ts b/src/main/git-bash.test.ts index 5cfdc33b533..b582a7414db 100644 --- a/src/main/git-bash.test.ts +++ b/src/main/git-bash.test.ts @@ -53,10 +53,86 @@ describe('Git Bash path discovery', () => { ).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') }) + // Why: resolveWindowsGitBashShellPath has no platform guard of its own, so passing options + // explicitly keeps these assertions meaningful on non-Windows CI hosts. it('honors an explicit bash.exe path for future user-configurable launch paths', () => { - expect(resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe')).toBe( - 'D:\\PortableGit\\bin\\bash.exe' - ) + expect( + resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe' + }) + ).toBe('D:\\PortableGit\\bin\\bash.exe') + }) + + it('rejects an explicit Git Bash path that is no longer installed', () => { + expect( + resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: () => false + }) + ).toBeNull() + }) + + it('resolves a bare bash entry through Git Bash discovery', () => { + expect( + resolveWindowsGitBashShellPath('bash', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('returns null for a bare bash entry when Git Bash is not installed', () => { + expect( + resolveWindowsGitBashShellPath('bash', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + exists: () => false + }) + ).toBeNull() + }) + + it('resolves an extension-less Git Bash path to the bash.exe it names', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { + platform: 'win32', + env: {}, + exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { + platform: 'win32', + env: {}, + exists: () => false + }) + ).toBeNull() + }) + + it('does not treat an extension-less non-Git bash path as Git Bash', () => { + expect( + resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() + }) + + it('does not probe a bash-prefixed path that is not bash itself', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() }) it('recognizes Git Bash executable paths case-insensitively', () => { @@ -64,7 +140,13 @@ describe('Git Bash path discovery', () => { }) it('does not classify arbitrary bash.exe paths as Git Bash', () => { - expect(resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe')).toBeNull() + expect( + resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false) }) diff --git a/src/main/git-bash.ts b/src/main/git-bash.ts index 1756f132bf9..e5841e47729 100644 --- a/src/main/git-bash.ts +++ b/src/main/git-bash.ts @@ -126,13 +126,24 @@ export function resolveWindowsGitBashShellPath( return resolveGitBashPath(options) } + // Why: resolveWindowsShellStartupFamily classifies extension-less `bash` as POSIX too, so both + // spellings must resolve here or setup/PTY shell selection disagrees with the quoting family. const shellBasename = pathWin32.basename(trimmed).toLowerCase() - if (shellBasename !== 'bash.exe') { + if (shellBasename !== 'bash.exe' && shellBasename !== 'bash') { return null } if (pathWin32.isAbsolute(trimmed) || trimmed.includes('\\') || trimmed.includes('/')) { - return isGitForWindowsBashPath(trimmed) ? trimmed : null + // Why: an uninstalled/stale configured path must resolve to null like the discovery + // branch above, so setup does not commit to a bash the PTY will never spawn. + const exists = options.exists ?? existsSync + if (shellBasename === 'bash') { + // Why: Git for Windows ships only bash.exe, so an extension-less path is a request for it. + // This branch synthesizes a path the user never typed, so it must confirm the file is there. + const candidate = `${trimmed}.exe` + return isGitForWindowsBashPath(candidate) && exists(candidate) ? candidate : null + } + return isGitForWindowsBashPath(trimmed) && exists(trimmed) ? trimmed : null } return resolveGitBashPath(options) diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index cd226bf9279..79827207aba 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -57,13 +57,15 @@ describe('createSetupRunnerScript', () => { ORCA_ROOT_PATH: '/test/repo', ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\', ORCA_WORKSPACE_NAME: 'feature' - }) + }), + // Why: native Windows worktrees without a configured setup shell keep the cmd runner. + shell: { family: 'cmd' } }) expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd', [ '@echo off', - 'setlocal EnableExtensions', + 'setlocal EnableExtensions DisableDelayedExpansion', 'call pnpm install', 'if errorlevel 1 exit /b %errorlevel%', 'call pnpm build', @@ -80,6 +82,110 @@ describe('createSetupRunnerScript', () => { } }) + it('converts path env vars to MSYS form for a native Windows Git Bash runner', async () => { + const fs = await import('node:fs') + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, + 'C:\\repo\\feature', + 'pnpm install', + undefined, + { family: 'posix' } + ) + + expect(result).toEqual({ + runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/c/Users/jinwo/git/orca', + ORCA_WORKTREE_PATH: '/c/repo/feature', + CONDUCTOR_ROOT_PATH: '/c/Users/jinwo/git/orca', + GHOSTX_ROOT_PATH: '/c/Users/jinwo/git/orca', + // Why: a display name, never a path — it must survive the conversion untouched. + ORCA_WORKSPACE_NAME: 'feature' + }), + shell: { family: 'posix' } + }) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + // Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit. + expect(vi.mocked(fs.chmodSync)).not.toHaveBeenCalledWith( + 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + 0o755 + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('leaves non-path setup env values alone under a Git Bash runner', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } = + await import('../shared/terminal-git-credential-guard') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo\\feature', + 'pnpm install', + undefined, + { family: 'posix' } + ) + + expect(result.envVars[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps native Windows env vars in Windows form for the default cmd runner', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, + 'C:\\repo\\feature', + 'pnpm install' + ) + + expect(result.envVars).toEqual( + expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\Users\\jinwo\\git\\orca', + ORCA_WORKTREE_PATH: 'C:\\repo\\feature' + }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves exclamation marks in Windows runner script lines', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + const runner = buildWindowsRunnerScript('echo hello!world!') + + // Why: launchers invoke the runner under cmd /v:on, so the runner must disable delayed + // expansion itself or `!world!` is consumed as a variable reference. + expect(runner).toContain('setlocal EnableExtensions DisableDelayedExpansion') + expect(runner).toContain('call echo hello!world!') + }) + it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => { const originalPlatform = process.platform @@ -256,4 +362,55 @@ describe('createIssueCommandRunnerScript', () => { }) } }) + + it('carries the WSL launch shell for a Windows-drive worktree routed through WSL', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('/mnt/c/repo/.git/orca/issue-command-runner.sh') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo\\feature', + 'codex exec "long command"', + { wslDistro: 'Ubuntu' } + ) + + // Why: the runner path is written back in native Windows form, so the launch needs /mnt again. + expect(result.runnerScriptPath).toBe('C:\\repo\\.git\\orca\\issue-command-runner.sh') + expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('keeps native Windows issue runners on the cmd launch shell', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript(makeRepo(), 'C:\\repo\\feature', 'pnpm install') + + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 6083638575d..b51a38e3321 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -537,7 +537,9 @@ describe('runner script builders', () => { try { const result = buildWindowsRunnerScript(script) - expect(result.startsWith('@echo off\r\nsetlocal EnableExtensions\r\n')).toBe(true) + expect( + result.startsWith('@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n') + ).toBe(true) expect(result).toContain('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%') expect(result).toContain('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%') const usedLineSplit = splitSpy.mock.calls.some( @@ -1175,6 +1177,7 @@ describe('runHook', () => { } ) expect(result.runnerScriptPath).toContain('setup-runner.sh') + expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' }) expect(mkdirSyncMock).toHaveBeenCalled() expect(writeFileSyncMock).toHaveBeenCalledWith( expect.stringContaining('setup-runner.sh'), @@ -1252,6 +1255,116 @@ describe('createSetupRunnerScript', () => { } }) as unknown as Repo + it('writes POSIX setup runners for Git Bash on native Windows paths', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + const chmodSyncMock = vi.mocked(fs.chmodSync) + writeFileSyncMock.mockClear() + chmodSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'pnpm install\r\nnpm run build', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\nnpm run build\n', + 'utf-8' + ) + expect(chmodSyncMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' } + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves cmd.exe setup runner semantics for configured cmd users', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'pnpm install\nnpm run build', + undefined, + { family: 'cmd' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%'), + 'utf-8' + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%'), + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps POSIX runner behavior on POSIX platforms', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + const chmodSyncMock = vi.mocked(fs.chmodSync) + writeFileSyncMock.mockClear() + chmodSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript(makeRepo(), '/test/worktree', 'pnpm install') + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { cwd: '/test/worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + '/test/repo/.git/orca/setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + expect(chmodSyncMock).toHaveBeenCalledWith('/test/repo/.git/orca/setup-runner.sh', 0o755) + expect(result.shell).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') @@ -1285,6 +1398,199 @@ describe('createSetupRunnerScript', () => { }) }) +describe('createIssueCommandRunnerScript', () => { + const makeRepo = () => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } + }) as unknown as Repo + + it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'gh issue view 42', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'posix' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the cmd issue-command runner when no setup shell is resolved', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'gh issue view 42' + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) +}) + +describe('resolveSetupRunnerShell', () => { + const installedGitBash = { + resolveGitBashShellPath: () => 'C:\\Program Files\\Git\\bin\\bash.exe' + } + + it('maps git-bash to POSIX setup launch metadata on Windows', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', installedGitBash) + ).toEqual({ + family: 'posix' + }) + }) + + it('falls back to the cmd runner when the git-bash setting has no installed Git Bash', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('keeps the cmd runner for a non-Git bash such as Cygwin', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'C:\\cygwin64\\bin\\bash.exe' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('keeps the cmd runner for a bare bash whose flavor cannot be resolved', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('uses the POSIX runner for a bare bash that resolves to Git Bash', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', installedGitBash) + ).toEqual({ family: 'posix' }) + }) + + it('uses the POSIX runner for an extension-less Git Bash path', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash' }, + 'win32', + installedGitBash + ) + ).toEqual({ family: 'posix' }) + }) + + it('preserves the existing cmd runner for PowerShell terminals', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell( + { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }, + 'win32', + installedGitBash + ) + ).toEqual({ family: 'cmd' }) + }) + + it('preserves cmd setup compatibility when a Windows-host project has a WSL shell setting', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'wsl.exe' }, 'win32', installedGitBash) + ).toEqual({ family: 'cmd' }) + }) + + it('classifies an installed explicit Git Bash executable as a POSIX runner', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + const { resolveWindowsGitBashShellPath } = await import('./git-bash') + + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' }, + 'win32', + { + resolveGitBashShellPath: (shell) => + resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => true }) + } + ) + ).toEqual({ family: 'posix' }) + }) + + it('falls back to the cmd runner when the explicit Git Bash path no longer exists', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + const { resolveWindowsGitBashShellPath } = await import('./git-bash') + + // Regression: a stale configured path used to commit setup to a .sh runner the + // PTY could never spawn, hanging wait-for-setup until the 2h timeout. + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' }, + 'win32', + { + resolveGitBashShellPath: (shell) => + resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => false }) + } + ) + ).toEqual({ family: 'cmd' }) + }) +}) + describe('shouldRunSetupForCreate', () => { const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 3f8a210ccf9..ad73003778f 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -8,6 +8,9 @@ import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-po import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' import { parseOrcaYaml } from '../shared/orca-yaml' +import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync, promptGuardShellEnv } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env' @@ -21,6 +24,7 @@ import type { WorktreeSetupLaunch } from '../shared/types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import type { SetupRunnerShell } from '../shared/setup-runner-command' const HOOK_TIMEOUT = 120_000 // 2 minutes @@ -28,6 +32,8 @@ export type HookRuntimeTarget = { wslDistro?: string | null } +type SetupRunnerShellSettings = Record | undefined + function getHookShell(): string | undefined { if (process.platform === 'win32') { return process.env.ComSpec || 'cmd.exe' @@ -322,6 +328,15 @@ export function getSetupCommandSource( return null } +// Why: kept in sync with pty/wsl-orca-env.ts, which path-translates the same keys for WSL. +// ORCA_WORKSPACE_NAME is a display name and the credential-guard policy is an enum — never paths. +const SETUP_RUNNER_PATH_ENV_KEYS = [ + 'ORCA_ROOT_PATH', + 'ORCA_WORKTREE_PATH', + 'CONDUCTOR_ROOT_PATH', + 'GHOSTX_ROOT_PATH' +] as const + function getSetupEnvVars(repo: Repo, worktreePath: string): Record { return { ORCA_ROOT_PATH: repo.path, @@ -383,7 +398,9 @@ function getHookWslContext( } export function buildWindowsRunnerScript(script: string): string { - let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n' + // Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an + // inherited delayed-expansion state — without this, every `!` in a user setup line is eaten. + let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() @@ -420,16 +437,20 @@ export function createSetupRunnerScript( repo: Repo, worktreePath: string, script: string, - projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, + setupShell?: SetupRunnerShell ): WorktreeSetupLaunch { - return createWorktreeRunnerScript( + return createWorktreeRunnerScript({ repo, worktreePath, script, - 'setup-runner', - getHookRuntimeTarget(projectRuntime), - shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) - ) + runnerBaseName: 'setup-runner', + runtimeTarget: getHookRuntimeTarget(projectRuntime), + waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup( + repo.hookSettings?.setupAgentStartupPolicy + ), + setupShell + }) } export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record { @@ -469,32 +490,55 @@ export function createIssueCommandRunnerScript( repo: Repo, worktreePath: string, command: string, - projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, + setupShell?: SetupRunnerShell ): WorktreeSetupLaunch { // Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them. - return createWorktreeRunnerScript( + return createWorktreeRunnerScript({ repo, worktreePath, - command, - 'issue-command-runner', - getHookRuntimeTarget(projectRuntime) - ) + script: command, + runnerBaseName: 'issue-command-runner', + runtimeTarget: getHookRuntimeTarget(projectRuntime), + // Why: issue commands run in the same terminal as setup, so a Git Bash setup + // runner must not be paired with a cmd issue runner in one session. + setupShell + }) } -function createWorktreeRunnerScript( - repo: Repo, - worktreePath: string, - script: string, - runnerBaseName: 'setup-runner' | 'issue-command-runner', - runtimeTarget?: HookRuntimeTarget, +function createWorktreeRunnerScript(args: { + repo: Repo + worktreePath: string + script: string + runnerBaseName: 'setup-runner' | 'issue-command-runner' + runtimeTarget?: HookRuntimeTarget waitForAgentStartup?: boolean -): WorktreeSetupLaunch { + setupShell?: SetupRunnerShell +}): WorktreeSetupLaunch { + const { + repo, + worktreePath, + script, + runnerBaseName, + runtimeTarget, + waitForAgentStartup, + setupShell + } = args const envVars = getSetupRunnerEnvVars(repo, worktreePath) // Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) - const useWindowsFormat = process.platform === 'win32' && !wslWorktree + const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree + const runnerShell: SetupRunnerShell = nativeWindowsWorktree + ? (setupShell ?? { family: 'cmd' }) + : { family: 'posix' } + const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree + ? runnerShell + : process.platform === 'win32' && runtimeTarget?.wslDistro + ? { family: 'posix', executable: 'wsl.exe' } + : undefined // Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path. - const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh` + const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh' + const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}` let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget) // Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it. @@ -507,12 +551,14 @@ function createWorktreeRunnerScript( mkdirSync(dirname(runnerScriptPath), { recursive: true }) - if (useWindowsFormat) { + if (runnerShell.family === 'cmd') { writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8') } else { writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8') - // Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL. - chmodSync(runnerScriptPath, 0o755) + if (!nativeWindowsWorktree) { + // Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL. + chmodSync(runnerScriptPath, 0o755) + } } // Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths. @@ -520,15 +566,68 @@ function createWorktreeRunnerScript( for (const key of Object.keys(envVars)) { envVars[key] = toLinuxPath(envVars[key]) } + } else if (nativeWindowsWorktree && runnerShell.family === 'posix') { + // Why: a Git Bash runner already receives its own path as /c/..., and the shell exports HOME + // and PWD the same way, so leaving ORCA_* in C:\ form would make them the lone exception. + // Only path-valued keys convert; the workspace name and policy values are not paths. + for (const key of SETUP_RUNNER_PATH_ENV_KEYS) { + const value = envVars[key] + if (value) { + envVars[key] = nativeWindowsPathToPosixShellPath(value) + } + } } return { runnerScriptPath, envVars, + // Why: WSL git returns /mnt paths that Node converts back to C:\ for file + // writes; retain the runtime signal so launch converts them to /mnt again. + // Issue-command runners take the same resolved shell, so one session never + // mixes a bash setup runner with a cmd issue runner. + ...(launchShell ? { shell: launchShell } : {}), ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) } } +export function resolveSetupRunnerShell( + settings: SetupRunnerShellSettings, + platform: NodeJS.Platform = process.platform, + options: { resolveGitBashShellPath?: (shell: string) => string | null } = {} +): SetupRunnerShell | undefined { + if (platform !== 'win32') { + return undefined + } + + const terminalWindowsShell = settings?.terminalWindowsShell + const configuredShell = + typeof terminalWindowsShell === 'string' && terminalWindowsShell.trim() + ? terminalWindowsShell.trim() + : 'powershell.exe' + const shellBasename = configuredShell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + const family = resolveWindowsShellStartupFamily(configuredShell) + if (family === 'posix' && shellBasename !== 'wsl.exe' && shellBasename !== 'wsl') { + // Why: the PTY resolves Git Bash independently and falls back to PowerShell when it + // is missing, so gate the .sh runner on the same resolution. This also keeps + // non-Git bash flavors (Cygwin's /cygdrive, the System32 WSL shim's /mnt) off the + // MSYS-only /c/... form the posix runner emits. + const resolveGitBashShellPath = + options.resolveGitBashShellPath ?? + ((shell: string) => resolveWindowsGitBashShellPath(shell, { platform })) + if (resolveGitBashShellPath(configuredShell)) { + // Note: Git Bash users with batch-syntax orca.yaml setup content get a bash + // interpreter from here on. The flip is intentional and documented in + // docs/reference/windows-setup-shell.md. + return { family: 'posix' } + } + } + + // Why: existing Windows setup scripts were authored for Orca's cmd runner; + // PowerShell, wsl.exe-as-terminal, and Windows-host projects can invoke it + // without changing syntax, so they intentionally stay on the cmd runner. + return { family: 'cmd' } +} + /** * Run a named hook script in the given working directory. */ diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index d37918595ff..b09b4e71c93 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -54,6 +54,7 @@ import { getSetupRunnerEnvVars, loadHooks, parseOrcaYaml, + resolveSetupRunnerShell, shouldRunSetupForCreate } from '../hooks' import { requireSshGitProvider } from '../providers/ssh-git-dispatch' @@ -178,6 +179,13 @@ function appendWorktreeCreateWarning(current: string | undefined, next: string): return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next } +function getSetupRunnerCommandPlatformForLaunch( + setup: CreateWorktreeResult['setup'], + fallbackPlatform: 'windows' | 'posix' +): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) +} + function validateWorkspaceLineageParentBeforeCreate( store: Store, parentWorkspace: CreateWorktreeArgs['parentWorkspace'], @@ -266,14 +274,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { let sequencedStartup = startup let wrappedSetupCommandStr: string | undefined if (startup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + const platform = getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: startup.command, - platform + platform, + shell: setup.shell }) sequencedStartup = { ...startup, @@ -329,10 +338,11 @@ async function spawnLocalStartupAndSetupTerminals(args: { wrappedSetupCommandStr ?? buildSetupRunnerCommand( setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' - ) + ), + setup.shell ) const setupLaunchMode = (settings as Partial>) @@ -1144,6 +1154,8 @@ async function createRemoteSetupRunnerScript( fsProvider: IFilesystemProvider ): Promise { const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath) + // Why: SSH terminals choose their shell on the remote host; local Windows + // preferences cannot safely select a remote runner format or launch command. const runnerRelativePath = useWindowsFormat ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh' const { stdout } = await gitProvider.exec( ['rev-parse', '--git-path', runnerRelativePath], @@ -2504,11 +2516,13 @@ export async function createLocalWorktree( try { // Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior. // Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation. + // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, setupScript, - ...localWorktreeGitOptionArgs + localWorktreeGitOptionArgs[0], + resolveSetupRunnerShell(settings) ) } catch (error) { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 0298baa40bd..ee9540048cd 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -20,6 +20,7 @@ const { getDefaultTabsLaunchMock, createIssueCommandRunnerScriptMock, createSetupRunnerScriptMock, + resolveSetupRunnerShellMock, shouldRunSetupForCreateMock, runHookMock, hasHooksFileMock, @@ -49,6 +50,7 @@ const { getDefaultTabsLaunchMock: vi.fn(), createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), + resolveSetupRunnerShellMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), @@ -110,6 +112,7 @@ vi.mock('../hooks', () => ({ loadHooks: loadHooksMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, + resolveSetupRunnerShell: resolveSetupRunnerShellMock, shouldRunSetupForCreate: shouldRunSetupForCreateMock })) @@ -177,6 +180,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getDefaultTabsLaunchMock.mockReset() createIssueCommandRunnerScriptMock.mockReset() createSetupRunnerScriptMock.mockReset() + resolveSetupRunnerShellMock.mockReset() shouldRunSetupForCreateMock.mockReset() runHookMock.mockReset() hasHooksFileMock.mockReset() @@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { refreshLocalBaseRefOnWorktreeCreate: false, workspaceDir: 'C:\\workspaces' }) + resolveSetupRunnerShellMock.mockReturnValue(undefined) store.getWorktreeMeta.mockReturnValue(undefined) store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') @@ -345,6 +350,61 @@ describe('registerWorktreeHandlers – Windows path handling', () => { ) }) + it('passes the configured Windows setup shell into local setup runner generation', async () => { + const setupShell = { family: 'posix' as const } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspaces' + }) + resolveSetupRunnerShellMock.mockReturnValue(setupShell) + listWorktreesMock.mockResolvedValue([ + { + path: 'C:/workspaces/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + createSetupRunnerScriptMock.mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: setupShell, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\improve-dashboard' + } + }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith( + expect.objectContaining({ terminalWindowsShell: 'git-bash' }) + ) + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\improve-dashboard', + 'pnpm install', + undefined, + setupShell + ) + expect(result).toMatchObject({ + setup: { + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: setupShell + } + }) + }) + it('preserves create-time metadata on the next list when Windows path formatting differs', async () => { const worktreeEntry = { path: 'C:/workspaces/improve-dashboard', @@ -439,4 +499,29 @@ describe('registerWorktreeHandlers – Windows path handling', () => { repoId: 'repo-1' }) }) + it('gives the issue-command runner the same setup shell as the setup runner', () => { + // Regression (C4): native Windows issue runners stayed .cmd even when setup + // resolved to Git Bash, so same-session bash issue templates broke. + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + createIssueCommandRunnerScriptMock.mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + envVars: {}, + shell: { family: 'posix' } + }) + + handlers['hooks:createIssueCommandRunner'](null, { + repoId: 'repo-1', + worktreePath: 'C:\\workspaces\\improve-dashboard', + command: 'gh issue view 42' + }) + + expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith(store.getSettings()) + expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\improve-dashboard', + 'gh issue view 42', + expect.anything(), + { family: 'posix' } + ) + }) }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 7e50c874f34..811fae77b8d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -50,6 +50,7 @@ const { buildPosixRunnerScriptMock, buildWindowsRunnerScriptMock, getSetupRunnerEnvVarsMock, + resolveSetupRunnerShellMock, runHookMock, hasHooksFileMock, loadHooksMock, @@ -100,6 +101,7 @@ const { buildPosixRunnerScriptMock: vi.fn(), buildWindowsRunnerScriptMock: vi.fn(), getSetupRunnerEnvVarsMock: vi.fn(), + resolveSetupRunnerShellMock: vi.fn(), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), loadHooksMock: vi.fn(), @@ -199,6 +201,7 @@ vi.mock('../hooks', () => ({ getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock, loadHooks: loadHooksMock, parseOrcaYaml: parseOrcaYamlMock, + resolveSetupRunnerShell: resolveSetupRunnerShellMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, shouldRunSetupForCreate: shouldRunSetupForCreateMock @@ -369,6 +372,7 @@ describe('registerWorktreeHandlers', () => { buildPosixRunnerScriptMock, buildWindowsRunnerScriptMock, getSetupRunnerEnvVarsMock, + resolveSetupRunnerShellMock, shouldRunSetupForCreateMock, runHookMock, hasHooksFileMock, @@ -484,6 +488,7 @@ describe('registerWorktreeHandlers', () => { (script: string) => `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n` ) buildWindowsRunnerScriptMock.mockImplementation((script: string) => script) + resolveSetupRunnerShellMock.mockReturnValue(undefined) getSetupRunnerEnvVarsMock.mockImplementation( (repoArg: { path: string }, worktreePath: string) => ({ ORCA_ROOT_PATH: repoArg.path, @@ -1226,10 +1231,11 @@ describe('registerWorktreeHandlers', () => { getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) shouldRunSetupForCreateMock.mockReturnValue(true) createSetupRunnerScriptMock.mockReturnValueOnce({ - runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { - ORCA_ROOT_PATH: '/workspace/repo', - ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + ORCA_ROOT_PATH: 'C:\\workspace\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspace\\improve-dashboard' }, waitForAgentStartup: true }) @@ -1254,8 +1260,8 @@ describe('registerWorktreeHandlers', () => { expect(result.setup).toEqual( expect.objectContaining({ - runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', - command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh') + runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh', + command: expect.stringContaining('bash /mnt/c/workspace/repo/.git/orca/setup-runner.sh') }) ) expect(result.setup?.command).toContain('printf') @@ -4944,6 +4950,103 @@ describe('registerWorktreeHandlers', () => { ) }) + it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\remote\\repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:\\remote\\improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspace' + }) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + 'C:\\remote\\improve-dashboard' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + 'pnpm install' + ) + expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\remote\\repo', + ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' + }) + } + }) + ) + }) + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { const repo = { id: 'repo-ssh', @@ -6642,7 +6745,9 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', 'codex exec "long command"', - {} + {}, + // Why: issue runners take the resolved setup shell; it is undefined off Windows. + undefined ) expect(result).toMatchObject({ runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh', @@ -7621,7 +7726,9 @@ describe('registerWorktreeHandlers', () => { expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', - 'pnpm worktree:setup' + 'pnpm worktree:setup', + undefined, + undefined ) expect(result).toMatchObject({ worktree: expect.objectContaining({ @@ -7682,7 +7789,8 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', 'pnpm worktree:setup', - { wslDistro: 'Ubuntu' } + { wslDistro: 'Ubuntu' }, + undefined ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -7719,7 +7827,9 @@ describe('registerWorktreeHandlers', () => { expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', - 'pnpm worktree:setup # worktree' + 'pnpm worktree:setup # worktree', + undefined, + undefined ) expect(result).toEqual( expect.objectContaining({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index fe2d6d5fff3..103464dc55e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -91,6 +91,7 @@ import { loadHooks, parseOrcaYaml, readIssueCommand, + resolveSetupRunnerShell, runHook, hasHooksFile, hasUnrecognizedOrcaYamlKeys, @@ -3118,7 +3119,8 @@ export function registerWorktreeHandlers( repo, args.worktreePath, args.command, - getLocalProjectWorktreeGitOptions(store, repo) + getLocalProjectWorktreeGitOptions(store, repo), + resolveSetupRunnerShell(store.getSettings()) ) } ) diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts index eedeb42ef9f..af907f4a4ab 100644 --- a/src/main/plugins/plugin-content-safety.test.ts +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { disposeWatcherProcess } from '../ipc/parcel-watcher-process' import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' import { @@ -43,6 +44,9 @@ function manifest(overrides: ManifestOverrides = {}): PluginManifest { afterEach(async () => { vi.restoreAllMocks() + // Why: PluginService may still be releasing in-process Parcel watches; drop + // the shared vitest watcher state before deleting roots those watches own. + disposeWatcherProcess() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) diff --git a/src/main/plugins/plugin-dev-watcher.test.ts b/src/main/plugins/plugin-dev-watcher.test.ts index 77996fe3bd8..33bd0050625 100644 --- a/src/main/plugins/plugin-dev-watcher.test.ts +++ b/src/main/plugins/plugin-dev-watcher.test.ts @@ -47,6 +47,22 @@ describe('PluginDevWatcher', () => { await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) }) + it('contains unsubscribe rejections so dispose cannot fail the host process', async () => { + const unsubscribe = vi + .fn() + .mockRejectedValue(new Error('Unable to remove watcher: Invalid argument')) + const subscribePath = vi.fn(async () => ({ unsubscribe })) + const devWatcher = new PluginDevWatcher(subscribePath) + devWatcher.start(['/plugins/demo'], vi.fn()) + await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce()) + + expect(() => devWatcher.dispose()).not.toThrow() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + // Why: give the rejected promise a turn so an uncaught rejection would surface. + await Promise.resolve() + await Promise.resolve() + }) + it('does not spin refreshes when a missing path cannot be subscribed', async () => { vi.useFakeTimers() const subscribePath = vi.fn().mockRejectedValue(new Error('missing path')) diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts index ea77def417f..c6aefa12699 100644 --- a/src/main/plugins/plugin-dev-watcher.ts +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -20,6 +20,13 @@ const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption) } ) +// Why: Parcel unsubscribe rejects when the watch root is already gone (common +// in tests that rm temp dirs). Fire-and-forget callers must not leave that as +// an unhandled rejection that fails the Vitest process. +function releaseSubscription(subscription: WatcherProcessSubscription): void { + void subscription.unsubscribe().catch(() => undefined) +} + /** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */ export class PluginDevWatcher { private readonly subscriptions: WatcherProcessSubscription[] = [] @@ -40,7 +47,7 @@ export class PluginDevWatcher { failedBeforeReady = true if (subscription) { this.removeSubscription(subscription) - void subscription.unsubscribe() + releaseSubscription(subscription) } onWatcherError?.() this.scheduleRefresh(refresh) @@ -65,7 +72,7 @@ export class PluginDevWatcher { .then((created) => { subscription = created if (generation !== this.generation || failedBeforeReady) { - void created.unsubscribe() + releaseSubscription(created) return } this.subscriptions.push(created) @@ -85,7 +92,7 @@ export class PluginDevWatcher { this.refreshTimer = null } for (const subscription of this.subscriptions.splice(0)) { - void subscription.unsubscribe() + releaseSubscription(subscription) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c91d324c109..390285e0a06 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -51,6 +51,7 @@ import { hasHooksFile, loadHooks, parseOrcaYaml, + resolveSetupRunnerShell, runHook, shouldRunSetupForCreate } from '../hooks' @@ -466,6 +467,7 @@ vi.mock('../hooks', () => ({ ORCA_WORKTREE_PATH: worktreePath }), loadHooks: vi.fn().mockReturnValue(null), + resolveSetupRunnerShell: vi.fn().mockReturnValue(undefined), runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), shouldRunSetupForCreate: vi .fn() @@ -683,6 +685,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(getEffectiveHooksFromConfig).mockReset() vi.mocked(getDefaultTabsLaunch).mockReset() vi.mocked(loadHooks).mockReset() + vi.mocked(resolveSetupRunnerShell).mockReset() vi.mocked(hasHooksFile).mockReset() vi.mocked(parseOrcaYaml).mockReset() vi.mocked(runHook).mockReset() @@ -692,6 +695,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null) vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined) vi.mocked(loadHooks).mockReturnValue(null) + vi.mocked(resolveSetupRunnerShell).mockReturnValue(undefined) vi.mocked(hasHooksFile).mockReturnValue(false) vi.mocked(parseOrcaYaml).mockReturnValue(null) computeWorktreePathMock.mockReset() @@ -37741,6 +37745,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-test', 'pnpm worktree:setup', + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -37832,6 +37837,85 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes the selected Windows setup shell into runtime runner generation', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + terminalWindowsShell: 'git-bash' + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-activate' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: 'C:/workspaces/runtime-hook-activate', + head: 'def', + branch: 'runtime-hook-activate', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-activate', + runHooks: true, + activate: true + }) + + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), + 'C:\\workspaces\\runtime-hook-activate', + 'pnpm worktree:setup', + undefined, + { family: 'posix' } + ) + expect(result.setup).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' } + }) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) + }) + it('follows normal setup policy for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -37896,6 +37980,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-skip', 'pnpm worktree:setup', + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -37949,6 +38034,224 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses returned WSL setup shell metadata when runtime spawns setup', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:/workspaces/runtime-hook-wsl', + head: 'def', + branch: 'runtime-hook-wsl', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-wsl' + }) + + expect(result.setup).toBeUndefined() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh', + env: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl', + ORCA_TAB_ID: expect.stringMatching(UUID_RE), + ORCA_PANE_KEY: expect.any(String), + ORCA_WORKTREE_ID: result.worktree.id + }), + worktreeId: result.worktree.id + }) + ) + }) + + it('uses the shell-aware setup runner for windowless creates without a startup command', async () => { + // Regression (C1): with no authoritative window and no startup command the + // create fell back to runHook, which hardcodes cmd.exe on Windows and so ran + // batch even when the configured terminal resolves to Git Bash. + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-windowless' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // Deliberately no attachWindow: this is the windowless/CLI create path. + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-windowless' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:/workspaces/runtime-hook-windowless', + head: 'def', + branch: 'runtime-hook-windowless', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-windowless', + awaitTerminalProvisioning: true + }) + + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\runtime-hook-windowless', + 'pnpm worktree:setup', + undefined, + { family: 'posix' } + ) + expect(runHook).not.toHaveBeenCalled() + expect(result.setupReceipt).toMatchObject({ state: 'running' }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /c/repo/.git/orca/setup-runner.sh', + worktreeId: result.worktree.id + }) + ) + }) + + it('reports the in-process setup hook as running when nothing can launch the runner', async () => { + // Regression (C1): the fire-and-forget hook is the last resort when there is + // no PTY controller; reporting spawn_failed made callers retry a live hook. + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-hook-no-pty', + head: 'def', + branch: 'runtime-hook-no-pty', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-no-pty', + awaitTerminalProvisioning: true + }) + + expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(runHook).toHaveBeenCalledWith( + 'setup', + '/tmp/workspaces/runtime-hook-no-pty', + expect.objectContaining({ id: 'repo-1' }), + '/tmp/workspaces/runtime-hook-no-pty', + undefined + ) + expect(result.setupReceipt).toMatchObject({ state: 'running' }) + }) + it('sequences setup before startup for opted-in local headless worktree creates', async () => { const waitRepo = { ...store.getRepo('repo-1')!, @@ -38001,7 +38304,8 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) vi.mocked(createSetupRunnerScript).mockReturnValue({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup' @@ -38038,6 +38342,8 @@ describe('OrcaRuntimeService', () => { const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) expect(nonceMatch?.[1]).toBeTruthy() expect(startupCommand).toContain('exec claude') + expect(startupCommand).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') expect(setupCommand).toContain('printf') expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) expect(result.setup).toBeUndefined() @@ -38120,6 +38426,75 @@ describe('OrcaRuntimeService', () => { }) }) + it('observes setup completion through the launch shell the runner was written for', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-observed-wsl-shell' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-observed-wsl-startup' }) + .mockResolvedValueOnce({ id: 'pty-observed-wsl-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-observed-wsl-shell' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-observed-wsl-shell', + head: 'def', + branch: 'runtime-observed-wsl-shell', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-observed-wsl-shell', + setupDecision: 'run', + startup: { command: 'claude' }, + observeSetupCompletion: true, + awaitTerminalProvisioning: true + }) + + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(setupCommand).toContain('__ORCA_SETUP_COMPLETE__:') + }) + it('creates the first terminal for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -39334,7 +39709,8 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) vi.mocked(createSetupRunnerScript).mockReturnValue({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' @@ -39364,8 +39740,8 @@ describe('OrcaRuntimeService', () => { 'repo-1', expect.any(String), expect.objectContaining({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + command: expect.stringContaining('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') }), undefined, undefined diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c75a00d579..6e255216ecc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -840,6 +840,7 @@ import { loadHooks, parseOrcaYaml, readIssueCommand, + resolveSetupRunnerShell, runHook, shouldRunSetupForCreate, writeIssueCommand @@ -2637,6 +2638,13 @@ async function hasLocalWorktreeBaseRef( ) } +function getSetupRunnerCommandPlatformForLaunch( + setup: CreateWorktreeResult['setup'], + fallbackPlatform: 'windows' | 'posix' +): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -20566,13 +20574,18 @@ export class OrcaRuntimeService { ? buildObservedSetupCommand( args.setup.runnerScriptPath, args.setupCommandPlatform, - completionToken + completionToken, + args.setup.shell ) : null const setupCommand = args.wrappedSetupCommand ?? observedCommand?.command ?? - buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) + buildSetupRunnerCommand( + args.setup.runnerScriptPath, + args.setupCommandPlatform, + args.setup.shell + ) const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } const shouldSplitSetup = primaryTerminalHandle && @@ -21507,17 +21520,27 @@ export class OrcaRuntimeService { : undefined } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + // Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run + // when nothing downstream is able to launch the shell-aware runner script. + let didStartInProcessSetupHook = false if (shouldRunSetup && hooks?.scripts.setup) { - const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) + const shouldUseSetupRunner = + this.authoritativeWindowId !== null || + Boolean(effectiveStartup) || + Boolean(this.ptyController?.spawn) if (shouldUseSetupRunner) { try { // Why: setup+startup must share the terminal runner path even without - // a renderer window, so the startup shell can wait on setup completion. + // a renderer window, so the startup shell can wait on setup completion + // and windowless creates resolve the same Windows setup shell. + const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] + // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, hooks.scripts.setup, - this.getLocalGitExecutionOptionArgs(repo)[0] + runtimeTarget, + resolveSetupRunnerShell(settings) ) } catch (error) { // Why: the git worktree is already real at this point. If runner @@ -21526,6 +21549,7 @@ export class OrcaRuntimeService { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } } else { + didStartInProcessSetupHook = true void runHook( 'setup', worktreePath, @@ -21571,14 +21595,15 @@ export class OrcaRuntimeService { let sequencedStartup = effectiveStartup let wrappedSetupCommandStr: string | undefined if (effectiveStartup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + const platform = getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: effectiveStartup.command, - platform + platform, + shell: setup.shell }) sequencedStartup = { ...effectiveStartup, @@ -21650,11 +21675,7 @@ export class OrcaRuntimeService { ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: setup - ? isWindowsAbsolutePathLike(setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // Setup tab runs the same script the sequenced agent waits on. @@ -21705,11 +21726,7 @@ export class OrcaRuntimeService { ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: setup - ? isWindowsAbsolutePathLike(setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false @@ -21776,7 +21793,9 @@ export class OrcaRuntimeService { ? ('not_configured' as const) : effectiveDecision === 'skip' || !shouldRunSetup ? ('skipped' as const) - : didSpawnSetup + : // Why: the in-process hook is already executing, so reporting + // spawn_failed would strand callers that retry on it. + didSpawnSetup || didStartInProcessSetupHook ? ('running' as const) : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) @@ -21927,11 +21946,12 @@ export class OrcaRuntimeService { let sequencedStartup = args.startup let wrappedSetupCommandStr: string | undefined if (args.startup && result.setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: result.setup.runnerScriptPath, startupCommand: args.startup.command, - platform + platform, + shell: result.setup.shell }) sequencedStartup = { ...args.startup, @@ -21998,11 +22018,7 @@ export class OrcaRuntimeService { ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: result.setup - ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // remote Setup tab runs the same script the sequenced agent waits on. @@ -22058,11 +22074,7 @@ export class OrcaRuntimeService { ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: result.setup - ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts index 0f2b3976f1e..baa5af51cf3 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -41,6 +41,29 @@ describe('orchestration setup completion signal', () => { expect(command).toContain('exit "$status"') }) + it('routes a WSL-launched Windows-drive runner through its /mnt mount', () => { + const { command } = buildObservedSetupCommand( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + 'windows', + 'token-mnt', + { family: 'posix', executable: 'wsl.exe' } + ) + + expect(command).toContain('bash /mnt/c/repo/.git/orca/setup-runner.sh') + expect(command).not.toContain('bash /c/repo') + }) + + it('keeps a Git Bash runner on the MSYS drive form', () => { + const { command } = buildObservedSetupCommand( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + 'windows', + 'token-git-bash', + { family: 'posix' } + ) + + expect(command).toContain('bash /c/repo/.git/orca/setup-runner.sh') + }) + it('recognizes one completion signal across output chunk boundaries', () => { const onComplete = vi.fn() const scanner = createSetupCompletionScanner('token-chunks', onComplete) diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 17819c12a0c..23e22008387 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -1,6 +1,7 @@ import { resolveSetupRunnerCommand, - type SetupRunnerCommandPlatform + type SetupRunnerCommandPlatform, + type SetupRunnerShell } from '../../../shared/setup-runner-command' const SETUP_COMPLETION_PREFIX = '__ORCA_SETUP_COMPLETE__:' @@ -10,9 +11,12 @@ const WINDOWS_SETUP_RUNNER_ENV = 'ORCA_SETUP_RUNNER_PATH' export function buildObservedSetupCommand( runnerScriptPath: string, platform: SetupRunnerCommandPlatform, - completionToken: string + completionToken: string, + // Why: the observed command must reuse the shell the runner was written for, or a + // WSL-routed Windows-drive runner gets Git Bash `/c/...` instead of `/mnt/c/...`. + shell?: SetupRunnerShell ): { command: string; env?: Record } { - const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform) + const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform, shell) if (resolution.shell === 'windows') { const script = [ `$runner = $env:${WINDOWS_SETUP_RUNNER_ENV}`, diff --git a/src/preload/index.ts b/src/preload/index.ts index b358c689edf..cb38c18e9af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,7 +95,8 @@ import type { WorktreeBaseStatusEvent, WorktreeDefaultTabsLaunch, WorktreeHeadIdentity, - WorktreeRemoteBranchConflictEvent + WorktreeRemoteBranchConflictEvent, + WorktreeSetupLaunch } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { PtyListedSession } from '../shared/pty-listed-session' @@ -2868,8 +2869,7 @@ const api = { repoId: string worktreePath: string command: string - }): Promise<{ runnerScriptPath: string; envVars: Record }> => - ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), readIssueCommand: (args: { repoId: string @@ -3685,7 +3685,7 @@ const api = { callback: (data: { repoId: string worktreeId: string - setup?: { runnerScriptPath: string; envVars: Record } + setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch }) => void @@ -3695,7 +3695,7 @@ const api = { data: { repoId: string worktreeId: string - setup?: { runnerScriptPath: string; envVars: Record } + setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 1c1ff41328d..f9e68447b43 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -687,6 +687,60 @@ describe('PtyHandler', () => { } }) + // Why: both spellings classify as a POSIX startup family, so the relay must not be the one host + // that hard-fails a setting the local and daemon PTYs accept. + it.each(['bash', 'bash.exe'])( + 'accepts the %s shell override and routes it through Git Bash resolution', + async (shellOverride) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride }) + + expect(resolveGitBashSpy).toHaveBeenCalledWith(shellOverride) + expect(mockPtySpawn).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + + it('falls back to the literal bash override when Git Bash is not installed', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue(null) + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride: 'bash' }) + + expect(mockPtySpawn).toHaveBeenCalledWith('bash', expect.any(Array), expect.any(Object)) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 243cc758943..c928bd4a106 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -263,6 +263,10 @@ const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([ 'cmd', 'wsl.exe', 'wsl', + // Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay + // the one host that hard-failed a setting the local and daemon PTYs accept. + 'bash.exe', + 'bash', WINDOWS_GIT_BASH_SHELL ]) diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index a2c2218d2ad..ea463464751 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -250,6 +250,37 @@ describe('launchWorktreeBackgroundTerminals', () => { ) }) + it('uses configured WSL setup commands for Windows bash runner paths', async () => { + state.repos = [{ id: 'repo-1', connectionId: null }] + state.worktreesByRepo['repo-1'] = [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo\\worktree', + displayName: 'Worktree' + } + ] + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + await launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: { + runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' } + } + }) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /mnt/c/repo/.git/worktrees/wt/orca/setup-runner.sh', + connectionId: null + }) + ) + }) + it('still attempts setup when a default tab fails to spawn', async () => { const spawnError = new Error('pty unavailable') mockSpawn diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 4c4c1603d0b..5725ce397a8 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -9,9 +9,11 @@ import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { makePaneKey } from '../../../shared/stable-pane-id' -import { buildSetupRunnerCommand } from '../../../shared/setup-runner-command' +import { + buildSetupRunnerCommand, + getSetupRunnerCommandPlatformForPath +} from '../../../shared/setup-runner-command' import type { TerminalLayoutSnapshot, Worktree, @@ -120,9 +122,11 @@ function registerBackgroundPaneBuffer(tabId: string, leafId: string, ptyId: stri } function buildSetupCommand(setup: WorktreeSetupLaunch): string { + // Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written. return buildSetupRunnerCommand( setup.runnerScriptPath, - isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' + getSetupRunnerCommandPlatformForPath(setup.runnerScriptPath, 'posix'), + setup.shell ) } diff --git a/src/renderer/src/lib/setup-runner.ts b/src/renderer/src/lib/setup-runner.ts index 1779f567703..760175daa1b 100644 --- a/src/renderer/src/lib/setup-runner.ts +++ b/src/renderer/src/lib/setup-runner.ts @@ -2,8 +2,12 @@ import { buildSetupRunnerCommand as buildSharedSetupRunnerCommand, getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' +import type { SetupRunnerShell } from '../../../shared/setup-runner-command' -export function buildSetupRunnerCommand(runnerScriptPath: string): string { +export function buildSetupRunnerCommand( + runnerScriptPath: string, + shell?: SetupRunnerShell +): string { // Why: the runner may live on a remote/WSL filesystem, so the shell follows // the runner path format rather than the local renderer OS. return buildSharedSetupRunnerCommand( @@ -11,6 +15,7 @@ export function buildSetupRunnerCommand(runnerScriptPath: string): string { getSetupRunnerCommandPlatformForPath( runnerScriptPath, navigator.userAgent.includes('Windows') ? 'windows' : 'posix' - ) + ), + shell ) } diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index b6f111c4d29..e99c70fadef 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -88,6 +88,29 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) + it('queues setup through returned POSIX shell metadata on native Windows paths', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1' + } + }) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash /c/repo/.git/orca/setup-runner.sh', + env: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1' + } + }) + }) + it('creates a single tab without setup split when no setup is provided', () => { const store = createMockStore() @@ -449,6 +472,29 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) + it('queues WSL setup launch commands with WSL path conversion on native Windows paths', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1' + } + }) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { + command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh', + env: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1' + } + }) + }) + it('queues a startup command when agent launch is provided', () => { const store = createMockStore() @@ -734,6 +780,35 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('keeps WSL setup shell metadata when gating startup behind setup completion', () => { + setSetupScriptLaunchMode('split-vertical') + const store = createMockStore() + + ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + { command: 'claude' }, + { + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { ORCA_ROOT_PATH: 'C:\\repo' }, + waitForAgentStartup: true + } + ) + + expect(store.queueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: expect.stringContaining('/mnt/c/repo/.git/orca/setup-runner.sh') + }) + ) + expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + command: expect.stringContaining('bash /mnt/c/repo/.git/orca/setup-runner.sh'), + env: { ORCA_ROOT_PATH: 'C:\\repo' }, + direction: 'vertical' + }) + }) + it('forwards telemetry on the queued startup so main can fire agent_started', () => { const store = createMockStore() @@ -827,6 +902,21 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('queues an issue command split through returned WSL shell metadata', () => { + const store = createMockStore() + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, { + runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { ORCA_ROOT_PATH: 'C:\\repo' } + }) + + expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', { + command: 'bash /mnt/c/repo/.git/orca/issue-command-runner.sh', + env: { ORCA_ROOT_PATH: 'C:\\repo' } + }) + }) + it('queues both setup split and issue command split when both are provided', () => { setSetupScriptLaunchMode('split-vertical') const store = createMockStore() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 0be0503c9e9..c5b57fd1a9d 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -112,6 +112,13 @@ export type IssueCommandLaunch = | WorktreeSetupLaunch | { command: string; env?: Record } +function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath( + setup.runnerScriptPath, + navigator.userAgent.includes('Windows') ? 'windows' : 'posix' + ) +} + type WorktreeActivationStore = Partial & { tabsByWorktree: Record defaultTerminalTabsAppliedByWorktreeId: Record @@ -430,14 +437,12 @@ export function ensureWorktreeHasInitialTerminal( let wrappedSetupCommandStr: string | undefined if (startup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - navigator.userAgent.includes('Windows') ? 'windows' : 'posix' - ) + const platform = getSetupRunnerCommandPlatformForLaunch(setup) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: startup.command, - platform + platform, + shell: setup.shell }) sequencedStartup = { ...startup, @@ -670,7 +675,9 @@ function queueSetupAndIssueCommands( const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' const setupCommand = { command: - wrappedSetupCommandStr ?? setup.command ?? buildSetupRunnerCommand(setup.runnerScriptPath), + wrappedSetupCommandStr ?? + setup.command ?? + buildSetupRunnerCommand(setup.runnerScriptPath, setup.shell), env: setup.envVars } if (mode === 'new-tab') { @@ -699,7 +706,7 @@ function queueSetupAndIssueCommands( const queuedIssueCommand = 'runnerScriptPath' in issueCommand ? { - command: buildSetupRunnerCommand(issueCommand.runnerScriptPath), + command: buildSetupRunnerCommand(issueCommand.runnerScriptPath, issueCommand.shell), env: issueCommand.envVars } : { command: issueCommand.command, env: issueCommand.env } diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index 9225249a325..bffeb0de351 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -165,6 +165,24 @@ describe('createSequencedSetupAgentCommands', () => { expect(result.startupCommand).toContain('[ "$seen" = nonce-remote ]') }) + it('preserves WSL shell metadata when sequencing native Windows runners', () => { + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + startupCommand: 'claude', + platform: 'windows', + shell: { family: 'posix', executable: 'wsl.exe' }, + nonce: 'nonce-wsl-shell' + }) + + expect(result.setupCommand).toContain('bash /mnt/c/repo/.git/orca/setup-runner.sh') + expect(result.setupCommand).toContain( + '/mnt/c/repo/.git/orca/setup-runner.sh.nonce-wsl-shell.done' + ) + expect(result.startupCommand).toContain( + '/mnt/c/repo/.git/orca/setup-runner.sh.nonce-wsl-shell.done' + ) + }) + it('wraps native Windows runners in a cmd-pinned setup and startup gate', () => { const result = createSequencedSetupAgentCommands({ runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 88f6c11ac1e..92376f10886 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -2,7 +2,8 @@ import { encodePowerShellCommand } from './powershell-command-encoding' import { resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, - type SetupRunnerCommandShell + type SetupRunnerCommandShell, + type SetupRunnerShell } from './setup-runner-command' const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60 @@ -34,11 +35,12 @@ export function createSequencedSetupAgentCommands(args: { runnerScriptPath: string startupCommand: string platform: SetupRunnerCommandPlatform + shell?: SetupRunnerShell nonce?: string waitTimeoutSeconds?: number }): SequencedSetupAgentCommands { const nonce = args.nonce ?? createSetupAgentSequenceNonce() - const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform) + const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell) // Why: overlapping gated launches of the same setup runner must not race on // a shared completion marker. const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 1d2dd5c3662..99ba2e029c4 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { buildSetupRunnerCommand, - getSetupRunnerCommandPlatformForPath + getSetupRunnerCommandPlatformForPath, + nativeWindowsPathToPosixShellPath, + resolveSetupRunnerCommand } from './setup-runner-command' describe('buildSetupRunnerCommand', () => { @@ -28,6 +30,137 @@ describe('buildSetupRunnerCommand', () => { buildSetupRunnerCommand('//server/share/repo/.git/orca/setup-runner.cmd', 'windows') ).toBe('cmd.exe /c "//server/share/repo/.git/orca/setup-runner.cmd"') }) + + it('uses POSIX launch semantics for native Windows runners when the setup shell is POSIX', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix' + }) + ).toBe('bash /c/repo/.git/orca/setup-runner.sh') + }) + + it('uses the active WSL shell with WSL paths for native Windows POSIX runners', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix', + executable: 'wsl.exe' + }) + ).toBe('bash /mnt/c/repo/.git/orca/setup-runner.sh') + }) + + it('keeps cmd.exe launch semantics for cmd setup runners', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'cmd' + }) + ).toBe('cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"') + }) + + it('infers generated POSIX runner shell semantics from extension when metadata is absent', () => { + expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows')).toBe( + 'bash /c/repo/.git/orca/setup-runner.sh' + ) + }) +}) + +describe('buildSetupRunnerCommand cmd metacharacter guard', () => { + const cmdRunner = (segment: string) => `C:\\repo${segment}\\.git\\orca\\setup-runner.cmd` + const decodePowerShellCommand = (command: string): string => { + const encoded = command.match(/-EncodedCommand (\S+)$/)?.[1] + expect(encoded).toBeTruthy() + const bytes = atob(encoded as string) + let decoded = '' + for (let index = 0; index < bytes.length; index += 2) { + decoded += String.fromCharCode(bytes.charCodeAt(index) | (bytes.charCodeAt(index + 1) << 8)) + } + return decoded + } + + it.each(['%', '&', '|', '<', '>', '^', '(', ')', '!', ',', ';', '=', '$', '`'])( + 'hardens the launch when the runner path contains %s', + (character) => { + const command = buildSetupRunnerCommand(cmdRunner(`\\a${character}b`), 'windows', { + family: 'cmd' + }) + + expect(command).toMatch( + /^powershell\.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand [A-Za-z0-9+/=]+$/ + ) + } + ) + + it.each([ + ['plain', 'C:\\repo\\.git\\orca\\setup-runner.cmd'], + ['spaces', 'C:\\Program Files\\repo\\.git\\orca\\setup-runner.cmd'], + ['single quote', "C:\\o'brien\\.git\\orca\\setup-runner.cmd"], + ['brackets and dash', 'C:\\repo-[2]\\.git\\orca\\setup-runner.cmd'] + ])('keeps the plain cmd launch for a %s path', (_label, runnerScriptPath) => { + expect(buildSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toBe( + `cmd.exe /c "${runnerScriptPath}"` + ) + }) + + it('passes the runner path through the environment rather than the cmd argument string', () => { + const runnerScriptPath = cmdRunner('\\100%%\\a&b') + const script = decodePowerShellCommand( + buildSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' }) + ) + + expect(script).toContain(`$runner = '${runnerScriptPath}'`) + expect(script).toContain('$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner') + expect(script).toContain('/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""') + // Why: the whole point of the guard is that the hostile path never reaches cmd as syntax. + expect(script).not.toContain(`/c ""${runnerScriptPath}""`) + expect(script).toContain('$processInfo.UseShellExecute = $false') + }) + + it('escapes single quotes when embedding the path in the PowerShell literal', () => { + const script = decodePowerShellCommand( + buildSetupRunnerCommand("C:\\o'brien&co\\.git\\orca\\setup-runner.cmd", 'windows', { + family: 'cmd' + }) + ) + + expect(script).toContain("$runner = 'C:\\o''brien&co\\.git\\orca\\setup-runner.cmd'") + }) + + it('leaves runnerScriptPathForShell untouched so marker paths keep the native form', () => { + const runnerScriptPath = cmdRunner('\\a&b') + + expect(resolveSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toMatchObject( + { + runnerScriptPathForShell: runnerScriptPath, + shell: 'windows' + } + ) + }) + + it.each([ + ['native POSIX runner', 'C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', undefined], + ['WSL UNC runner', '\\\\wsl.localhost\\Ubuntu\\home\\a&b\\orca\\setup-runner.sh', undefined] + ])('does not disturb the %s launch', (_label, runnerScriptPath) => { + expect(buildSetupRunnerCommand(runnerScriptPath, 'windows')).toMatch(/^bash /) + }) + + it('does not disturb the wsl.exe POSIX launch', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix', + executable: 'wsl.exe' + }) + ).toBe("bash '/mnt/c/repo/a&b/.git/orca/setup-runner.sh'") + }) +}) + +describe('nativeWindowsPathToPosixShellPath', () => { + it('converts a drive path to the MSYS form Git Bash uses', () => { + expect(nativeWindowsPathToPosixShellPath('C:\\Users\\jin\\repo')).toBe('/c/Users/jin/repo') + }) + + it('is idempotent, so a double-applied conversion cannot corrupt a value', () => { + const once = nativeWindowsPathToPosixShellPath('D:\\repo\\worktrees\\feature') + expect(nativeWindowsPathToPosixShellPath(once)).toBe(once) + }) }) describe('getSetupRunnerCommandPlatformForPath', () => { diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index d750f5b9e51..fe9cebed50b 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -1,7 +1,16 @@ import { isWindowsAbsolutePathLike } from './cross-platform-path' +import { + buildWindowsCmdRunnerDelayedLaunchCommand, + windowsRunnerPathNeedsCmdGuard +} from './windows-cmd-runner-delayed-launch' export type SetupRunnerCommandPlatform = 'windows' | 'posix' +export type SetupRunnerShellFamily = 'posix' | 'cmd' export type SetupRunnerCommandShell = 'posix' | 'windows' +export type SetupRunnerShell = { + family: SetupRunnerShellFamily + executable?: string +} export type SetupRunnerCommandResolution = { command: string @@ -11,9 +20,10 @@ export type SetupRunnerCommandResolution = { export function buildSetupRunnerCommand( runnerScriptPath: string, - platform: SetupRunnerCommandPlatform + platform: SetupRunnerCommandPlatform, + shell?: SetupRunnerShell ): string { - return resolveSetupRunnerCommand(runnerScriptPath, platform).command + return resolveSetupRunnerCommand(runnerScriptPath, platform, shell).command } export function getSetupRunnerCommandPlatformForPath( @@ -31,7 +41,8 @@ export function getSetupRunnerCommandPlatformForPath( export function resolveSetupRunnerCommand( runnerScriptPath: string, - platform: SetupRunnerCommandPlatform + platform: SetupRunnerCommandPlatform, + shell?: SetupRunnerShell ): SetupRunnerCommandResolution { if (platform === 'windows') { if (isWslUncPath(runnerScriptPath)) { @@ -49,8 +60,30 @@ export function resolveSetupRunnerCommand( shell: 'posix' } } + if (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath)) { + // Why: WSL shells need /mnt/... paths, while Git Bash expects /c/... when replaying deferred setup scripts. + if (isWslExecutable(shell?.executable)) { + const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath) + return { + command: `bash ${quotePosixArg(wslPath)}`, + runnerScriptPathForShell: wslPath, + shell: 'posix' + } + } + // Why: queued setup launches can outlive the process that generated them, so convert native paths before handing off to POSIX shells. + const posixPath = nativeWindowsPathToPosixShellPath(runnerScriptPath) + return { + command: `bash ${quotePosixArg(posixPath)}`, + runnerScriptPathForShell: posixPath, + shell: 'posix' + } + } return { - command: `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + // Why: some path characters survive no amount of quoting on a cmd command line, so those + // paths take a delayed-expansion launcher instead. Every other path keeps the plain form. + command: windowsRunnerPathNeedsCmdGuard(runnerScriptPath) + ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) + : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'windows' } @@ -85,3 +118,24 @@ function quotePosixArg(value: string): string { function quoteWindowsArg(value: string): string { return `"${value.replace(/"/g, '""')}"` } + +export function nativeWindowsPathToPosixShellPath(value: string): string { + const driveMatch = value.match(/^([A-Za-z]):[\\/](.*)$/) + if (driveMatch) { + return `/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` + } + return value.replace(/\\/g, '/') +} + +function nativeWindowsPathToWslShellPath(value: string): string { + const driveMatch = value.match(/^([A-Za-z]):[\\/](.*)$/) + if (driveMatch) { + return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` + } + return value.replace(/\\/g, '/') +} + +function isWslExecutable(value: string | undefined): boolean { + const basename = value?.trim().replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + return basename === 'wsl.exe' || basename === 'wsl' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b52aa1365c6..8336a33a22d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -51,6 +51,7 @@ import type { StatusBarUsageMode } from './status-bar-usage-mode' import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' import type { TaskSourceContext } from './task-source-context' +import type { SetupRunnerShell } from './setup-runner-command' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -2166,6 +2167,7 @@ export type RepoHookSettings = { export type WorktreeSetupLaunch = { runnerScriptPath: string envVars: Record + shell?: SetupRunnerShell command?: string waitForAgentStartup?: boolean } diff --git a/src/shared/windows-cmd-runner-delayed-launch.ts b/src/shared/windows-cmd-runner-delayed-launch.ts new file mode 100644 index 00000000000..50ed131dc99 --- /dev/null +++ b/src/shared/windows-cmd-runner-delayed-launch.ts @@ -0,0 +1,42 @@ +import { encodePowerShellCommand } from './powershell-command-encoding' + +// Why: `cmd.exe /c ""` is typed into the terminal's shell, so the path is parsed twice. +// cmd expands %VAR% even inside quotes (no escape exists on the command line), and PowerShell +// only re-quotes a native-command argument that contains whitespace — so a space-free path +// carrying any of these reaches cmd unquoted, or is rewritten by PowerShell's own expandable +// -string rules ($ interpolation, ` escapes) before cmd ever sees it. +const WINDOWS_RUNNER_PATH_CMD_GUARD_PATTERN = /[%&|<>^()!,;=$`]/ + +export function windowsRunnerPathNeedsCmdGuard(runnerScriptPath: string): boolean { + return WINDOWS_RUNNER_PATH_CMD_GUARD_PATTERN.test(runnerScriptPath) +} + +/** + * Launches a native Windows runner script whose path cannot be quoted safely on a + * `cmd.exe /c` command line. The path travels as an environment variable and is + * substituted by delayed expansion, which cmd does not re-scan for metacharacters. + */ +export function buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath: string): string { + const script = [ + `$runner = ${quotePowerShellString(runnerScriptPath)}`, + // Why: an empty value would silently degrade to `cmd /c ""`, which exits 0 without running setup. + 'if ([string]::IsNullOrEmpty($runner)) { exit 1 }', + '$processInfo = [System.Diagnostics.ProcessStartInfo]::new()', + '$processInfo.FileName = $env:ComSpec', + "if (-not $processInfo.FileName) { $processInfo.FileName = 'cmd.exe' }", + // Why: /s strips exactly the outer quote pair, leaving "!ORCA_SETUP_RUNNER!" for /v:on to substitute verbatim. + '$processInfo.Arguments = \'/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""\'', + // Why: no redirection means stdio is inherited, so setup output still reaches the ConPTY. + '$processInfo.UseShellExecute = $false', + '$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner', + '$process = [System.Diagnostics.Process]::Start($processInfo)', + '$process.WaitForExit()', + 'exit $process.ExitCode' + ].join('; ') + + return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` +} + +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} diff --git a/src/shared/windows-terminal-shell.test.ts b/src/shared/windows-terminal-shell.test.ts index dbc566ca236..e7d86a07cc9 100644 --- a/src/shared/windows-terminal-shell.test.ts +++ b/src/shared/windows-terminal-shell.test.ts @@ -26,4 +26,10 @@ describe('resolveWindowsShellStartupFamily', () => { expect(resolveWindowsShellStartupFamily('wsl.exe')).toBe('posix') expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('posix') }) + + it('maps extension-less bash and wsl entries to POSIX quoting', () => { + expect(resolveWindowsShellStartupFamily('bash')).toBe('posix') + expect(resolveWindowsShellStartupFamily('wsl')).toBe('posix') + expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash')).toBe('posix') + }) }) diff --git a/src/shared/windows-terminal-shell.ts b/src/shared/windows-terminal-shell.ts index 72dda7de71e..1f55bf89d35 100644 --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -30,7 +30,13 @@ export function resolveWindowsShellStartupFamily( } // Why: wsl.exe and bash.exe (Git for Windows) launch POSIX shells, so queued // commands must use POSIX quoting and `cd ''` rather than cmd/PowerShell. - if (basename === 'wsl.exe' || basename === 'wsl' || basename === 'bash.exe') { + // Extension-less forms reach the same executables through PATHEXT. + if ( + basename === 'wsl.exe' || + basename === 'wsl' || + basename === 'bash.exe' || + basename === 'bash' + ) { return 'posix' } return 'powershell'