Files
orca/src/main/runtime/runtime-worktree-agent-startup.test.ts
Neil d084a2a36a fix(ssh): decide remote-vs-local from the resolved execution host, not a raw field (#18294)
`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.
2026-09-02 17:46:01 -07:00

144 lines
4.7 KiB
TypeScript

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(),
detectRemoteAgents: vi.fn(),
detectInstalledAgentsWithShellPathHydration: vi.fn()
}))
vi.mock('../agent-trust-presets', () => ({
markCodexProjectTrusted: mocks.markCodexProjectTrusted,
markCopilotFolderTrusted: mocks.markCopilotFolderTrusted,
markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted
}))
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 () => {
let finish!: () => void
mocks.markCodexProjectTrusted.mockReturnValue(
new Promise<void>((resolve) => {
finish = resolve
})
)
let settled = false
const marking = markLocalWorktreeTrusted('codex', '/workspace/app').then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
finish()
await marking
expect(mocks.markCodexProjectTrusted).toHaveBeenCalledWith('/workspace/app')
})
it('contains a rejected Codex trust write', async () => {
mocks.markCodexProjectTrusted.mockRejectedValueOnce(new Error('write failed'))
await expect(markLocalWorktreeTrusted('codex', '/workspace/app')).resolves.toBeUndefined()
})
})