mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
fix(terminal): open the picked Windows shell on a managed host instead of typing it into PowerShell (#29836)
* fix(terminal): open the picked Windows shell on a managed host instead of typing it into PowerShell * fix(terminal): keep the shell pick through materialization and folder runtimes * fix(terminal): refuse a shell pick on agent launches and update paired-tab expectations * test(terminal): type the shell-selection runtime fixture instead of casting
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService, electronMocks, ipcMain } from '../orca-runtime-test-mocks.spec'
|
||||
import { TEST_REPO_ID, TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec'
|
||||
import { createMobileCreateTestNotifier } from '../orca-runtime-test-scenario-builders.spec'
|
||||
|
||||
function makeHeadlessRuntime(runtimeStore: typeof store = store) {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-shell-pick' })
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
return { runtime, spawn }
|
||||
}
|
||||
|
||||
async function onHostPlatform(platform: NodeJS.Platform, run: () => Promise<void>): Promise<void> {
|
||||
const hostPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', hostPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowsHost = (run: () => Promise<void>) => onHostPlatform('win32', run)
|
||||
|
||||
describe('OrcaRuntimeService session-tab shell selection', () => {
|
||||
it('spawns the picked shell for a headless session terminal instead of typing it', async () => {
|
||||
await onWindowsHost(async () => {
|
||||
const { runtime, spawn } = makeHeadlessRuntime()
|
||||
|
||||
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
shellOverride: 'git-bash'
|
||||
})
|
||||
|
||||
// The defect this pins: the pick rode as `command`, so the managed host typed
|
||||
// `git-bash` into PowerShell and the tab stayed PowerShell.
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ shellOverride: 'git-bash', command: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the pick when a renderer-created tab is materialized after its surface stalls', async () => {
|
||||
await onWindowsHost(async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const leafId = '44444444-4444-4444-8444-444444444444'
|
||||
const { runtime, spawn } = makeHeadlessRuntime()
|
||||
runtime.setNotifier(createMobileCreateTestNotifier(vi.fn()))
|
||||
const webContents = { send: vi.fn() }
|
||||
webContents.send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-pending', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
|
||||
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
shellOverride: 'cmd.exe'
|
||||
})
|
||||
await vi.waitFor(() => expect(webContents.send).toHaveBeenCalledTimes(1))
|
||||
expect(webContents.send.mock.calls[0]?.[1]).toMatchObject({ shellOverride: 'cmd.exe' })
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-pending',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: `tab-pending::${leafId}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `tab-pending::${leafId}`,
|
||||
parentTabId: 'tab-pending',
|
||||
leafId,
|
||||
title: 'Terminal',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await create
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'tab-pending', shellOverride: 'cmd.exe' })
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a Windows shell on a POSIX host before any terminal exists', async () => {
|
||||
await onHostPlatform('linux', async () => {
|
||||
const { runtime, spawn } = makeHeadlessRuntime()
|
||||
|
||||
await expect(
|
||||
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { shellOverride: 'cmd.exe' })
|
||||
).rejects.toThrow(/names a Windows shell/)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a shell pick for a workspace that runs over SSH', async () => {
|
||||
await onWindowsHost(async () => {
|
||||
const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' }
|
||||
const { runtime, spawn } = makeHeadlessRuntime({
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined)
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { shellOverride: 'cmd.exe' })
|
||||
).rejects.toThrow(/over SSH/)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -86,6 +86,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec')
|
||||
await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec')
|
||||
await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec')
|
||||
await import('./orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec')
|
||||
await import('./orca-runtime-tests/mobile-session-tabs-shell-selection.spec')
|
||||
await import('./orca-runtime-tests/exit-retirement-activation.spec')
|
||||
await import('./orca-runtime-tests/mobile-creation-and-orchestration.spec')
|
||||
await import('./orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec')
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import '../unused-default-rpc-methods.test-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
|
||||
import { SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY } from '../../../../shared/session-tabs-runtime-capabilities'
|
||||
import { SESSION_TAB_METHODS } from './session-tabs'
|
||||
|
||||
function makeRequest(params: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method: 'session.tabs.createTerminal', params }
|
||||
}
|
||||
|
||||
function makeRuntime(): OrcaRuntimeService {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only calls these two members.
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createMobileSessionTerminal: vi.fn().mockResolvedValue({
|
||||
tab: { type: 'terminal', id: 'tab-1::leaf-1' },
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
describe('session.tabs.createTerminal shell selection', () => {
|
||||
it('advertises shell selection so clients stop typing the shell into the default one', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY)
|
||||
})
|
||||
|
||||
it('creates the requested shell instead of a startup command', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest({ worktree: 'id:wt-1', shell: 'Cmd' }))
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
const opts = vi.mocked(runtime.createMobileSessionTerminal).mock.calls[0]?.[1]
|
||||
expect(opts).toMatchObject({ shellOverride: 'cmd.exe' })
|
||||
expect(opts?.command).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the override when no shell is requested', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest({ worktree: 'id:wt-1' }))
|
||||
|
||||
const opts = vi.mocked(runtime.createMobileSessionTerminal).mock.calls[0]?.[1]
|
||||
expect(opts).not.toHaveProperty('shellOverride')
|
||||
})
|
||||
|
||||
it('refuses a shell pick combined with an agent launch, whose plan picks its own shell', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest({ worktree: 'id:wt-1', shell: 'cmd.exe', agent: 'claude' })
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(runtime.createMobileSessionTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a shell outside the Windows allowlist before creating a terminal', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(makeRequest({ worktree: 'id:wt-1', shell: 'zsh' }))
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(runtime.createMobileSessionTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,7 @@ export const SESSION_TAB_METHODS = [
|
||||
afterTabId: params.afterTabId,
|
||||
targetGroupId: params.targetGroupId,
|
||||
command: params.command,
|
||||
...(params.shell ? { shellOverride: params.shell } : {}),
|
||||
cwd: params.cwd,
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
...(params.envToDelete ? { envToDelete: params.envToDelete } : {}),
|
||||
|
||||
@@ -32,6 +32,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun
|
||||
afterTabId?: string
|
||||
targetGroupId?: string
|
||||
command?: string
|
||||
shellOverride?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
|
||||
@@ -27,6 +27,7 @@ const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000
|
||||
|
||||
export type OwnedMobileTerminalCreateOptions = {
|
||||
command?: string
|
||||
shellOverride?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
@@ -114,6 +115,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
|
||||
: {}),
|
||||
...(preAllocatedHandle ? { preAllocatedHandle } : {}),
|
||||
command: opts.command,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
cwd,
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
} from '../../orca-runtime-core'
|
||||
import { rendererPublicationThrottle } from '../../../window/renderer-publication-throttle'
|
||||
import { deterministicAgentSessionUuid } from '../../runtime-agent-launch-resolution'
|
||||
import { assertMobileTerminalShellSupported } from './mobile-terminal-shell-refusal'
|
||||
|
||||
export type MobileTerminalCreateOptions = {
|
||||
terminalImageViewerSupported?: boolean
|
||||
afterTabId?: string
|
||||
targetGroupId?: string
|
||||
command?: string
|
||||
shellOverride?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
@@ -99,6 +101,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const worktreeId = workspace.id
|
||||
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
|
||||
assertMobileTerminalShellSupported(this.store, workspace, opts.shellOverride, cwd)
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
// Older mobile clients append their optimistic tab, so make the host append too until the
|
||||
// client advertises the grouped placement contract.
|
||||
@@ -157,6 +160,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
|
||||
afterTabId,
|
||||
{
|
||||
command: startupCommand.command,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
cwd,
|
||||
env: startupCommand.env,
|
||||
envToDelete: startupCommand.envToDelete,
|
||||
@@ -217,6 +221,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
|
||||
afterTabId: afterDesktopTabId,
|
||||
targetGroupId: opts.targetGroupId,
|
||||
command: startupCommand.command,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
cwd,
|
||||
...(startupCommand.env ? { env: startupCommand.env } : {}),
|
||||
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
|
||||
@@ -283,6 +288,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
|
||||
afterTabId,
|
||||
{
|
||||
command: startupCommand.command,
|
||||
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
||||
cwd,
|
||||
env: startupCommand.env,
|
||||
envToDelete: startupCommand.envToDelete,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types'
|
||||
|
||||
const resolveLocalLaunchScopeRuntime = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../../local-project-runtime-resolution', () => ({ resolveLocalLaunchScopeRuntime }))
|
||||
|
||||
const { assertMobileTerminalShellSupported } = await import('./mobile-terminal-shell-refusal')
|
||||
|
||||
const folderScope: TerminalWorkspaceLaunchScope = {
|
||||
id: 'folder:fw-1',
|
||||
path: 'C:\\work\\folder',
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the id is read through the mocked resolver.
|
||||
folderWorkspace: { id: 'fw-1' } as TerminalWorkspaceLaunchScope['folderWorkspace']
|
||||
}
|
||||
|
||||
describe('assertMobileTerminalShellSupported', () => {
|
||||
const hostPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
resolveLocalLaunchScopeRuntime.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', hostPlatform)
|
||||
})
|
||||
|
||||
it('refuses a Windows shell for a folder workspace whose project runs in WSL', () => {
|
||||
resolveLocalLaunchScopeRuntime.mockReturnValue({
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'p-1',
|
||||
distro: 'Ubuntu',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'k'
|
||||
}
|
||||
})
|
||||
const store = {}
|
||||
|
||||
expect(() =>
|
||||
assertMobileTerminalShellSupported(store, folderScope, 'cmd.exe', undefined)
|
||||
).toThrow(/in WSL/)
|
||||
expect(resolveLocalLaunchScopeRuntime).toHaveBeenCalledWith(store, folderScope)
|
||||
})
|
||||
|
||||
it('accepts the shell when the folder runs on the Windows host', () => {
|
||||
resolveLocalLaunchScopeRuntime.mockReturnValue(undefined)
|
||||
|
||||
expect(() =>
|
||||
assertMobileTerminalShellSupported({}, folderScope, 'cmd.exe', undefined)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('skips resolution when no shell was picked', () => {
|
||||
assertMobileTerminalShellSupported({}, folderScope, undefined, undefined)
|
||||
|
||||
expect(resolveLocalLaunchScopeRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
resolveLocalLaunchScopeRuntime,
|
||||
type LaunchScopeRuntimeStore
|
||||
} from '../../../local-project-runtime-resolution'
|
||||
import { terminalShellOverrideRefusal } from '../../terminal-shell-override-host-support'
|
||||
import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types'
|
||||
|
||||
/** Throws before any surface exists when this host cannot run the requested shell. */
|
||||
export function assertMobileTerminalShellSupported(
|
||||
store: LaunchScopeRuntimeStore | null | undefined,
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
shellOverride: string | undefined,
|
||||
cwd: string | undefined
|
||||
): void {
|
||||
if (!shellOverride) {
|
||||
return
|
||||
}
|
||||
const refusal = terminalShellOverrideRefusal({
|
||||
shellOverride,
|
||||
connectionId: workspace.connectionId,
|
||||
platform: process.platform,
|
||||
// Why the launch scope: a folder workspace's runtime comes from its project, not a repo id.
|
||||
projectRuntime: store ? resolveLocalLaunchScopeRuntime(store, workspace) : undefined,
|
||||
cwd: cwd ?? workspace.path,
|
||||
workspacePath: workspace.path
|
||||
})
|
||||
if (refusal) {
|
||||
throw refusal
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function useTabGroupCreationCommands({
|
||||
worktreeId,
|
||||
environmentId,
|
||||
targetGroupId: groupId,
|
||||
command: shellOverride,
|
||||
shell: shellOverride,
|
||||
activate: true
|
||||
})
|
||||
if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('createNewTerminalTab', () => {
|
||||
expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'web-runtime',
|
||||
command: 'pwsh',
|
||||
shell: 'pwsh',
|
||||
activate: true
|
||||
})
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
@@ -158,7 +158,7 @@ describe('createNewTerminalTab', () => {
|
||||
expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
environmentId: 'owner-runtime',
|
||||
command: 'pwsh',
|
||||
shell: 'pwsh',
|
||||
activate: true
|
||||
})
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createNewTerminalTab(
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
command: shellOverride,
|
||||
shell: shellOverride,
|
||||
...(options?.startupCwd ? { cwd: options.startupCwd } : {}),
|
||||
activate: true
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
targetGroupId,
|
||||
command: shellOverride,
|
||||
shell: shellOverride,
|
||||
activate: true
|
||||
})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY } from '../../../shared/session-tabs-runtime-capabilities'
|
||||
|
||||
const supportsCapability = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./runtime-rpc-client', () => ({
|
||||
runtimeEnvironmentSupportsCapability: supportsCapability
|
||||
}))
|
||||
|
||||
const { buildPlainCreateTerminalParams, resolveCreateTerminalShellParams } =
|
||||
await import('./web-runtime-plain-terminal-create-params')
|
||||
|
||||
describe('resolveCreateTerminalShellParams', () => {
|
||||
beforeEach(() => {
|
||||
supportsCapability.mockReset()
|
||||
})
|
||||
|
||||
it('sends the pick as the shell on hosts that advertise shell selection', async () => {
|
||||
supportsCapability.mockResolvedValue(true)
|
||||
|
||||
await expect(resolveCreateTerminalShellParams('env-1', { shell: 'git-bash' })).resolves.toEqual(
|
||||
{ command: undefined, shell: 'git-bash' }
|
||||
)
|
||||
expect(supportsCapability).toHaveBeenCalledWith(
|
||||
'env-1',
|
||||
SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the typed command for hosts that would drop the shell', async () => {
|
||||
supportsCapability.mockResolvedValue(false)
|
||||
|
||||
await expect(resolveCreateTerminalShellParams('env-1', { shell: 'cmd.exe' })).resolves.toEqual({
|
||||
command: 'cmd.exe'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not probe the host when no shell was picked', async () => {
|
||||
await expect(resolveCreateTerminalShellParams('env-1', { command: 'ls' })).resolves.toEqual({
|
||||
command: 'ls'
|
||||
})
|
||||
expect(supportsCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds caller-selected plain terminal params with the shell pick', async () => {
|
||||
supportsCapability.mockResolvedValue(true)
|
||||
|
||||
const params = await buildPlainCreateTerminalParams('env-1', {
|
||||
worktreeId: 'repo-1::/repo',
|
||||
shell: 'cmd.exe'
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ shell: 'cmd.exe', select: true, navigation: 'caller' })
|
||||
expect(params.command).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY } from '../../../shared/session-tabs-runtime-capabilities'
|
||||
import { toHostSessionTabId } from '../../../shared/terminal-surface-id'
|
||||
import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types'
|
||||
|
||||
export type CreateTerminalShellParams = { command?: string; shell?: string }
|
||||
|
||||
/** How a session.tabs.createTerminal call carries the user's shell pick to this host. */
|
||||
export async function resolveCreateTerminalShellParams(
|
||||
environmentId: string,
|
||||
args: { command?: string; shell?: string }
|
||||
): Promise<CreateTerminalShellParams> {
|
||||
if (!args.shell) {
|
||||
return { command: args.command }
|
||||
}
|
||||
if (
|
||||
await runtimeEnvironmentSupportsCapability(
|
||||
environmentId,
|
||||
SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY
|
||||
)
|
||||
) {
|
||||
return { command: args.command, shell: args.shell }
|
||||
}
|
||||
// Why: an older host drops `shell`; typing the pick still reaches it, nested in the default shell.
|
||||
return { command: args.command ?? args.shell }
|
||||
}
|
||||
|
||||
/** session.tabs.createTerminal params for a plain (non-agent) terminal. */
|
||||
export async function buildPlainCreateTerminalParams(
|
||||
environmentId: string,
|
||||
args: CreateWebRuntimeSessionTerminalArgs
|
||||
): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
|
||||
targetGroupId: args.targetGroupId,
|
||||
...(await resolveCreateTerminalShellParams(environmentId, args)),
|
||||
cwd: args.cwd,
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
startupCommandDelivery: args.startupCommandDelivery,
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
...(args.launchToken ? { launchToken: args.launchToken } : {}),
|
||||
...(args.viewMode ? { viewMode: args.viewMode } : {}),
|
||||
// Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus.
|
||||
activate: false,
|
||||
select: args.activate !== false,
|
||||
navigation: 'caller'
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export type CreateWebRuntimeSessionTerminalArgs = {
|
||||
afterTabId?: string
|
||||
targetGroupId?: string
|
||||
command?: string
|
||||
/** The Windows shell the terminal runs as, never typed into the host default shell. */
|
||||
shell?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
readCreatedAgentTerminalIdentity
|
||||
} from './web-runtime-terminal-identity'
|
||||
import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement'
|
||||
import { buildPlainCreateTerminalParams } from './web-runtime-plain-terminal-create-params'
|
||||
|
||||
export async function createWebRuntimeSessionTerminalResult(
|
||||
args: CreateWebRuntimeSessionTerminalArgs
|
||||
@@ -221,23 +222,7 @@ export async function createWebRuntimeSessionTerminalResult(
|
||||
} else {
|
||||
const response = await callEnvironment({
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: {
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
|
||||
targetGroupId: args.targetGroupId,
|
||||
command: args.command,
|
||||
cwd: args.cwd,
|
||||
...(args.env ? { env: args.env } : {}),
|
||||
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
|
||||
startupCommandDelivery: args.startupCommandDelivery,
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
...(args.launchToken ? { launchToken: args.launchToken } : {}),
|
||||
...(args.viewMode ? { viewMode: args.viewMode } : {}),
|
||||
// Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus.
|
||||
activate: false,
|
||||
select: args.activate !== false,
|
||||
navigation: 'caller'
|
||||
},
|
||||
params: await buildPlainCreateTerminalParams(environmentId, args),
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
const created = unwrapRuntimeRpcResult(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-a
|
||||
import type { TuiAgent } from '../tui-agent'
|
||||
import { isTuiAgent } from '../tui-agent-config'
|
||||
import { LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../terminal-quick-command-prompt-limit'
|
||||
import { WindowsShellOverrideParam } from './terminal-unary-params'
|
||||
|
||||
export const WorktreeTabSelector = z.object({
|
||||
worktree: z
|
||||
@@ -143,6 +144,8 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({
|
||||
afterTabId: z.string().optional(),
|
||||
targetGroupId: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
// Why: the shell the terminal IS; a shell sent as `command` runs nested in the host default.
|
||||
shell: WindowsShellOverrideParam.optional(),
|
||||
cwd: z.string().min(1).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
|
||||
@@ -191,6 +194,14 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({
|
||||
message: 'Agent prompt cannot be combined with a startup command'
|
||||
})
|
||||
}
|
||||
// Why: agent launches plan their own shell, which would silently replace the pick.
|
||||
if (value.shell !== undefined && (value.agent || value.launchAgent || value.launchConfig)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shell'],
|
||||
message: 'A shell pick cannot be combined with an agent launch'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const MoveTabBase = {
|
||||
|
||||
@@ -11,6 +11,17 @@ import {
|
||||
import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry'
|
||||
import { OptionalExecutionHostId } from './worktree-params'
|
||||
|
||||
// Why refused at the boundary rather than at spawn: only the host knows the allowlist, and a
|
||||
// relay-side throw reaches the caller as an opaque spawn failure after the round trip.
|
||||
export const WindowsShellOverrideParam = z
|
||||
.string()
|
||||
.refine(isSupportedWindowsShellOverride, {
|
||||
message: `shell must be one of: ${listSupportedWindowsShellOverrides().join(', ')}`
|
||||
})
|
||||
// Why here: the host is authoritative, so it canonicalizes even when a client did not; the
|
||||
// spawn path exact-matches `.exe` spellings and must never see `cmd` or `Git-Bash`.
|
||||
.transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell)
|
||||
|
||||
export const TerminalHandle = z.object({
|
||||
terminal: requiredString('Missing terminal handle'),
|
||||
// Additive fence understood by newer hosts; legacy hosts safely ignore it.
|
||||
@@ -216,17 +227,7 @@ export const TerminalCreateParams = z.object({
|
||||
tabId: OptionalString,
|
||||
leafId: OptionalString,
|
||||
spawnIntent: SpawnIntentParam.optional(),
|
||||
// Why refused at the boundary rather than at spawn: only the host knows the allowlist, and a
|
||||
// relay-side throw reaches the caller as an opaque spawn failure after the round trip.
|
||||
shell: z
|
||||
.string()
|
||||
.refine(isSupportedWindowsShellOverride, {
|
||||
message: `shell must be one of: ${listSupportedWindowsShellOverrides().join(', ')}`
|
||||
})
|
||||
// Why here: the host is authoritative, so it canonicalizes even when a client did not; the
|
||||
// spawn path exact-matches `.exe` spellings and must never see `cmd` or `Git-Bash`.
|
||||
.transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell)
|
||||
.optional()
|
||||
shell: WindowsShellOverrideParam.optional()
|
||||
})
|
||||
|
||||
export const TerminalSplit = TerminalHandle.extend({
|
||||
|
||||
@@ -7,7 +7,13 @@ export const SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY =
|
||||
export const SESSION_TABS_EMPTY_WORKTREE_ANSWER_RUNTIME_CAPABILITY =
|
||||
'session-tabs.empty-worktree-answer.v1' as const
|
||||
|
||||
// Why: an older host strips session.tabs.createTerminal's unknown `shell` and spawns its default
|
||||
// shell, so a client sends the pick there only when advertised.
|
||||
export const SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY =
|
||||
'session-tabs.create-terminal-shell.v1' as const
|
||||
|
||||
export const SESSION_TABS_RUNTIME_CAPABILITIES = [
|
||||
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_EMPTY_WORKTREE_ANSWER_RUNTIME_CAPABILITY
|
||||
SESSION_TABS_EMPTY_WORKTREE_ANSWER_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_CREATE_TERMINAL_SHELL_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user