fix(ssh): decide remote-vs-local from the resolved execution host, not a raw field

`repoIsRemote` read `repo.connectionId` directly. That is one of four spellings
of host ownership, so the predicate was wrong in both directions: a row carrying
only `executionHostId: 'ssh:<target>'` read as local and got the Linux-only
`orca-ide` rename it cannot resolve through the relay shim, while a row that
declares itself `local` with a stale `connectionId` read as remote and lost the
rename it needs on a Linux desktop.

The predicate now resolves the host first and asks "does an SSH target hold this
row's files" via `getRepoSshConnectionId`. That keeps a `runtime:` host's nested
SSH target remote (that machine reaches the files through its own relay shim)
while a runtime with no nested target - a full Orca install - stays local, as do
WSL and local.

Its call sites did not all want that question:

- The four launch-scope sites in main already hold the resolved PTY route on
  `TerminalWorkspaceLaunchScope.connectionId`. `scope.repo` is documented display
  metadata and can be a row from a different host than the worktree names, so
  they now read the route they will actually spawn on. A launch shape that
  disagrees with its own route is the bug, not a second predicate.
- `launchAgentInNewTab` picked its repo row with a host-blind
  `store.repos.find`, so a worktree that names its own host could be shaped by
  another host's row. It now resolves through `getConnectionIdFromState`, the
  same rule the file already used for transcript readability.
- `resolveAgentBackgroundLaunchHost` derived the route, the trust write and the
  launch shape from three reads of the raw field; one resolution now feeds all
  three.

Also converts the raw `repo.connectionId` agent-detection probe eight lines above
`buildWorktreeStartupForDraft`'s launch shape, which #17919 deferred precisely
because converting it alone would have left that file internally inconsistent.

