From 76e16105e233b4bead5965b67e899eb0edddf887 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:17:18 -0700 Subject: [PATCH] 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:'` 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. --- ...ca-runtime-agent-session-operation.test.ts | 35 ++++ .../orca-runtime-create-agent-session.ts | 7 +- ...e-get-agent-session-execution-namespace.ts | 5 +- ...resolve-mobile-session-terminal-command.ts | 3 +- ...runtime-resolve-worktree-removal-target.ts | 5 +- .../runtime-worktree-agent-startup.test.ts | 111 +++++++++++- .../runtime/runtime-worktree-agent-startup.ts | 8 +- ...ent-background-session-launch-host.test.ts | 74 ++++++++ .../agent-background-session-launch-host.ts | 11 +- ...h-agent-in-new-tab-host-resolution.test.ts | 167 ++++++++++++++++++ .../src/lib/launch-agent-in-new-tab.ts | 17 +- src/shared/agent-launch-remote.test.ts | 33 ++++ src/shared/agent-launch-remote.ts | 31 +++- 13 files changed, 475 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts create mode 100644 src/shared/agent-launch-remote.test.ts diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index ac64dfaa6b7..e99560c7b3a 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -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 + } + 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 { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index db2b71a0adc..2ac34f4a920 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 7172331a551..afbb70fc286 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index ab63b16ee53..db30b7e2d11 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 58fd6231fc7..0d934548332 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -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, diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index e276595c4dd..87fcfd9dd58 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -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 { + 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 () => { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 7c662d633e2..8663771e998 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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 = [] diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts index 86fd8577fae..ef00efa5145 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.test.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts @@ -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({ diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts index 7919f9f3af5..300dec7f6ff 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -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 @@ -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) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts new file mode 100644 index 00000000000..bcb09c1b17c --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts @@ -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, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + 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(), + transientClearedAgentStatusConnectionIds: {} as Record, + worktreesByRepo: {} as Record, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1'] ?? []), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record< + string, + { activeLeafId: string | null; ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + 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 { + 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'") + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index b6cbbb736d8..bf4eda09890 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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 = { diff --git a/src/shared/agent-launch-remote.test.ts b/src/shared/agent-launch-remote.test.ts new file mode 100644 index 00000000000..4656dab39fc --- /dev/null +++ b/src/shared/agent-launch-remote.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-launch-remote.ts b/src/shared/agent-launch-remote.ts index 08482815859..bec5aae49b9 100644 --- a/src/shared/agent-launch-remote.ts +++ b/src/shared/agent-launch-remote.ts @@ -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:'` 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:` 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): boolean { + return getRepoSshConnectionId(repo) !== null }