fix(ssh): resolve the execution host in the worktree scan and managed create

The worktree scan and createManagedWorktree both picked remote-vs-local from
repo.connectionId, so a row stamped only executionHostId: 'ssh:*' was scanned
and created on the client against a remote path. The folder branch returns
before the check, so its agent-trust write landed locally too.

Refs #11163
This commit is contained in:
Neil
2026-09-02 16:20:33 -07:00
parent 05bc9aab88
commit cbdaeebf70
5 changed files with 412 additions and 20 deletions
@@ -0,0 +1,155 @@
// createManagedWorktree used to pick remote-vs-local from the raw `connectionId` field, so a repo
// stamped only `executionHostId: 'ssh:*'` ran `git worktree add` on the client against a remote
// path — and the folder branch, which returns before that check, wrote agent trust locally for a
// remote workspace. Both are the #11163 shape: read the execution host, never one spelling of it.
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
const createRuntimeFolderWorktreeMock = vi.hoisted(() => vi.fn())
vi.mock('./runtime-folder-worktree-create', () => ({
createRuntimeFolderWorktree: createRuntimeFolderWorktreeMock
}))
const createRuntimeLocalManagedWorktreeMock = vi.hoisted(() => vi.fn())
vi.mock('./runtime-local-worktree-create', () => ({
createRuntimeLocalManagedWorktree: createRuntimeLocalManagedWorktreeMock
}))
const trustMocks = vi.hoisted(() => ({
local: vi.fn(async () => {}),
remote: vi.fn(async () => {})
}))
vi.mock('./runtime-worktree-agent-startup', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
markLocalWorktreeTrusted: trustMocks.local,
markRemoteWorktreeTrusted: trustMocks.remote
}))
import { OrcaRuntimeService } from './orca-runtime'
const TARGET_ID = 'remote-1'
const REMOTE_PATH = '/srv/app'
type RuntimeInternals = {
resolveRepoSelector: (selector: string) => Promise<unknown>
createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise<unknown>
resolveLineageForWorktreeCreate: (input: unknown) => Promise<unknown>
recordCreatedWorktreeLineage: (worktree: unknown, resolution: unknown) => unknown
}
function makeRuntime(repo: Record<string, unknown>): {
runtime: OrcaRuntimeService
createRemote: ReturnType<typeof vi.fn>
} {
const store = {
getSettings: () => ({ disabledTuiAgents: [], workspaceDir: '/tmp/workspaces' }),
getProjectHostSetups: () => []
}
const runtime = new OrcaRuntimeService(store as never)
const internals = runtime as unknown as RuntimeInternals
vi.spyOn(internals, 'resolveRepoSelector').mockResolvedValue(repo)
vi.spyOn(internals, 'resolveLineageForWorktreeCreate').mockResolvedValue(null)
vi.spyOn(internals, 'recordCreatedWorktreeLineage').mockReturnValue({
lineage: null,
workspaceLineage: null,
warnings: []
})
const createRemote = vi.fn().mockResolvedValue({
worktree: { id: 'wt-1', path: '/srv/app-feature', branch: 'feature' }
})
vi.spyOn(internals, 'createManagedRemoteWorktree').mockImplementation(createRemote)
return { runtime, createRemote }
}
describe('createManagedWorktree execution-host routing', () => {
beforeEach(() => {
createRuntimeFolderWorktreeMock.mockReset()
createRuntimeFolderWorktreeMock.mockResolvedValue({ worktree: { id: 'folder-1' } })
createRuntimeLocalManagedWorktreeMock.mockReset()
// Name the defect in the failure output: reaching this mock means a remote repo was routed
// into a client-side `git worktree add`.
createRuntimeLocalManagedWorktreeMock.mockRejectedValue(
new Error('local_worktree_create_ran_for_remote_repo')
)
trustMocks.local.mockClear()
trustMocks.remote.mockClear()
})
it('creates on the SSH host for a repo stamped executionHostId only', async () => {
const { runtime, createRemote } = makeRuntime({
id: 'repo-remote',
path: REMOTE_PATH,
kind: 'git',
executionHostId: `ssh:${TARGET_ID}`
})
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
// A local `git worktree add` against a remote path is the silent-substitution failure.
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
expect(createRemote).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-remote', connectionId: TARGET_ID }),
expect.anything()
)
})
it('still creates on the SSH host for a legacy connectionId-only repo', async () => {
const { runtime, createRemote } = makeRuntime({
id: 'repo-remote',
path: REMOTE_PATH,
kind: 'git',
connectionId: TARGET_ID
})
await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'feature' } as never)
expect(createRuntimeLocalManagedWorktreeMock).not.toHaveBeenCalled()
expect(createRemote).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: TARGET_ID }),
expect.anything()
)
})
it('marks a folder workspace trusted on its SSH host, not on the client', async () => {
const { runtime } = makeRuntime({
id: 'repo-folder',
path: REMOTE_PATH,
kind: 'folder',
connectionId: TARGET_ID,
executionHostId: `ssh:${TARGET_ID}`
})
await runtime.createManagedWorktree({ repoSelector: 'repo-folder', name: 'notes' } as never)
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
await deps.markTrusted('codex', '/srv/app')
expect(trustMocks.remote).toHaveBeenCalledWith('codex', TARGET_ID, '/srv/app')
expect(trustMocks.local).not.toHaveBeenCalled()
})
it('keeps a local folder workspace trusted on the client', async () => {
const { runtime } = makeRuntime({
id: 'repo-folder-local',
path: '/Users/me/notes',
kind: 'folder'
})
await runtime.createManagedWorktree({
repoSelector: 'repo-folder-local',
name: 'notes'
} as never)
const deps = createRuntimeFolderWorktreeMock.mock.calls[0]?.[0]?.deps
await deps.markTrusted('codex', '/Users/me/notes')
expect(trustMocks.local).toHaveBeenCalledWith('codex', '/Users/me/notes')
expect(trustMocks.remote).not.toHaveBeenCalled()
})
})
@@ -4,6 +4,7 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { isFolderRepo } from '../../shared/repo-kind'
import { getRepoSshConnectionId } from '../../shared/execution-host'
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
@@ -56,7 +57,13 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
draftStartup?.agent ??
(requestedAgentEnabled ? requestedAgent : undefined))
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
// Resolve the execution host once: SSH ownership has two spellings, and reading the raw
// `connectionId` field routes an `executionHostId: 'ssh:*'`-only repo down the local path,
// which runs `git worktree add` on the client against a remote path.
const sshConnectionId = getRepoSshConnectionId(repo)
if (isFolderRepo(repo)) {
// A folder workspace is a registration, not a filesystem create, so it is host-agnostic —
// except for the agent trust write, which must land on the host that will run the agent.
return createRuntimeFolderWorktree({
request: args,
repo,
@@ -68,7 +75,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
store: this.store,
ptySpawnAvailable: Boolean(this.ptyController?.spawn),
createTerminal: (selector, options) => this.createTerminal(selector, options),
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
markTrusted: (agent, path) =>
this.markWorkspaceTrustedForAgent(agent, sshConnectionId, path),
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
@@ -89,15 +97,20 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
const lineageInput =
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
if (repo.connectionId) {
const result = await this.createManagedRemoteWorktree(repo, {
...args,
activate: args.activate,
...(effectiveStartup ? { startup: effectiveStartup } : {}),
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
})
if (sshConnectionId) {
// Why normalize the row: the remote-create pipeline reads `repo.connectionId!` at every
// depth, so hand it the connection the resolved host actually names.
const result = await this.createManagedRemoteWorktree(
{ ...repo, connectionId: sshConnectionId },
{
...args,
activate: args.activate,
...(effectiveStartup ? { startup: effectiveStartup } : {}),
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
}
)
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
this.emitWorktreeLifecycle({
kind: 'created',
@@ -19,7 +19,7 @@ import type { Repo } from '../../shared/repo-types'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host'
import type { RuntimeWorktreeScanCache } from './orca-runtime-core'
import { resolveWorktreeScanCacheTtlMs } from './runtime-worktree-scan-cache'
@@ -135,17 +135,21 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends
repo: Repo,
projectRuntimeByRepoId?: ReadonlyMap<string, ProjectExecutionRuntimeResolution>
): Promise<RuntimeWorktreeScanResult> {
// Resolve the execution host, not the raw field: an `executionHostId: 'ssh:*'` row with no
// `connectionId` would otherwise get a local project runtime and a `local:default` cache key,
// so its scan neither routes remotely nor re-runs when the SSH provider is replaced.
const sshConnectionId = getRepoSshConnectionId(repo)
const projectRuntime = projectRuntimeByRepoId
? projectRuntimeByRepoId.get(repo.id)
: !repo.connectionId
: !sshConnectionId
? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo)
: undefined
const runtimeKey = projectRuntime
? projectRuntime.status === 'resolved'
? projectRuntime.runtime.cacheKey
: projectRuntime.repair.cacheKey
: repo.connectionId
? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}`
: sshConnectionId
? `ssh:${sshConnectionId}:${getSshGitProviderGeneration(sshConnectionId)}`
: 'local:default'
const now = Date.now()
const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}`
@@ -176,7 +180,7 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends
return this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId)
}
if (
(refresh.result.ok || !repo.connectionId) &&
(refresh.result.ok || !sshConnectionId) &&
this.worktreeScanInFlight.get(scanScopeKey)?.promise === promise
) {
const entry: RuntimeWorktreeScanCache = {
@@ -17,7 +17,7 @@ import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host'
export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget {
/**
@@ -31,8 +31,10 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
): Promise<RuntimeWorktreeScanRefresh> {
const scannedAt = Date.now()
// SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them.
// Resolve the execution host rather than reading `connectionId`: a row stamped only
// `executionHostId: 'ssh:*'` is just as off-host, and fingerprinting it stats client paths.
const fingerprintCapable =
!repo.connectionId &&
!getRepoSshConnectionId(repo) &&
// Why: a repo whose scan TTL already reaches the reconciliation interval can never reuse a
// fingerprint, so reading one would be pure work. Agent-scratch roots are that case today.
resolveWorktreeScanCacheTtlMs(repo) < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS &&
@@ -92,13 +94,17 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
repo: Repo,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): Promise<RuntimeWorktreeScanResult> {
if (!repo.connectionId) {
// Why not `repo.connectionId`: SSH ownership has two spellings, and a repo carrying only
// `executionHostId: 'ssh:*'` would otherwise be scanned on the client against a remote path —
// `git worktree list` then reports nothing, so the remote worktrees never resolve at all.
const sshConnectionId = getRepoSshConnectionId(repo)
if (!sshConnectionId) {
return await scanLocalRepoWorktreesForResolution(
repo.path,
getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime)
)
}
const provider = getSshGitProvider(repo.connectionId)
const provider = getSshGitProvider(sshConnectionId)
if (!provider) {
return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) }
}
@@ -149,7 +155,7 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
protected invalidateSshWorktreeScanCacheInternal(targetId: string): void {
const repos = this.store?.getRepos() ?? []
const affectedRepos = repos.filter((repo) => repo.connectionId === targetId)
const affectedRepos = repos.filter((repo) => getRepoSshConnectionId(repo) === targetId)
const affectedScopeKeys = new Set(
affectedRepos.map((repo) => `${repo.id}\0${getRepoExecutionHostId(repo)}`)
)
@@ -0,0 +1,214 @@
// SSH ownership has two spellings on a repo row: the legacy `connectionId` field and the unified
// `executionHostId: 'ssh:*'`. This suite pins the scan and the terminal launch that follows it for
// the second spelling — the seam #17909 identified but could not test end to end (#11163).
import { beforeEach, describe, expect, it, vi } from 'vitest'
const electronMocks = vi.hoisted(() => {
const ipcMain = {
on: vi.fn(() => ipcMain),
removeListener: vi.fn(() => ipcMain),
emit: vi.fn(() => true)
}
return {
BrowserWindow: { fromId: vi.fn((): unknown => null) },
webContents: { fromId: vi.fn((): unknown => null) },
ipcMain,
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}
})
vi.mock('electron', () => electronMocks)
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: vi.fn(() => 0),
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId)
}))
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
vi.mock('../git/worktree', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listWorktreesStrict: listWorktreesStrictMock
}))
vi.mock('./repo-worktree-admin-fingerprint', () => ({
readRepoWorktreeAdminFingerprint: vi.fn(async () => null)
}))
import { OrcaRuntimeService } from './orca-runtime'
const TARGET_ID = 'remote-1'
const REPO_ID = 'repo-remote'
const REPO_PATH = '/srv/app'
const WORKTREE_PATH = '/srv/app-feature'
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}`
function makeMeta(overrides: Record<string, unknown> = {}) {
return {
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
/** One repo row owned by an SSH host, stamped with `executionHostId` only — no `connectionId`. */
function makeStore(repoOverrides: Record<string, unknown>) {
const metaById: Record<string, ReturnType<typeof makeMeta>> = {
[WORKTREE_ID]: makeMeta({
hostId: `ssh:${TARGET_ID}`,
instanceId: '11111111-1111-4111-8111-111111111111'
}),
[MAIN_WORKTREE_ID]: makeMeta({
displayName: 'main',
hostId: `ssh:${TARGET_ID}`,
instanceId: '22222222-2222-4222-8222-222222222222'
})
}
const repos = [
{
id: REPO_ID,
path: REPO_PATH,
displayName: 'app',
badgeColor: 'blue',
addedAt: 1,
...repoOverrides
}
]
const store = {
getRepo: (id: string) => repos.find((repo) => repo.id === id),
getRepos: () => repos,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (id: string) => metaById[id],
setWorktreeMeta: (id: string, meta: Record<string, unknown>) => {
metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never
return metaById[id]
},
removeWorktreeMeta: () => {},
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage: vi.fn(),
removeWorkspaceLineage: vi.fn(),
getGitHubCache: () => undefined as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
}),
getProjects: () => []
}
return store
}
type RuntimeInternals = {
listResolvedWorktrees: () => Promise<{ id: string; path: string; hostId?: string }[]>
}
function makeRuntime(repoOverrides: Record<string, unknown>): {
runtime: OrcaRuntimeService
list: () => Promise<{ id: string; path: string; hostId?: string }[]>
} {
const runtime = new OrcaRuntimeService(makeStore(repoOverrides) as never)
return {
runtime,
list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees()
}
}
describe('worktree scan execution-host routing', () => {
beforeEach(() => {
getSshGitProviderMock.mockReset()
listWorktreesStrictMock.mockReset()
listWorktreesStrictMock.mockResolvedValue([])
})
it('scans an executionHostId-only SSH repo over its SSH provider, not on the client', async () => {
const listWorktrees = vi.fn(async () => [
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{ path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false }
])
getSshGitProviderMock.mockReturnValue({ listWorktrees })
const { list } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` })
const worktrees = await list()
expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID)
expect(listWorktrees).toHaveBeenCalledWith(REPO_PATH)
// A client-side `git worktree list` against a remote path is the silent-substitution failure.
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
expect(worktrees.map((worktree) => worktree.path).sort()).toEqual([REPO_PATH, WORKTREE_PATH])
expect(worktrees.every((worktree) => worktree.hostId === `ssh:${TARGET_ID}`)).toBe(true)
})
it('routes the PTY of an executionHostId-only SSH worktree to its host', async () => {
getSshGitProviderMock.mockReturnValue({
listWorktrees: async () => [
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{
path: WORKTREE_PATH,
head: 'def',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]
})
const { runtime } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
} as never)
await runtime.createTerminal(`id:${WORKTREE_ID}`)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH })
)
})
it('still routes a legacy connectionId-only SSH repo the same way', async () => {
getSshGitProviderMock.mockReturnValue({
listWorktrees: async () => [
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
{
path: WORKTREE_PATH,
head: 'def',
branch: 'feature',
isBare: false,
isMainWorktree: false
}
]
})
const { runtime } = makeRuntime({ connectionId: TARGET_ID })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
} as never)
await runtime.createTerminal(`id:${WORKTREE_ID}`)
expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH })
)
})
})