feat: configurable terminal shell with Windows pwsh auto-probe (#95)

* feat: configurable terminal shell with Windows pwsh auto-probe

Windows previously hardcoded powershell.exe (5.1) in defaultShell(), ignoring PowerShell 7 installs. Resolve the shell in priority order: explicit shell config -> DSH_SIDEBAR_SHELL -> PATH/known-dir probe for pwsh.exe -> inbox 5.1 fallback. The same resolved shell feeds both the UI terminal tabs (PtyManager) and the model-facing terminal_* tools (AgentPtyRegistry).

defaultShell() now accepts injectable platform/env/exists options so the Windows chain is unit-testable on the ubuntu CI, which never executes win32 branches. The probe also checks ProgramW6432 for 32-bit Node, and machines without PowerShell 7 keep the 5.1 fallback.

Refs: #46

* fix: trim resolved SHELL and document ProgramW6432 in resolver contract

- POSIX SHELL is validated with trim() but was returned untrimmed; trailing whitespace could leak into the spawned executable path. Return the trimmed value like the explicit/config and Windows/env branches.
- ShellResolutionOptions.env comment now lists ProgramW6432, which the Windows probe prefers over ProgramFiles on 32-bit Node.
- Test pins the SHELL trimming behavior.
This commit is contained in:
oy-g
2026-08-16 16:34:21 +08:00
committed by GitHub
parent 835d1538e0
commit fd9544f16b
5 changed files with 165 additions and 32 deletions
+7
View File
@@ -34,6 +34,13 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.listLimit).toBe(1000)
expect(resolved.terminalsPerSession).toBe(3)
expect(resolved.reconnectGraceMs).toBe(30_000)
// The terminal shell config defaults to auto-resolution (empty shell =
// the platform chain in defaultShell()).
expect(resolved.shell).toBe('')
const configured = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ shell: 'pwsh.exe' })
expect(configured.shell).toBe('pwsh.exe')
})
it('registers the side card preferences schema with the documented defaults', async () => {
+54 -19
View File
@@ -31,6 +31,7 @@ import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
import type { Context } from '../src/context-types.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
import { resolveSidebarConfig } from '../src/config.ts'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
} from '../src/client/subagent-detect.ts'
@@ -859,25 +860,59 @@ describe('agent terminal reconciliation', () => {
})
describe('pty helpers', () => {
it('prefers an explicit SHELL, then falls back to the account login shell', () => {
if (process.platform === 'win32') {
// defaultShell() short-circuits to powershell.exe before env/passwd
// resolution on Windows; the POSIX fallback chain is asserted below.
expect(defaultShell()).toBe('powershell.exe')
return
}
const previous = process.env.SHELL
try {
process.env.SHELL = '/explicit/zsh'
expect(defaultShell()).toBe('/explicit/zsh')
process.env.SHELL = ' '
expect(defaultShell()).toBe('/usr/bin/zsh')
delete process.env.SHELL
expect(defaultShell()).toBe('/usr/bin/zsh')
} finally {
if (previous === undefined) delete process.env.SHELL
else process.env.SHELL = previous
}
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
// The platform is injected so this chain also runs (and is asserted) on
// non-POSIX developer machines. The userInfo mock at the top of the file
// pins the passwd login shell to /usr/bin/zsh.
expect(defaultShell({ platform: 'linux', env: { SHELL: '/explicit/zsh' } })).toBe('/explicit/zsh')
// Surrounding whitespace must not leak into the spawned executable path.
expect(defaultShell({ platform: 'linux', env: { SHELL: ' /explicit/zsh ' } })).toBe('/explicit/zsh')
expect(defaultShell({ platform: 'linux', env: { SHELL: ' ' } })).toBe('/usr/bin/zsh')
expect(defaultShell({ platform: 'linux', env: {} })).toBe('/usr/bin/zsh')
})
it('Windows: explicit shell wins, then DSH_SIDEBAR_SHELL, then a probed pwsh.exe, then powershell.exe 5.1', () => {
// The explicit shell (the `shell` config field) beats every automatic
// source, and whitespace-only values count as unset.
expect(defaultShell({ platform: 'win32', explicit: 'C:\\Tools\\pwsh.exe', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('C:\\Tools\\pwsh.exe')
expect(defaultShell({ platform: 'win32', explicit: ' ', env: { DSH_SIDEBAR_SHELL: 'pwsh.exe' } }))
.toBe('pwsh.exe')
// PATH is probed entry by entry; the first directory containing pwsh.exe
// wins and the returned value is the full resolved path. The candidate is
// normalized to forward slashes before comparing so the assertion holds
// under BOTH join() implementations: on the ubuntu runners node:path is
// POSIX and joins 'C:\\other' + 'pwsh.exe' as 'C:\\other/pwsh.exe'.
const fromPath = defaultShell({
platform: 'win32',
env: { PATH: 'C:\\tools;C:\\other' },
exists: path => path.replaceAll('\\', '/') === 'C:/other/pwsh.exe',
})
expect(fromPath.replaceAll('\\', '/')).toBe('C:/other/pwsh.exe')
// PATH misses fall through to the known install directories. On 32-bit
// Node, ProgramW6432 is the real 64-bit Program Files and is preferred.
const fromProgramW6432 = defaultShell({
platform: 'win32',
env: { ProgramW6432: 'C:\\PF64' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF64/PowerShell/7/pwsh.exe',
})
expect(fromProgramW6432.replaceAll('\\', '/')).toBe('C:/PF64/PowerShell/7/pwsh.exe')
const fromProgramFiles = defaultShell({
platform: 'win32',
env: { ProgramFiles: 'C:\\PF', LOCALAPPDATA: 'C:\\Users\\x\\AppData\\Local' },
exists: path => path.replaceAll('\\', '/') === 'C:/PF/PowerShell/7/pwsh.exe',
})
expect(fromProgramFiles.replaceAll('\\', '/')).toBe('C:/PF/PowerShell/7/pwsh.exe')
// Nothing installed: keep the inbox 5.1 fallback instead of breaking.
expect(defaultShell({ platform: 'win32', env: {}, exists: () => false })).toBe('powershell.exe')
})
it('trims the configured shell and defaults it to auto for old documents', () => {
expect(resolveSidebarConfig(undefined).shell).toBe('')
expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe')
})
it('restores the spawn-helper executable bit idempotently', () => {