Tests cover two distinct SSH hosts (a single-host fixture passes even when the
answer comes off the wrong row, which is how the `ssh:m4air` -> openclaw leak
survived review) and a `runtime:` host carrying a nested SSH target.
This commit is contained in:
Neil
2026-09-02 17:17:29 -07:00
parent 36e139ed19
commit 76e16105e2
13 changed files with 475 additions and 32 deletions
@@ -149,6 +149,41 @@ describe('agent-session create operation ledger', () => {
expect(createTerminal).toHaveBeenCalledOnce()
})
it('shapes the launch for the route it resolved, not a repo row on another host', async () => {
// `scope.repo` is display metadata and can be a row from a different host than the worktree
// names (#11163). Reading it made a locally-routed launch emit the SSH relay shim name.
const runtime = createRuntime({
supportsAgentSessionClaims: () => true,
supportsAgentSessionCreateOperations: () => true
})
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: ReturnType<typeof vi.fn>
}
internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({
id: 'worktree-1',
path: '/repo/worktree-1',
connectionId: null,
// The rival row names openclaw while the worktree resolved to no SSH route at all.
repo: {
id: 'repo-1',
connectionId: 'openclaw',
executionHostId: null,
path: '/srv/openclaw'
},
folderWorkspace: null
})
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
await runtime.createAgentSession(
request(operationId(), { agent: 'claude-agent-teams', prompt: '' })
)
expect(createTerminal).toHaveBeenCalledWith(
'id:worktree-1',
expect.objectContaining({ command: expect.stringContaining('orca-ide claude-teams') })
)
})
it('requests exact client legacy fallback before nested SSH side effects', async () => {
const runtime = createRuntime()
const internal = runtime as unknown as {
@@ -16,7 +16,6 @@ import {
AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT
} from './orca-runtime-core'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import {
resolveTuiAgentLaunchArgs,
@@ -152,9 +151,9 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo
? repoIsRemote(workspace.repo)
: Boolean(workspace.connectionId)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -11,7 +11,6 @@ import type {
} from '../../shared/agent-session-host-authority'
import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
import {
@@ -123,7 +122,9 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim
throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -5,7 +5,6 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import {
@@ -54,7 +53,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt
// Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell.
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
// Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply.
const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace)
const isRemote = Boolean(workspace.connectionId)
const queuedShell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -14,7 +14,6 @@ import type { ForceDeleteWorktreeBranchResult } from '../../shared/worktree/crea
import type { RuntimeTerminalRename } from '../../shared/runtime-types'
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
@@ -175,7 +174,9 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
const settings = store.getSettings()
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const queuedShell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
@@ -1,14 +1,119 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
const mocks = vi.hoisted(() => ({
markCodexProjectTrusted: vi.fn(),
markCopilotFolderTrusted: vi.fn(),
markCursorWorkspaceTrusted: vi.fn()
markCursorWorkspaceTrusted: vi.fn(),
detectRemoteAgents: vi.fn(),
detectInstalledAgentsWithShellPathHydration: vi.fn()
}))
vi.mock('../agent-trust-presets', () => mocks)
vi.mock('../agent-trust-presets', () => ({
markCodexProjectTrusted: mocks.markCodexProjectTrusted,
markCopilotFolderTrusted: mocks.markCopilotFolderTrusted,
markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted
}))
import { markLocalWorktreeTrusted } from './runtime-worktree-agent-startup'
vi.mock('../preflight/agent-detection', () => ({
detectRemoteAgents: mocks.detectRemoteAgents,
detectInstalledAgentsWithShellPathHydration: mocks.detectInstalledAgentsWithShellPathHydration
}))
import {
buildWorktreeStartupForAgent,
buildWorktreeStartupForDraft,
markLocalWorktreeTrusted
} from './runtime-worktree-agent-startup'
function makeRepo(fields: Partial<Repo>): Repo {
return {
id: 'repo-1',
name: 'repo',
path: '/srv/repo',
connectionId: null,
executionHostId: null,
...fields
} as Repo
}
const settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
disabledTuiAgents: [],
defaultTuiAgent: undefined,
terminalWindowsShell: null
} as never
/** The launched CLI name is the whole decision: `orca` is the relay shim, `orca-ide` is local. */
function launchCliNameFor(repo: Repo): string {
return buildWorktreeStartupForAgent({
repo,
settings,
agent: 'claude-agent-teams',
getLaunchPlatform: () => 'linux',
toSessionOptions: () => undefined
}).startup.command.split(' ')[0]!
}
describe('buildWorktreeStartupForAgent host resolution', () => {
// Why two hosts: one SSH fixture passes even when the launch shape is resolved off another
// host's row, which is the shape of the `ssh:m4air` -> openclaw leak.
it('drops the Linux-only rename for both spellings of SSH ownership on two hosts', () => {
expect(launchCliNameFor(makeRepo({ connectionId: 'm4air' }))).toBe('orca')
expect(launchCliNameFor(makeRepo({ executionHostId: 'ssh:openclaw' }))).toBe('orca')
})
it('keeps the Linux rename for a local row carrying a stale connection', () => {
expect(launchCliNameFor(makeRepo({ connectionId: 'm4air', executionHostId: 'local' }))).toBe(
'orca-ide'
)
})
it('drops the rename for a runtime host reaching a nested SSH target', () => {
expect(
launchCliNameFor(makeRepo({ connectionId: 'nested', executionHostId: 'runtime:vm-1' }))
).toBe('orca')
})
it('keeps the rename for a runtime host with no nested SSH target', () => {
expect(launchCliNameFor(makeRepo({ executionHostId: 'runtime:vm-1' }))).toBe('orca-ide')
})
})
describe('buildWorktreeStartupForDraft agent detection', () => {
it('probes the SSH host named only by executionHostId instead of this client', async () => {
mocks.detectRemoteAgents.mockResolvedValueOnce(['claude'])
mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValue([])
const result = await buildWorktreeStartupForDraft({
repo: makeRepo({ executionHostId: 'ssh:openclaw' }),
settings,
draft: 'ship it',
getLaunchPlatform: () => 'linux'
})
expect(mocks.detectRemoteAgents).toHaveBeenCalledWith({ connectionId: 'openclaw' })
expect(mocks.detectInstalledAgentsWithShellPathHydration).not.toHaveBeenCalled()
expect(result?.agent).toBe('claude')
})
it('probes this client for a local row carrying a stale connection', async () => {
mocks.detectRemoteAgents.mockClear()
mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValueOnce(['claude'])
const result = await buildWorktreeStartupForDraft({
repo: makeRepo({ connectionId: 'm4air', executionHostId: 'local' }),
settings,
draft: 'ship it',
getLaunchPlatform: () => 'linux'
})
expect(mocks.detectRemoteAgents).not.toHaveBeenCalled()
expect(result?.agent).toBe('claude')
})
})
describe('markLocalWorktreeTrusted', () => {
it('waits for the Codex trust write before resolving', async () => {
@@ -3,6 +3,7 @@ import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { getRepoSshConnectionId } from '../../shared/execution-host'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection'
import {
@@ -55,10 +56,13 @@ export async function buildWorktreeStartupForDraft(
: null
if (!agent) {
let detected: string[] = []
// Why: detection has to run on the machine that will run the agent, and SSH ownership has two
// spellings — the raw field probes this client for an `executionHostId: 'ssh:*'`-only repo.
const sshConnectionId = getRepoSshConnectionId(repo)
try {
// Why: startup-draft fallback can run from sparse runtime launch envs too.
detected = repo.connectionId
? await detectRemoteAgents({ connectionId: repo.connectionId })
detected = sshConnectionId
? await detectRemoteAgents({ connectionId: sshConnectionId })
: await detectInstalledAgentsWithShellPathHydration()
} catch {
detected = []
@@ -71,6 +71,80 @@ describe('resolveAgentBackgroundLaunchHost', () => {
).toThrow('unavailable or ambiguous')
})
// Why two hosts: a single-SSH fixture passes even when the route is read off another host's
// row, which is the shape of the `ssh:m4air` -> openclaw leak.
it('routes both spellings of SSH ownership to their own host', () => {
const legacy = resolveAgentBackgroundLaunchHost({
store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never,
worktreeId: 'repo-1::/srv/repo',
worktreePath: '/srv/repo',
repo: {
id: 'repo-1',
connectionId: 'm4air',
executionHostId: null,
path: '/srv/repo'
} as never
})
const unified = resolveAgentBackgroundLaunchHost({
store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never,
worktreeId: 'repo-1::/srv/repo',
worktreePath: '/srv/repo',
repo: {
id: 'repo-1',
connectionId: null,
executionHostId: 'ssh:openclaw',
path: '/srv/repo'
} as never
})
expect(legacy).toMatchObject({
connectionId: 'm4air',
isRemote: true,
expectedConnectionId: 'm4air'
})
expect(unified).toMatchObject({
connectionId: 'openclaw',
isRemote: true,
expectedConnectionId: 'openclaw'
})
})
it('keeps a local row with a stale connection off the SSH route', () => {
const host = resolveAgentBackgroundLaunchHost({
store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never,
worktreeId: 'repo-1::/srv/repo',
worktreePath: '/srv/repo',
repo: {
id: 'repo-1',
connectionId: 'm4air',
executionHostId: 'local',
path: '/srv/repo'
} as never
})
expect(host).toMatchObject({
connectionId: null,
isRemote: false,
expectedConnectionId: null
})
})
it('keeps a runtime host reaching a nested SSH target remote', () => {
const host = resolveAgentBackgroundLaunchHost({
store: makeFolderHostState({ connectionId: null, folderPath: '/project' }) as never,
worktreeId: 'repo-1::/srv/repo',
worktreePath: '/srv/repo',
repo: {
id: 'repo-1',
connectionId: 'nested',
executionHostId: 'runtime:vm-1',
path: '/srv/repo'
} as never
})
expect(host).toMatchObject({ connectionId: 'nested', isRemote: true })
})
it('uses Linux startup quoting for a local WSL folder', () => {
const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\project'
const host = resolveAgentBackgroundLaunchHost({
@@ -6,6 +6,7 @@ import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connectio
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
import { getRepoSshConnectionId } from '../../../shared/execution-host'
import { isWslUncPath } from '../../../shared/wsl-paths'
type LaunchStore = ReturnType<typeof useAppStore.getState>
@@ -41,14 +42,18 @@ export function resolveAgentBackgroundLaunchHost(args: {
}): AgentBackgroundLaunchHost {
const { store, worktreeId, worktreePath, repo } = args
if (repo) {
// Why: SSH ownership has two spellings, so the raw field spawns an `executionHostId: 'ssh:*'`-only
// repo on the client with a remote path. One resolution feeds the route, the trust write and the
// launch shape, which must not disagree about the host.
const sshConnectionId = getRepoSshConnectionId(repo)
return {
connectionId: repo.connectionId ?? null,
connectionId: sshConnectionId,
platform: getAgentLaunchPlatformForRepo(
repo,
repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
sshConnectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
),
isRemote: repoIsRemote(repo),
expectedConnectionId: repo.connectionId ?? null
expectedConnectionId: sshConnectionId
}
}
const folderWorkspaceConnectionId = resolveFolderWorkspaceConnectionIdForLaunch(store, worktreeId)
@@ -0,0 +1,167 @@
// Execution-host coverage for launchAgentInNewTab, split from launch-agent-in-new-tab.test.ts to
// keep both files within the lines budget.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCreateTab = vi.fn()
const mockQueueTabStartupCommand = vi.fn()
type StoreRepo = {
id: string
connectionId: string | null
executionHostId?: string | null
path: string
}
type StoreWorktree = {
id: string
repoId: string
projectId: string
hostId?: string | null
path: string
displayName: string
}
const store = {
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
settings: {
agentCmdOverrides: {} as Record<string, string>,
agentDefaultArgs: {} as Record<string, string>,
agentDefaultEnv: {} as Record<string, Record<string, string>>,
activeRuntimeEnvironmentId: null as string | null
},
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }],
repos: [] as StoreRepo[],
folderWorkspaces: [] as unknown[],
projectGroups: [] as unknown[],
sshConnectionStates: new Map<string, { status: string }>(),
transientClearedAgentStatusConnectionIds: {} as Record<string, true>,
worktreesByRepo: {} as Record<string, StoreWorktree[]>,
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1'] ?? []),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>,
terminalLayoutsByTabId: {} as Record<
string,
{ activeLeafId: string | null; ptyIdsByLeafId?: Record<string, string> }
>,
ptyIdsByTabId: {} as Record<string, string[]>,
createTab: mockCreateTab,
closeTab: vi.fn(),
queueTabStartupCommand: mockQueueTabStartupCommand,
setActiveTabType: vi.fn(),
setTabBarOrder: vi.fn(),
setAgentStatus: vi.fn(),
seedNativeChatLaunchPrompt: vi.fn(),
seedNativeChatLaunchDraft: vi.fn(),
markNativeChatLaunchPromptFailed: vi.fn()
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/tab-bar/reconcile-order', () => ({
reconcileTabOrder: vi.fn(
(_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [
...termIds,
...editorIds,
...browserIds
]
)
}))
vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn() }))
vi.mock('@/lib/telemetry', () => ({
track: vi.fn(),
tuiAgentToAgentKind: (agent: string) => agent
}))
vi.mock('@/runtime/web-runtime-session', () => ({
createWebRuntimeSessionTerminal: vi.fn(),
isWebRuntimeSessionActive: vi.fn(() => false),
isWebTerminalSurfaceTabId: vi.fn(() => false)
}))
function worktreeOn(hostId: string, path: string): StoreWorktree {
return { id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', hostId, path, displayName: 'main' }
}
async function launchOnLinux(): Promise<void> {
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
launchAgentInNewTab({ agent: 'claude-agent-teams', worktreeId: 'wt-1', launchPlatform: 'linux' })
}
function queuedCommand(): string {
return mockQueueTabStartupCommand.mock.calls[0]?.[1]?.command
}
describe('launchAgentInNewTab execution host resolution', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreateTab.mockReturnValue({ id: 'tab-1' })
store.settings = {
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {},
activeRuntimeEnvironmentId: null
}
store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] }
store.openFiles = []
store.browserTabsByWorktree = {}
store.tabBarOrderByWorktree = {}
store.terminalLayoutsByTabId = {}
store.ptyIdsByTabId = {}
})
it('shapes the launch from the worktree host, not a rival repo row on another SSH host', async () => {
// `store.repos.find` is host-blind, so a worktree that names its own host could be shaped by
// an `ssh:openclaw` row it has nothing to do with (#11163).
store.repos = [
{ id: 'repo-1', connectionId: 'openclaw', path: '/srv/openclaw' },
{ id: 'repo-1', connectionId: null, executionHostId: 'local', path: '/repo' }
]
store.worktreesByRepo = { 'repo-1': [worktreeOn('local', '/repo/worktree')] }
await launchOnLinux()
expect(queuedCommand()).toBe("orca-ide claude-teams '--dangerously-skip-permissions'")
})
it('keeps a worktree on one SSH host remote while a rival row names another', async () => {
store.repos = [
{ id: 'repo-1', connectionId: 'openclaw', path: '/srv/openclaw' },
{ id: 'repo-1', connectionId: null, executionHostId: 'ssh:m4air', path: '/srv/m4air' }
]
store.worktreesByRepo = { 'repo-1': [worktreeOn('ssh:m4air', '/srv/m4air/worktree')] }
await launchOnLinux()
expect(queuedCommand()).toBe("orca claude-teams '--dangerously-skip-permissions'")
})
it('keeps a runtime host reaching a nested SSH target on the relay shim name', async () => {
store.repos = [
{ id: 'repo-1', connectionId: 'nested', executionHostId: 'runtime:vm-1', path: '/srv/vm' }
]
store.worktreesByRepo = { 'repo-1': [worktreeOn('runtime:vm-1', '/srv/vm/worktree')] }
await launchOnLinux()
expect(queuedCommand()).toBe("orca claude-teams '--dangerously-skip-permissions'")
})
it('keeps a runtime host with no nested SSH target on the local CLI name', async () => {
store.repos = [
{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:vm-1', path: '/srv/vm' }
]
store.worktreesByRepo = { 'repo-1': [worktreeOn('runtime:vm-1', '/srv/vm/worktree')] }
await launchOnLinux()
expect(queuedCommand()).toBe("orca-ide claude-teams '--dangerously-skip-permissions'")
})
})
@@ -22,7 +22,6 @@ import {
} from '../../../shared/tui-agent-launch-defaults'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { LaunchSource } from '../../../shared/telemetry-events'
@@ -96,16 +95,23 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
const store = useAppStore.getState()
const worktree = store.allWorktrees?.().find((entry: { id: string }) => entry.id === worktreeId)
const repo = worktree ? store.repos?.find((entry) => entry.id === worktree.repoId) : null
// Why: `store.repos.find` is host-blind and the same repo id can exist on local, SSH and runtime
// hosts, so the row it returns can belong to a different host than the worktree names (#11163).
// The shared resolver answers from the worktree's own host; `undefined` (rival rows disagree) is
// not evidence of a remote, and main rejects that launch anyway.
const worktreeSshConnectionId = getConnectionIdFromState(store, worktreeId)
const resolvedLaunchPlatform =
launchPlatform ??
(repo
? getAgentLaunchPlatformForRepo(
repo,
repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId)
worktreeSshConnectionId
? undefined
: getLocalProjectExecutionRuntimeContext(store, worktreeId)
)
: CLIENT_PLATFORM)
// Why: SSH remotes deploy the shim as plain `orca`, so skip the Linux-only `orca-ide` rename for remote launches.
const isRemote = repo ? repoIsRemote(repo) : false
const isRemote = Boolean(worktreeSshConnectionId)
const queuedShell = resolveLocalWindowsAgentStartupShell({
platform: resolvedLaunchPlatform,
isRemote,
@@ -127,9 +133,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
agent,
promptDelivery: viewModePromptDelivery,
launchDraftText: trimmedPrompt,
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionIdFromState(store, worktreeId)
)
nativeChatTranscriptIsLocalReadable:
isNativeChatTranscriptLocalReadable(worktreeSshConnectionId)
}
const initialViewModeProps = initialAgentTabViewModeProps(store.settings, initialViewModeOptions)
const startupPlanBase = {
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { repoIsRemote } from './agent-launch-remote'
describe('repoIsRemote', () => {
it('reads both spellings of SSH ownership on two different hosts', () => {
// Why two hosts: a single-host fixture passes even when the predicate answers from the wrong
// row, which is how the `ssh:m4air` -> openclaw leak survived review.
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: null })).toBe(true)
expect(repoIsRemote({ connectionId: null, executionHostId: 'ssh:openclaw' })).toBe(true)
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: 'ssh:m4air' })).toBe(true)
})
it('answers local for a row that declares itself local with a stale connection', () => {
expect(repoIsRemote({ connectionId: 'm4air', executionHostId: 'local' })).toBe(false)
})
it('keeps a runtime host with a nested SSH target remote', () => {
expect(repoIsRemote({ connectionId: 'nested-target', executionHostId: 'runtime:vm-1' })).toBe(
true
)
})
it('keeps a runtime host with no nested SSH target local-shaped', () => {
// A runtime with no nested target is a full Orca install, not a relay shim, so it keeps the
// platform CLI name.
expect(repoIsRemote({ connectionId: null, executionHostId: 'runtime:vm-1' })).toBe(false)
})
it('keeps plain local and WSL rows local', () => {
expect(repoIsRemote({ connectionId: null, executionHostId: null })).toBe(false)
expect(repoIsRemote({ connectionId: null, executionHostId: 'local' })).toBe(false)
})
})
+23 -8
View File
@@ -1,11 +1,26 @@
import type { Repo } from './repo-types'
import { getRepoSshConnectionId } from './execution-host'
/**
* Why: a repo reached over SSH runs the Orca CLI through the relay shim, which
* is always deployed as plain `orca` (Unix) / `orca.cmd` (Windows). The
* Linux-only `orca-ide` rename — which exists solely to avoid shadowing the
* GNOME Orca screen reader on a local desktop — must not be applied to those
* remotes, or `orca-ide claude-teams` lands on a PATH where it does not exist.
* `connectionId` is the SSH signal; WSL and local stay false.
* Why: a repo reached over SSH runs the Orca CLI through the relay shim, which is always deployed
* as plain `orca` (Unix) / `orca.cmd` (Windows). The Linux-only `orca-ide` rename — which exists
* solely to avoid shadowing the GNOME Orca screen reader on a local desktop — must not be applied
* to those remotes, or `orca-ide claude-teams` lands on a PATH where it does not exist.
*
* The question is "does an SSH target hold this row's files", not "what may this client dial", so
* it resolves the execution host instead of reading the raw `connectionId` field. SSH ownership has
* two spellings and the raw read is wrong in both directions:
*
* - a row carrying only `executionHostId: 'ssh:<target>'` reads as local and gets the `orca-ide`
* rename it cannot resolve on the remote;
* - a row that declares itself `local` while a stale `connectionId` survives reads as remote and
* loses the rename it needs on a Linux desktop.
*
* `runtime:<env>` keeps its nested SSH target (that machine reaches the files through its own relay
* shim), while a runtime host with no nested target is a full Orca install and stays false — as do
* WSL and local. Callers routing a client-local PTY want `getSshTargetIdForExecutionHost` instead;
* callers that already hold a resolved launch connection should read that, not re-derive here.
*/
export function repoIsRemote(repo: { connectionId?: string | null }): boolean {
return Boolean(repo.connectionId)
export function repoIsRemote(repo: Pick<Repo, 'connectionId' | 'executionHostId'>): boolean {
return getRepoSshConnectionId(repo) !== null
}