From cbdaeebf7014cef072fa10c501e3e64633d94289 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:44:52 -0700 Subject: [PATCH] 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 --- ...ged-worktree-create-execution-host.test.ts | 155 +++++++++++++ .../orca-runtime-create-managed-worktree.ts | 33 ++- ...-resolved-worktrees-for-explicit-target.ts | 14 +- ...orca-runtime-refresh-repo-worktree-scan.ts | 16 +- ...rktree-scan-execution-host-routing.test.ts | 214 ++++++++++++++++++ 5 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 src/main/runtime/managed-worktree-create-execution-host.test.ts create mode 100644 src/main/runtime/worktree-scan-execution-host-routing.test.ts diff --git a/src/main/runtime/managed-worktree-create-execution-host.test.ts b/src/main/runtime/managed-worktree-create-execution-host.test.ts new file mode 100644 index 00000000000..af1a73dc9cc --- /dev/null +++ b/src/main/runtime/managed-worktree-create-execution-host.test.ts @@ -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>()), + 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 + createManagedRemoteWorktree: (repo: unknown, args: unknown) => Promise + resolveLineageForWorktreeCreate: (input: unknown) => Promise + recordCreatedWorktreeLineage: (worktree: unknown, resolution: unknown) => unknown +} + +function makeRuntime(repo: Record): { + runtime: OrcaRuntimeService + createRemote: ReturnType +} { + 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() + }) +}) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index a6f8ebfbd79..f9116f73405 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -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', diff --git a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts index f8632bb2643..1f0dbe591a3 100644 --- a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts +++ b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts @@ -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 ): Promise { + // 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 = { diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index 5e3282c5e53..1964b5fdd2f 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -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 { 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 { - 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)}`) ) diff --git a/src/main/runtime/worktree-scan-execution-host-routing.test.ts b/src/main/runtime/worktree-scan-execution-host-routing.test.ts new file mode 100644 index 00000000000..9e40789da82 --- /dev/null +++ b/src/main/runtime/worktree-scan-execution-host-routing.test.ts @@ -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>()), + 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 = {}) { + 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) { + const metaById: Record> = { + [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) => { + 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): { + 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 }) + ) + }) +})