mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user