fix(settings): paste bash-native skill setup under WSL PTY (#13710)

* fix(settings): paste bash-native skill setup under WSL PTY

WSL worktree setup terminals force wsl.exe even when shellOverride is
powershell.exe. Auto-pasting the PowerShell `& { wsl.exe ... }` wrapper
into bash fails with a leading-& syntax error (#13305). Rewrite that
wrapper to a bash login-shell script for setup-terminal paste only;
clipboard copy still keeps the PS host wrapper for manual use outside Orca.

* fix(settings): align skill paste with resolved PTY shell

* fix(onboarding): keep shell preparation out of render

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
BingZ
2026-08-11 00:07:49 -07:00
committed by GitHub
co-authored by OrcaWin
parent 3ed4dc484f
commit bdebe53568
14 changed files with 240 additions and 39 deletions
@@ -21,12 +21,6 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
activeSkillRuntime.installDisabledReason ? undefined : activeSkillRuntime.agentRuntime
)
// The copied string stays as built; only what we execute is adapted.
const setupTerminalCommand = buildSkillSetupTerminalCommand(
skillCommand,
activeSkillRuntime.terminalShellOverride
)
const handleCopySkillCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(skillCommand)
@@ -78,7 +72,8 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
</Tooltip>
</div>
<OnboardingInlineCommandTerminal
command={setupTerminalCommand}
command={skillCommand}
prepareCommandForShell={buildSkillSetupTerminalCommand}
title={translate(
'auto.components.feature.tips.CliSkillSetupTerminal.84e9576dac',
'Skill setup'
@@ -97,6 +92,7 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
autoScrollIntoView={false}
worktreeId="feature-tip-cli-skills-terminal"
shellOverride={activeSkillRuntime.terminalShellOverride}
forceHostRuntime={Boolean(activeSkillRuntime.installDisabledReason)}
/>
</div>
)
@@ -13,7 +13,15 @@ const mocks = vi.hoisted(() => ({
(command: string, runtime?: { runtime: 'host' | 'wsl' }) =>
`${runtime?.runtime ?? 'host'}:${command}`
),
terminalProps: null as { command: string; shellOverride?: string } | null
buildSetupCommand: vi.fn(
(command: string, shellOverride?: string) => `${shellOverride ?? 'default'}:${command}`
),
terminalProps: null as {
command: string
forceHostRuntime?: boolean
prepareCommandForShell?: (command: string, shellOverride?: string) => string
shellOverride?: string
} | null
}))
vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
@@ -21,7 +29,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({
}))
vi.mock('../settings/CliSkillRuntimeSetup', () => ({
buildSkillCommandForRuntime: mocks.buildCommand
buildSkillCommandForRuntime: mocks.buildCommand,
buildSkillSetupTerminalCommand: mocks.buildSetupCommand
}))
vi.mock('./OnboardingInlineCommandTerminal', () => ({
@@ -57,8 +66,13 @@ describe('FeatureSetupInlineTerminal', () => {
})
expect(mocks.terminalProps).toMatchObject({
command: 'wsl:npx skills add orchestration',
forceHostRuntime: false,
prepareCommandForShell: mocks.buildSetupCommand,
shellOverride: 'powershell.exe'
})
expect(
mocks.terminalProps?.prepareCommandForShell?.('wsl:npx skills add orchestration', 'wsl.exe')
).toBe('wsl.exe:wsl:npx skills add orchestration')
})
it('uses the host command builder when the WSL runtime needs repair', () => {
@@ -71,6 +85,7 @@ describe('FeatureSetupInlineTerminal', () => {
expect(mocks.buildCommand).toHaveBeenCalledWith('npx skills add orchestration', undefined)
expect(mocks.terminalProps).toMatchObject({
command: 'host:npx skills add orchestration',
forceHostRuntime: true,
shellOverride: 'powershell.exe'
})
})
@@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef, type KeyboardEvent } from 'react'
import { track } from '@/lib/telemetry'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { buildSkillCommandForRuntime } from '../settings/CliSkillRuntimeSetup'
import {
buildSkillCommandForRuntime,
buildSkillSetupTerminalCommand
} from '../settings/CliSkillRuntimeSetup'
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
import {
getOnboardingFeatureSetupAgentRuntime,
@@ -71,7 +74,9 @@ export function FeatureSetupInlineTerminal({
return (
<OnboardingInlineCommandTerminal
command={runtimeCommand}
prepareCommandForShell={buildSkillSetupTerminalCommand}
shellOverride={setupRuntime.terminalShellOverride}
forceHostRuntime={Boolean(setupRuntime.installDisabledReason)}
title={translate(
'auto.components.onboarding.FeatureSetupInlineTerminal.c767ab7061',
'Skill setup'
@@ -8,9 +8,10 @@ import {
ORCA_TERMINAL_COMMAND_FINISHED_EVENT,
type TerminalCommandFinishedEventDetail
} from '@/hooks/terminal-command-finished-event'
import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal'
const mocks = vi.hoisted(() => ({
createTab: vi.fn(() => ({ id: 'tab-1' })),
createTab: vi.fn(() => ({ id: 'tab-1', shellOverride: 'wsl.exe' })),
closeTab: vi.fn(),
setActiveTabForWorktree: vi.fn(),
setTabCustomTitle: vi.fn()
@@ -27,7 +28,12 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/components/terminal-pane/TerminalPane', () => ({
default: () => <div data-testid="terminal-pane" />
default: (props: { tabId: string }) => (
<div data-testid="terminal-pane" data-terminal-tab-id={props.tabId}>
<div data-pty-id="pty-1" />
<div className="xterm-rows">$</div>
</div>
)
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
@@ -69,6 +75,81 @@ describe('OnboardingInlineCommandTerminal command-finished forwarding', () => {
container?.remove()
container = null
Reflect.deleteProperty(window, 'api')
vi.useRealTimers()
})
it('prepares auto-paste again when the resolved tab shell changes', async () => {
vi.useFakeTimers()
mocks.createTab
.mockReturnValueOnce({ id: 'tab-1', shellOverride: 'wsl.exe' })
.mockReturnValueOnce({ id: 'tab-2', shellOverride: 'powershell.exe' })
const prepareCommandForShell = vi.fn(
(command: string, shellOverride: string | undefined) => `${shellOverride}:${command}`
)
const pasted: PasteTerminalTextDetail[] = []
const handlePaste = (event: Event): void => {
pasted.push((event as CustomEvent<PasteTerminalTextDetail>).detail)
}
window.addEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
try {
await act(async () => {
root?.render(
<OnboardingInlineCommandTerminal
command="npx skills add orchestration"
prepareCommandForShell={prepareCommandForShell}
shellOverride="powershell.exe"
title="Skill setup"
ariaLabel="Skill setup terminal"
/>
)
})
await act(async () => {})
await act(async () => {
vi.advanceTimersByTime(250)
})
await act(async () => {
root?.render(
<OnboardingInlineCommandTerminal
command="npx skills add orchestration"
forceHostRuntime
prepareCommandForShell={prepareCommandForShell}
shellOverride="powershell.exe"
title="Skill setup"
ariaLabel="Skill setup terminal"
/>
)
})
await act(async () => {})
await act(async () => {
vi.advanceTimersByTime(250)
})
expect(prepareCommandForShell).toHaveBeenCalledWith('npx skills add orchestration', 'wsl.exe')
expect(prepareCommandForShell).toHaveBeenCalledWith(
'npx skills add orchestration',
'powershell.exe'
)
expect(mocks.createTab).toHaveBeenCalledWith(
'ephemeral-setup-terminal:onboarding-inline-terminal',
undefined,
'powershell.exe',
expect.objectContaining({ forceHostRuntime: true })
)
expect(pasted).toContainEqual({
tabId: 'tab-1',
text: 'wsl.exe:npx skills add orchestration'
})
expect(pasted).toContainEqual({
tabId: 'tab-2',
text: 'powershell.exe:npx skills add orchestration'
})
} finally {
window.removeEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste)
}
})
it('forwards exit codes only for its own branded worktree id', async () => {
@@ -21,6 +21,7 @@ const PTY_TEXT_FALLBACK_MS = 750
type OnboardingInlineCommandTerminalProps = {
command: string
prepareCommandForShell?: (command: string, shellOverride: string | undefined) => string
title: string
description?: string
ariaLabel: string
@@ -30,6 +31,7 @@ type OnboardingInlineCommandTerminalProps = {
autoScrollIntoView?: boolean
worktreeId?: string
shellOverride?: string
forceHostRuntime?: boolean
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
onTerminalExit?: () => void
@@ -43,6 +45,7 @@ type OnboardingInlineCommandTerminalProps = {
*/
export function OnboardingInlineCommandTerminal({
command,
prepareCommandForShell,
title,
description,
ariaLabel,
@@ -52,6 +55,7 @@ export function OnboardingInlineCommandTerminal({
autoScrollIntoView = true,
worktreeId: worktreeIdProp = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID,
shellOverride,
forceHostRuntime = false,
onOpened,
onInteracted,
onTerminalExit,
@@ -75,13 +79,17 @@ export function OnboardingInlineCommandTerminal({
[]
)
const [cwd, setCwd] = useState<string | null>(null)
const [tabId, setTabId] = useState<string | null>(null)
const [createdTab, setCreatedTab] = useState<{
id: string
shellOverride: string | undefined
} | null>(null)
const tabId = createdTab?.id ?? null
// Why: starts at `prefersReducedMotion` so users opted out of motion never
// see the slide-in frame; otherwise we flip to true after first paint so the
// CSS transition has a starting state to interpolate from.
const [entered, setEntered] = useState(prefersReducedMotion)
const terminalSectionRef = useRef<HTMLElement>(null)
const autoInsertedRef = useRef<string | null>(null)
const autoInsertedRef = useRef<{ tabId: string; command: string } | null>(null)
useEffect(() => {
onOpened?.()
@@ -120,11 +128,12 @@ export function OnboardingInlineCommandTerminal({
useEffect(() => {
const tab = createTab(worktreeId, undefined, shellOverride, {
activate: false,
recordInteraction: false
recordInteraction: false,
forceHostRuntime
})
setActiveTabForWorktree(worktreeId, tab.id)
setTabCustomTitle(tab.id, title, { recordInteraction: false })
setTabId(tab.id)
setCreatedTab({ id: tab.id, shellOverride: tab.shellOverride })
return () => {
// Why: inline setup panels can disappear after detection succeeds; close
// the backing tab so installer shells do not keep running invisibly.
@@ -133,6 +142,7 @@ export function OnboardingInlineCommandTerminal({
}, [
closeTab,
createTab,
forceHostRuntime,
setActiveTabForWorktree,
setTabCustomTitle,
shellOverride,
@@ -201,9 +211,17 @@ export function OnboardingInlineCommandTerminal({
}, [autoScrollIntoView, entered, prefersReducedMotion])
const insertCommand = useCallback(() => {
if (!tabId) {
if (!createdTab) {
return
}
const terminalCommand = prepareCommandForShell?.(command, createdTab.shellOverride) ?? command
if (
autoInsertedRef.current?.tabId === createdTab.id &&
autoInsertedRef.current.command === terminalCommand
) {
return
}
autoInsertedRef.current = { tabId: createdTab.id, command: terminalCommand }
if (autoScrollIntoView) {
terminalSectionRef.current?.scrollIntoView({
behavior: 'auto',
@@ -213,16 +231,16 @@ export function OnboardingInlineCommandTerminal({
window.dispatchEvent(
new CustomEvent<PasteTerminalTextDetail>(PASTE_TERMINAL_TEXT_EVENT, {
detail: {
tabId,
text: command.trim()
tabId: createdTab.id,
text: terminalCommand.trim()
}
})
)
focusTerminalTabSurface(tabId)
}, [autoScrollIntoView, command, tabId])
focusTerminalTabSurface(createdTab.id)
}, [autoScrollIntoView, command, createdTab, prepareCommandForShell])
useEffect(() => {
if (!tabId || !cwd || autoInsertedRef.current === command) {
if (!tabId || !cwd) {
return
}
let canceled = false
@@ -236,7 +254,6 @@ export function OnboardingInlineCommandTerminal({
}
insertionTimer = window.setTimeout(() => {
if (!canceled) {
autoInsertedRef.current = command
insertCommand()
}
}, AUTO_INSERT_DELAY_MS)
@@ -280,7 +297,7 @@ export function OnboardingInlineCommandTerminal({
window.clearTimeout(insertionTimer)
}
}
}, [command, cwd, insertCommand, tabId])
}, [cwd, insertCommand, tabId])
// Why: grid 0fr → 1fr animates to the child's natural height without a
// hardcoded max-height, so we don't leave dead space if the terminal
@@ -387,11 +387,11 @@ export function AgentSkillSetupPanel({
</TooltipContent>
</Tooltip>
</div>
{/* The copied string above stays as built; only what we run is adapted. */}
<OnboardingInlineCommandTerminal
key={terminalAttempt}
worktreeId={terminalWorktreeId}
command={buildSkillSetupTerminalCommand(openTerminalCommand, terminalShellOverride)}
command={openTerminalCommand}
prepareCommandForShell={buildSkillSetupTerminalCommand}
title={terminalTitle}
description={translate(
'auto.components.settings.AgentSkillSetupPanel.runCommandDescription',
@@ -314,19 +314,38 @@ describe('CliSkillRuntimeSetup runtime helpers', () => {
}
})
it('leaves WSL and non-Windows setup terminal commands untouched', () => {
it('rewrites WSL PowerShell wrappers to bash for setup-terminal auto-paste', () => {
const skillCommand = 'npx skills add orchestration --global'
const wslCommand = buildSkillCommandForRuntime(
'npx skills add orchestration --global',
skillCommand,
{ runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' },
'win32'
)
expect(wslCommand.startsWith('& {')).toBe(true)
expect(buildSkillSetupTerminalCommand(wslCommand, 'powershell.exe', 'win32')).toBe(wslCommand)
const setupCommand = buildSkillSetupTerminalCommand(wslCommand, 'wsl.exe', 'win32')
expect(setupCommand.startsWith('&')).toBe(false)
expect(setupCommand).toBe(buildWslLoginShellCommand(skillCommand))
expect(setupCommand).toContain('npx skills add orchestration --global')
expect(
buildSkillSetupTerminalCommand('npx skills add orchestration --global', undefined, 'linux')
).toBe('npx skills add orchestration --global')
})
it('preserves the exact WSL script when adapting setup-terminal auto-paste', () => {
const skillCommand = "printf 'héllo\n# Runs: unchanged'"
const copiedCommand = buildSkillCommandForRuntime(skillCommand, {
runtime: 'wsl',
wslDistro: 'Ubuntu',
label: 'WSL Ubuntu'
})
expect(buildSkillSetupTerminalCommand(copiedCommand, 'wsl.exe', 'win32')).toBe(
buildWslLoginShellCommand(skillCommand)
)
})
it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => {
const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration'])
const previous = useAppStore.getState()
@@ -8,6 +8,7 @@ import {
quotePowerShellNativeArgument
} from '../../../../shared/powershell-native-argument'
import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command'
import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime'
import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell'
import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime'
import { useAppStore } from '@/store'
@@ -138,16 +139,23 @@ function normalizeWindowsSkillUpdateCommand(
type SkillCommandTarget = 'copied-command' | 'orca-setup-terminal'
/**
* Re-adds the npx preflight for Orca's own setup terminal, which
* `getAgentSkillTerminalShellOverride` forces onto powershell.exe. The copied
* string stays bare for POSIX-family shells; only the executed one is wrapped.
* Adapts a copied skill command for Orca's inline setup terminal auto-paste.
* Host Windows installs may gain an npx preflight; WSL-targeted PowerShell wrappers
* must become bash-native because the daemon forces wsl.exe for WSL worktrees.
*/
export function buildSkillSetupTerminalCommand(
copiedCommand: string,
terminalShellOverride: string | undefined,
effectiveShell: string | undefined,
currentPlatform = getSkillCommandPlatform()
): string {
if (!isSetupTerminalForcedToPowerShell(terminalShellOverride)) {
// Why: the created tab is authoritative when project runtime replaces the requested shell.
const wslNative = isWslShellName(effectiveShell)
? decodeWslSetupTerminalCommand(copiedCommand)
: null
if (wslNative) {
return wslNative
}
if (!isSetupTerminalForcedToPowerShell(effectiveShell)) {
return copiedCommand
}
return wrapWindowsSkillCommandWithNpxPrerequisite(
@@ -157,6 +165,30 @@ export function buildSkillSetupTerminalCommand(
)
}
function decodeWslSetupTerminalCommand(command: string): string | null {
if (
!command.startsWith("& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe") ||
!command.includes(' } # Runs: ')
) {
return null
}
const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec(
command
)?.[1]
if (!encoded) {
return null
}
try {
const binary = atob(encoded)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return new TextDecoder().decode(bytes)
} catch {
return null
}
}
function isSetupTerminalForcedToPowerShell(terminalShellOverride: string | undefined): boolean {
const trimmedOverride = terminalShellOverride?.trim()
return (
@@ -170,11 +170,11 @@ describe('AgentSkillSetupPanel installed-command call sites', () => {
)
expect(source).toContain('buildSkillCommandForRuntime(')
// The copied string stays bare for POSIX-family shells; the forced-PowerShell
// setup terminal keeps the npx preflight.
// Clipboard and auto-paste share the source command until the created tab
// resolves the shell that prepares the executable form.
expect(source).toContain('writeClipboardText(skillCommand)')
expect(source).toContain('buildSkillSetupTerminalCommand(')
expect(source).toContain('command={setupTerminalCommand}')
expect(source).toContain('command={skillCommand}')
expect(source).toContain('prepareCommandForShell={buildSkillSetupTerminalCommand}')
expect(source).toContain('shellOverride={activeSkillRuntime.terminalShellOverride}')
expect(source).not.toContain('command={ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND}')
// This terminal auto-pastes with no install gate, so a repair-required runtime
@@ -153,6 +153,7 @@ type StoreState = {
title?: string
launchAgent?: string
shellOverride?: string
forceHostRuntime?: boolean
generation?: number
}[]
>
@@ -2316,6 +2317,27 @@ describe('connectPanePty', () => {
})
})
it('keeps an explicit host fallback out of the project runtime', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: null, forceHostRuntime: true }]
},
settings: {
...mockStoreState.settings,
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}
}
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
await flushAsyncTicks()
expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined()
})
it('observes live terminal GitHub PR URLs before agent completion', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -3646,7 +3646,7 @@ export function connectPanePty(
? getCachedWindowsTerminalCapabilities()
: null
const projectRuntime =
!connectionId && runtimeEnvironmentId === null
!tab?.forceHostRuntime && !connectionId && runtimeEnvironmentId === null
? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, {
wslAvailable: localWindowsTerminalCapabilities?.wslAvailable,
availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null
@@ -1658,6 +1658,14 @@ describe('setActiveWorktree', () => {
const terminal = store.getState().createTab(wt, undefined, 'cmd.exe')
expect(terminal.shellOverride).toBe('wsl.exe')
const hostTerminal = store
.getState()
.createTab(wt, undefined, 'powershell.exe', { forceHostRuntime: true })
expect(hostTerminal).toMatchObject({
shellOverride: 'powershell.exe',
forceHostRuntime: true
})
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
+5 -1
View File
@@ -670,6 +670,7 @@ export type TerminalSlice = {
/** Initial native-chat view mode; agent launches pass 'chat' when openAgentTabsInChatByDefault is on, else omitted for the 'terminal' default. */
viewMode?: Tab['viewMode']
startupCwd?: string
forceHostRuntime?: boolean
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
@@ -1379,7 +1380,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
: null,
// Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe.
isWslWorktree,
isRemoteWorktree ? undefined : getLocalProjectExecutionRuntimeContext(s, worktreeId)
isRemoteWorktree || options?.forceHostRuntime
? undefined
: getLocalProjectExecutionRuntimeContext(s, worktreeId)
)
tab = {
id,
@@ -1396,6 +1399,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
createdAt: Date.now(),
...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}),
...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}),
...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}),
...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}),
// Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click.
...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {})
+2
View File
@@ -903,6 +903,8 @@ export type TerminalTab = {
* PTY and tab icon stay stable even if the default shell setting changes
* later. Older persisted tabs may omit this field. */
shellOverride?: string
/** Keeps an ephemeral host fallback out of the active project's runtime. */
forceHostRuntime?: boolean
/** Why: explorer-created terminals can start below the workspace root while
* still belonging to that workspace for tab/session ownership. */
startupCwd?: string