fix(worktrees): respect Windows shell for setup runners (#6967)

* Honor configured shells during worktree setup

* Align setup launch paths with selected Windows shells

* Carry setup shell selection through deferred launches

* Prove Windows setup shell routing at its real adapters

* Ground remote PowerShell proof in the real writer

* Preserve Git Bash across deferred setup launches

* Harden Windows setup runner shell selection

- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
  Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
  'auto' implementation could route the remote runner to a pwsh.exe the remote
  lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
  auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
  $LASTEXITCODE before $?, so a failing native command surfaces its real code
  instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
  new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
  non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.

* Restore setup-shell scope narrowing over the rebase

The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:

- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
  or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
  family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures

* Satisfy the changed-code gates for the setup-shell runner

- createWorktreeRunnerScript took 7 positional parameters, tripping the
  changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
  assert the cmd shell now returned for native Windows worktrees.

* Carry the setup launch shell through observed and issue runners

- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
  Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
  resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
  when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`

Co-authored-by: Orca <help@stably.ai>

* fix(worktrees): close counsel P1 gaps for Windows setup shells

Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.

* Convert setup env to MSYS form and harden the bare cmd runner launch

C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.

Co-authored-by: Orca <help@stably.ai>

* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution

Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.

* revert: drop windows-setup-shell doc allowlist and AGENTS link

Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.

* fix(plugins): contain Parcel unsubscribe rejections under Vitest

Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.

* fix(plugins): keep in-process unsubscribe rejection surface

Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Rod Boev 2026-08-02 20:40:58 -04:00 committed by GitHub
parent d426e35be3
commit 8c5371ebad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1900 additions and 118 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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') =>
({

View File

@ -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<string, unknown> | 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<string, string> {
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<string, string> {
@ -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.
*/

View File

@ -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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
@ -1144,6 +1154,8 @@ async function createRemoteSetupRunnerScript(
fsProvider: IFilesystemProvider
): Promise<CreateWorktreeResult['setup']> {
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)

View File

@ -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' }
)
})
})

View File

@ -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({

View File

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

View File

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

View File

@ -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'))

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, string> } {
const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform)
const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform, shell)
if (resolution.shell === 'windows') {
const script = [
`$runner = $env:${WINDOWS_SETUP_RUNNER_ENV}`,

View File

@ -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<string, string> }> =>
ipcRenderer.invoke('hooks:createIssueCommandRunner', args),
}): Promise<WorktreeSetupLaunch> => 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<string, string> }
setup?: WorktreeSetupLaunch
startup?: { command: string; env?: Record<string, string> }
defaultTabs?: WorktreeDefaultTabsLaunch
}) => void
@ -3695,7 +3695,7 @@ const api = {
data: {
repoId: string
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
setup?: WorktreeSetupLaunch
startup?: { command: string; env?: Record<string, string> }
defaultTabs?: WorktreeDefaultTabsLaunch
}

View File

@ -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', {

View File

@ -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
])

View File

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

View File

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

View File

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

View File

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

View File

@ -112,6 +112,13 @@ export type IssueCommandLaunch =
| WorktreeSetupLaunch
| { command: string; env?: Record<string, string> }
function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' {
return getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
)
}
type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabsByWorktree: Record<string, { id: string }[]>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
@ -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 }

View File

@ -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',

View File

@ -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`

View File

@ -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', () => {

View File

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

View File

@ -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<string, string>
shell?: SetupRunnerShell
command?: string
waitForAgentStartup?: boolean
}

View File

@ -0,0 +1,42 @@
import { encodePowerShellCommand } from './powershell-command-encoding'
// Why: `cmd.exe /c "<path>"` 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, "''")}'`
}

View File

@ -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')
})
})

View File

@ -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 '<cwd>'` 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'