From ec14775f934a367ce4b2f1fabfab97271d9ef7fa Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 06:25:41 -0700 Subject: [PATCH] refactor(providers): an ssh: route carries no provider and throws one typed error (#27477) Co-authored-by: m4air --- .../first-work-branch-rename.test.ts | 63 +------ .../agent-hooks/first-work-branch-rename.ts | 23 +-- src/main/git/remote-url-probe.test.ts | 26 +-- ...nt-ssh-provider-execution-boundary.test.ts | 37 ---- ...client-tracked-upstream-fork-owner.test.ts | 178 +----------------- .../client-tracked-upstream-snapshot.test.ts | 9 +- .../client/lookup/pull-request-lookup-data.ts | 6 +- .../client/lookup/tracked-upstream-branch.ts | 9 +- ...repository-identity.ssh-host-alias.test.ts | 47 ----- .../filesystem-branch-compare-diff.test.ts | 37 ---- ...esystem-conflict-operation-routing.test.ts | 20 +- .../filesystem-git-commit-dispatch.test.ts | 59 +----- .../ipc/filesystem-git-status-staging.test.ts | 126 +++---------- .../filesystem-git-commit-handlers.ts | 5 +- .../filesystem-git-diff-handlers.ts | 33 +--- .../filesystem-git-index-handlers.ts | 30 +-- .../filesystem-git-status-handlers.ts | 47 +---- .../filesystem/filesystem-git-url-handlers.ts | 10 +- .../git-remote/branch-mutation-handlers.ts | 68 +------ .../filesystem/git-remote/compare-handlers.ts | 14 +- .../filesystem/git-remote/sync-handlers.ts | 37 +--- ...-status-upstream-ref-watch-request.test.ts | 39 ++-- .../git-status-upstream-ref-watch-request.ts | 15 +- src/main/ipc/repos-remote.test.ts | 37 ---- src/main/ipc/workspace-cleanup-git-route.ts | 3 +- .../execution-host-provider-dispatch.test.ts | 110 +++-------- .../execution-host-provider-dispatch.ts | 102 ++-------- .../providers/ssh-host-not-served-error.ts | 15 ++ src/main/repo-git-remote-identity.test.ts | 66 +------ src/main/repo-git-remote-identity.ts | 17 +- src/main/repo-icon-autodetect.test.ts | 19 +- src/main/repo-icon-autodetect.ts | 33 +--- src/main/repo-icon-file-detection.test.ts | 129 +------------ src/main/repo-icon-file-detection.ts | 75 +------- src/main/repo-worktrees.ts | 9 +- ...runtime-files-terminal-artifact-io.test.ts | 60 +----- .../orca-runtime-test-mocks/setup.spec.ts | 3 +- .../runtime/runtime-file-command-target.ts | 11 +- ...erminal-file-grant-path-still-canonical.ts | 7 +- ...-resolve-allowed-terminal-artifact-path.ts | 16 +- .../runtime/runtime-git-command-target.ts | 8 +- .../runtime/runtime-worktree-filesystem.ts | 3 +- ...hosted-review-creation-eligibility.test.ts | 11 +- .../hosted-review-creation-git-state.ts | 58 +----- .../hosted-review-creation.test.ts | 75 -------- ...sted-review-execution-host-routing.test.ts | 41 +--- src/main/ssh/ssh-target-registry.test.ts | 15 ++ src/main/ssh/ssh-target-registry.ts | 27 +-- src/main/workspace-space-repo-scan.ts | 13 +- .../worktree-removal-execution-host-route.ts | 17 +- 50 files changed, 285 insertions(+), 1633 deletions(-) create mode 100644 src/main/providers/ssh-host-not-served-error.ts create mode 100644 src/main/ssh/ssh-target-registry.test.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 9b44e24bb9b..5e885e9c33d 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -695,68 +695,17 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).not.toHaveBeenCalled() }) - it('uses the SSH git provider and remote generation target for remote worktrees', async () => { - getSshGitUsernameMock.mockResolvedValue('remote-user') - const provider = { - exec: vi.fn(gitResponder({ currentBranch: 'remote-user/Nautilus', hasUpstream: false })), - renameCurrentBranch: vi.fn(async () => undefined), - executeCommitMessagePlan: vi.fn() - } - getSshGitProviderMock.mockReturnValue(provider as never) - const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo - const { deps } = makeDeps({ getRepo: () => repo }) - - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(prepareLocalEnvMock).not.toHaveBeenCalled() - expect(generateBranchNameMock).toHaveBeenCalledWith( - { firstPrompt: 'Fix the auth bug', assistantMessage: undefined }, - { agentId: 'claude', model: 'm' }, - expect.objectContaining({ - kind: 'remote', - cwd: '/repo/wt', - missingBinaryLocation: 'remote PATH' - }) - ) - expect(computeBranchNameMock).toHaveBeenCalledWith('fix-auth', expect.anything(), 'remote-user') - expect(provider.exec).not.toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], '/repo/wt') - expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') - }) - - it('retries when the SSH provider is unavailable on the first working event', async () => { - const provider = { - exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })), - renameCurrentBranch: vi.fn(async () => undefined), - executeCommitMessagePlan: vi.fn() - } - getSshGitProviderMock.mockReturnValueOnce(undefined).mockReturnValue(provider as never) - const repo = { id: REPO_ID, path: '/repo', connectionId: 'ssh-1' } as unknown as Repo + it.each<[string, Repo]>([ + ['connectionId', { ...REMOTE_REPO_BASE, connectionId: 'ssh-1' }], + ['executionHostId', { ...REMOTE_REPO_BASE, executionHostId: 'ssh:ssh-1' }] + ])('retries without local git for a row naming its SSH owner by %s', async (_spelling, repo) => { const { deps, onRenamed } = makeDeps({ getRepo: () => repo }) await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - expect(onRenamed).not.toHaveBeenCalled() - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') - expect(onRenamed).toHaveBeenCalledWith(REPO_ID) - }) - - it('routes a row that names its SSH owner only as executionHostId to that target', async () => { - const provider = { - exec: vi.fn(gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: false })), - renameCurrentBranch: vi.fn(async () => undefined), - executeCommitMessagePlan: vi.fn() - } - getSshGitProviderMock.mockReturnValue(provider) - const repo: Repo = { ...REMOTE_REPO_BASE, executionHostId: 'ssh:ssh-1' } - const { deps } = makeDeps({ getRepo: () => repo }) - - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) - - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(provider.renameCurrentBranch).toHaveBeenCalledWith('/repo/wt', 'you/fix-auth') + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() }) it("never dials a paired server's nested SSH target from this client", async () => { diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index ffc3eac7b22..21b095f38a7 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -13,7 +13,7 @@ import { import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' -import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username' +import { resolveLocalGitUsername } from '../git/git-username' import { resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' import { getRepoExecutionHostId } from '../../shared/execution-host' import { @@ -182,13 +182,10 @@ async function runAutoRename( if (route.kind === 'runtime') { return stop(`branch is owned by ${route.hostId}`) } - if (route.kind === 'ssh' && !route.provider) { + if (route.kind === 'ssh') { return retry('ssh provider unavailable') } - const provider = route.kind === 'ssh' ? route.provider : null - const exec: GitExec = provider - ? (args) => provider.exec(args, worktreePath) - : (args) => gitExecFileAsync(args, { cwd: worktreePath }) + const exec: GitExec = (args) => gitExecFileAsync(args, { cwd: worktreePath }) const currentBranch = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() if (!currentBranch || currentBranch === 'HEAD') { @@ -218,9 +215,7 @@ async function runAutoRename( } const settings = deps.getSettings() - const hostKey = getCommitMessageModelDiscoveryHostKey( - route.kind === 'ssh' ? route.connectionId : null - ) + const hostKey = getCommitMessageModelDiscoveryHostKey(null) const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo) if (!resolvedParams.ok) { // Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card. @@ -229,7 +224,7 @@ async function runAutoRename( } const params = resolvedParams.params - const target = await resolveGenerationTarget(worktreePath, params.agentId, provider, deps) + const target = await resolveGenerationTarget(worktreePath, params.agentId, null, deps) if (!target) { deps.setRenameError(worktreeId, 'Could not prepare the branch-name generation environment.') return retry('could not prepare generation environment') @@ -262,9 +257,7 @@ async function runAutoRename( ) } - const username = provider - ? (await getSshGitUsername(provider, repo.path)) || null - : (await resolveLocalGitUsername(repo.path)) || null + const username = (await resolveLocalGitUsername(repo.path)) || null // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing. const slug = stripConfiguredBranchPrefix( generated.slug, @@ -288,9 +281,7 @@ async function runAutoRename( return stop(`no distinct unique branch name for slug "${slug}"`, true) } - await (provider - ? provider.renameCurrentBranch(worktreePath, newBranch) - : renameCurrentBranch(exec, newBranch)) + await renameCurrentBranch(exec, newBranch) // resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug. const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1) diff --git a/src/main/git/remote-url-probe.test.ts b/src/main/git/remote-url-probe.test.ts index da4964b45a7..a075f188b11 100644 --- a/src/main/git/remote-url-probe.test.ts +++ b/src/main/git/remote-url-probe.test.ts @@ -13,6 +13,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { assertRemoteUrlReadable, isTransientGitProbeError, @@ -71,16 +72,10 @@ describe('remote URL probe', () => { it('reports a probe cut off by its deadline as unavailable, not as an answer', async () => { const aborted = new Error('Request was cancelled') aborted.name = 'AbortError' - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(async () => { - throw aborted - }) - }) + gitExecFileAsyncMock.mockRejectedValue(aborted) expect(isTransientGitProbeError(aborted)).toBe(true) - await expect( - assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) - ).rejects.toBe(aborted) + await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).rejects.toBe(aborted) }) it('rethrows a timed-out local probe so callers can report unavailable', async () => { @@ -97,20 +92,11 @@ describe('remote URL probe', () => { await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).resolves.toBeUndefined() }) - it('keeps a failed SSH command unverifiable even without a transport error string', async () => { - const failure = new Error('relay request failed') - getSshGitProviderMock.mockReturnValue({ exec: vi.fn().mockRejectedValue(failure) }) + it('does not turn an SSH target into a false readable result', async () => { + getSshGitProviderMock.mockReturnValue({ exec: vi.fn() }) await expect( assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) - ).rejects.toBe(failure) - }) - - it('does not turn a missing SSH provider into a false readable result', async () => { - getSshGitProviderMock.mockReturnValue(null) - - await expect( - assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) - ).rejects.toThrow('SSH Git provider unavailable') + ).rejects.toThrow(SshHostNotServedError) }) }) diff --git a/src/main/github/client-ssh-provider-execution-boundary.test.ts b/src/main/github/client-ssh-provider-execution-boundary.test.ts index c42e3dc3f5c..6b6ce2da5db 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -155,24 +155,6 @@ describe('getPRForBranch SSH execution boundary', () => { expect(outcome).toMatchObject({ kind: 'no-pr' }) }) - it('rev-parses HEAD through the SSH provider when it is registered', async () => { - const sshGitProvider = { - exec: vi.fn(async (args: string[]) => - args[0] === 'rev-parse' && args[1] === 'HEAD' - ? { stdout: `${MERGED_HEAD_OID}\n`, stderr: '' } - : { stdout: '', stderr: '' } - ) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - mockMergedBranchPRLookup() - - const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1') - - expect(sshGitProvider.exec).toHaveBeenCalledWith(['rev-parse', 'HEAD'], '/remote/repo') - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID }) - }) - it('rev-parses HEAD through the local runtime for a WSL repository', async () => { mockMergedBranchPRLookup() gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }) @@ -227,25 +209,6 @@ describe('getPRForBranch SSH execution boundary', () => { expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' }) }) - it('reads tracked upstreams through the SSH provider when it is registered', async () => { - const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'] - const sshGitProvider = { - exec: vi.fn(async (args: string[]) => - args[0] === 'for-each-ref' - ? { stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, stderr: '' } - : { stdout: '', stderr: '' } - ) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - mockUpstreamOnlyPRLookup() - - const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1') - - expect(sshGitProvider.exec).toHaveBeenCalledWith(forEachRefArgs, '/remote/repo') - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' }) - }) - it('reads tracked upstreams through the local runtime for a WSL repository', async () => { const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'] mockUpstreamOnlyPRLookup() diff --git a/src/main/github/client-tracked-upstream-fork-owner.test.ts b/src/main/github/client-tracked-upstream-fork-owner.test.ts index bc38313c2e9..b46f9463256 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -31,11 +31,9 @@ import { resetPRForBranchMocks } from './client-test-harness' const { ghExecFileAsyncMock, - getOwnerRepoMock, getOwnerRepoForRemoteMock, resolvePRRepositoryCandidatesMock, - gitExecFileAsyncMock, - getSshGitProviderMock + gitExecFileAsyncMock } = clientMocks describe('getPRForBranch', () => { @@ -200,178 +198,4 @@ describe('getPRForBranch', () => { { cwd: '/repo-root' } ) }) - - it('checks the tracked upstream branch through the SSH git provider', async () => { - const sshGitProvider = { - exec: vi.fn().mockResolvedValue({ - stdout: 'local-created-from-pr\0origin/contributor/original\n', - stderr: '' - }) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 78, - title: 'SSH upstream branch PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/78', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - - const pr = await getPRForBranch( - '/remote/repo-root', - 'local-created-from-pr', - undefined, - 'ssh-1' - ) - - expect(sshGitProvider.exec).toHaveBeenCalledWith( - ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'], - '/remote/repo-root' - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'], - {} - ) - expect(pr).toMatchObject({ number: 78, title: 'SSH upstream branch PR' }) - }) - - it('uses the same-name tracked upstream fork owner through the SSH git provider', async () => { - const sshGitProvider = { - exec: vi.fn().mockResolvedValue({ - stdout: 'contributor/fix\0fork/contributor/fix\n', - stderr: '' - }) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ - candidates: [ - { owner: 'stablyai', repo: 'orca' }, - { owner: 'origin-owner', repo: 'orca' } - ], - headRepo: { owner: 'origin-owner', repo: 'orca' } - }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 79, - title: 'SSH same-name fork PR', - state: 'open', - html_url: 'https://github.com/stablyai/orca/pull/79', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/fix', sha: 'same-name-ssh-head-oid' } - } - ]) - }) - - const pr = await getPRForBranch('/remote/repo-root', 'contributor/fix', undefined, 'ssh-1') - - expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'fork', 'ssh-1') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['api', 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Ffix&state=all&per_page=1'], - {} - ) - expect(pr).toMatchObject({ - number: 79, - title: 'SSH same-name fork PR', - prRepo: { owner: 'stablyai', repo: 'orca' }, - headRepo: { owner: 'fork-owner', repo: 'orca' } - }) - }) - - it('caches positive tracked-upstream entries for unsigned SSH runtimes during PR refresh polling', async () => { - const sshGitProvider = { - exec: vi.fn().mockResolvedValue({ - stdout: 'refs/heads/feature\0origin/contributor/original\n', - stderr: '' - }) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) - - await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - - expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) - }) - - it('refreshes positive tracked-upstream entries for unsigned SSH runtimes after the TTL', async () => { - vi.useFakeTimers() - try { - const sshGitProvider = { - exec: vi - .fn() - .mockResolvedValueOnce({ - stdout: 'refs/heads/feature\0origin/old-upstream\n', - stderr: '' - }) - .mockResolvedValueOnce({ - stdout: 'refs/heads/feature\0origin/contributor/original\n', - stderr: '' - }) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) - getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 81, - title: 'Fresh SSH upstream PR', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/81', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - base: { ref: 'main', sha: 'base-oid' }, - head: { ref: 'contributor/original', sha: 'upstream-head-oid' } - } - ]) - }) - - await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - await vi.advanceTimersByTimeAsync(30_001) - const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') - - // Why: a found upstream PR also resolves the default branch (#26948); count snapshot probes only. - const snapshotProbes = sshGitProvider.exec.mock.calls.filter(([args]) => - args.includes('refs/heads') - ) - expect(snapshotProbes).toHaveLength(2) - expect(pr).toMatchObject({ - number: 81, - title: 'Fresh SSH upstream PR' - }) - } finally { - vi.useRealTimers() - } - }) }) diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index 894c4481a09..bc70d2ff01c 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -39,7 +39,6 @@ const { getOwnerRepoForRemoteMock, resolvePRRepositoryCandidatesMock, gitExecFileAsyncMock, - getSshGitProviderMock, readLocalGitConfigSignatureMock } = clientMocks @@ -143,10 +142,6 @@ describe('getPRForBranch', () => { }) it('releases tracked-upstream probe generations across runtime identities', async () => { - const sshGitProvider = { - exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) - } - getSshGitProviderMock.mockReturnValue(sshGitProvider) getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) @@ -155,15 +150,13 @@ describe('getPRForBranch', () => { await getPRForBranch('/repo-root', 'feature', null, null, null, { localGitExecOptions: { wslDistro: 'Ubuntu' } }) - await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ - snapshots: 3, + snapshots: 2, inFlight: 0, generations: 0 }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) }) it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 8d4a00cea43..71b534446d9 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -218,11 +218,7 @@ export async function getCurrentHeadOid( connectionId?: string | null, localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {} ): Promise { - const route = requireReachableGitRoute(getConnectionExecutionHostId(connectionId)) - if (route.kind === 'ssh') { - const result = await route.provider.exec(['rev-parse', 'HEAD'], repoPath) - return result.stdout.trim() || null - } + requireReachableGitRoute(getConnectionExecutionHostId(connectionId)) try { const result = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: repoPath, diff --git a/src/main/github/client/lookup/tracked-upstream-branch.ts b/src/main/github/client/lookup/tracked-upstream-branch.ts index ed74b8f484c..edfc57934e4 100644 --- a/src/main/github/client/lookup/tracked-upstream-branch.ts +++ b/src/main/github/client/lookup/tracked-upstream-branch.ts @@ -134,14 +134,7 @@ export async function probeTrackedUpstreamBranches( upstreamsByBranchName: Map }> { const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'] - const route = requireReachableGitRoute(getConnectionExecutionHostId(connectionId)) - if (route.kind === 'ssh') { - const result = await route.provider.exec(args, repoPath) - return { - probeFailed: false, - upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) - } - } + requireReachableGitRoute(getConnectionExecutionHostId(connectionId)) try { const result = await gitExecFileAsync(args, { cwd: repoPath, diff --git a/src/main/github/github-repository-identity.ssh-host-alias.test.ts b/src/main/github/github-repository-identity.ssh-host-alias.test.ts index 809d362cfaf..bd3a2dab6a6 100644 --- a/src/main/github/github-repository-identity.ssh-host-alias.test.ts +++ b/src/main/github/github-repository-identity.ssh-host-alias.test.ts @@ -193,53 +193,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) - it('preserves a one-shot SSH remote failure through candidate discovery', async () => { - const failure = new Error('relay request failed') - const provider = { - exec: vi - .fn() - .mockRejectedValueOnce(failure) - .mockResolvedValueOnce({ stdout: 'git@github.com:team/orca.git\n', stderr: '' }) - } - getSshGitProviderMock.mockReturnValue(provider) - - await expect( - getOwnerRepoForRemote( - '/remote/repo', - 'origin', - 'ssh-1', - {}, - { - requireVerifiedSshProbe: true - } - ) - ).rejects.toBe(failure) - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(provider.exec).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('keeps failed SSH alias classification unverifiable', async () => { - const provider = sshProvider('') - getSshGitProviderMock.mockReturnValue(provider) - - await expect( - getOwnerRepoForRemote( - '/remote/repo', - 'origin', - 'ssh-1', - {}, - { - requireVerifiedSshProbe: true - } - ) - ).rejects.toThrow('Remote repository identity is unverifiable.') - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('isolates the same alias across native and WSL runtimes', async () => { resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) commandExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index bb0669ec697..47782408b9c 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -9,7 +9,6 @@ import { getBranchCompareMock, getBranchDiffMock, listWorktreesMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -248,40 +247,4 @@ describe('registerFilesystemHandlers', () => { { admissionTier: 'interactive' } ) }) - - it('forwards the pinned head through SSH branch diff queries', async () => { - const result = { - kind: 'text', - originalContent: 'left', - modifiedContent: 'right', - originalIsBinary: false, - modifiedIsBinary: false - } - const getBranchDiff = vi.fn().mockResolvedValue([result]) - getSshGitProviderMock.mockReturnValue({ getBranchDiff }) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:branchDiff')!(null, { - worktreePath: '/home/user/project', - compare: { - baseRef: 'origin/main', - baseOid: 'base-oid', - headOid: 'head-oid', - mergeBase: 'merge-base-oid' - }, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts', - connectionId: 'conn-1' - }) - ).resolves.toEqual(result) - - expect(getBranchDiff).toHaveBeenCalledWith('/home/user/project', 'merge-base-oid', { - includePatch: true, - headOid: 'head-oid', - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) - }) }) diff --git a/src/main/ipc/filesystem-conflict-operation-routing.test.ts b/src/main/ipc/filesystem-conflict-operation-routing.test.ts index 993add48902..0ebdd270eec 100644 --- a/src/main/ipc/filesystem-conflict-operation-routing.test.ts +++ b/src/main/ipc/filesystem-conflict-operation-routing.test.ts @@ -91,24 +91,8 @@ describe('git:conflictOperation ssh routing', () => { detectConflictOperationMock.mockReset() }) - it('asks the connected host and never probes locally', async () => { - const detect = vi.fn().mockResolvedValue('rebase') - getSshGitProviderMock.mockReturnValue({ detectConflictOperation: detect }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:conflictOperation')!(null, { - worktreePath: '/remote/wt', - connectionId: 'ssh-1' - }) - ).resolves.toBe('rebase') - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') - expect(detect).toHaveBeenCalledWith('/remote/wt') - expect(detectConflictOperationMock).not.toHaveBeenCalled() - }) - - it('reports an unreachable host instead of falling back to this machine', async () => { - getSshGitProviderMock.mockReturnValue(undefined) + it('reports an SSH host as not served instead of falling back to this machine', async () => { + getSshGitProviderMock.mockReturnValue({ detectConflictOperation: vi.fn() }) registerFilesystemHandlers(store as never) await expect( diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 223a448e775..cecc7c089f7 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -6,7 +6,6 @@ import { store, WORKTREE_FEATURE_PATH, commitChangesMock, - bulkDiscardChangesMock, getSshGitProviderMock, resolveCommitMessageSettingsMock, generatePullRequestFieldsFromContextMock, @@ -92,7 +91,7 @@ describe('registerFilesystemHandlers', () => { }) }) - it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => { + it('refuses ssh git:commit as not served instead of running local commitChanges', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) @@ -104,30 +103,14 @@ describe('registerFilesystemHandlers', () => { message: 'feat: remote commit', connectionId: 'conn-1' }) - ).resolves.toEqual({ success: true }) + ).rejects.toThrow( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) - expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit') + expect(sshCommitMock).not.toHaveBeenCalled() expect(commitChangesMock).not.toHaveBeenCalled() }) - it('routes ssh git:remoteCommitUrl through the SSH provider', async () => { - const sha = '0123456789abcdef0123456789abcdef01234567' - const sshRemoteCommitUrlMock = vi.fn().mockResolvedValue('https://github.com/org/repo/commit/x') - getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock }) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:remoteCommitUrl')!(null, { - worktreePath: '/remote/repo', - sha, - connectionId: 'conn-1' - }) - ).resolves.toBe('https://github.com/org/repo/commit/x') - - expect(sshRemoteCommitUrlMock).toHaveBeenCalledWith('/remote/repo', sha) - }) - it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => { const sshRemoteCommitUrlMock = vi.fn() getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock }) @@ -145,38 +128,6 @@ describe('registerFilesystemHandlers', () => { expect(sshRemoteCommitUrlMock).not.toHaveBeenCalled() }) - it('routes ssh git:bulkDiscard through the SSH provider', async () => { - const sshBulkDiscardMock = vi.fn().mockResolvedValue(undefined) - getSshGitProviderMock.mockReturnValue({ bulkDiscardChanges: sshBulkDiscardMock }) - - registerFilesystemHandlers(store as never) - - await handlers.get('git:bulkDiscard')!(null, { - worktreePath: '/remote/repo', - filePaths: ['a.ts', 'b.ts'], - connectionId: 'conn-1' - }) - - expect(sshBulkDiscardMock).toHaveBeenCalledWith('/remote/repo', ['a.ts', 'b.ts']) - expect(bulkDiscardChangesMock).not.toHaveBeenCalled() - }) - - it('routes ssh git:fastForward through the SSH provider', async () => { - const sshFastForwardMock = vi.fn().mockResolvedValue(undefined) - const pushTarget = { remoteName: 'fork', branchName: 'feature/fix' } - getSshGitProviderMock.mockReturnValue({ fastForwardBranch: sshFastForwardMock }) - - registerFilesystemHandlers(store as never) - - await handlers.get('git:fastForward')!(null, { - worktreePath: '/remote/repo', - connectionId: 'conn-1', - pushTarget - }) - - expect(sshFastForwardMock).toHaveBeenCalledWith('/remote/repo', pushTarget) - }) - it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index ba105c0ac3d..af7a8e7d0d5 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -71,6 +71,7 @@ vi.mock( ) import { registerFilesystemHandlers } from './filesystem' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { registerWorktreeRootsForRepo, invalidateAuthorizedRootsCache @@ -159,13 +160,9 @@ describe('registerFilesystemHandlers', () => { }) }) - it('forwards includeIgnored through local and SSH git status IPC', async () => { + it('forwards includeIgnored through local git status IPC', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - const sshProvider = { - getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) @@ -173,23 +170,14 @@ describe('registerFilesystemHandlers', () => { worktreePath: WORKTREE_FEATURE_PATH, includeIgnored: true }) - await handlers.get('git:status')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1', - includeIgnored: true - }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'status', includeIgnored: true }) - expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { - admissionTier: 'status', - includeIgnored: true - }) }) - it('returns capped-state metadata unchanged across local and SSH status IPC', async () => { + it('returns capped-state metadata unchanged through local status IPC', async () => { const cappedStatus = { entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }], conflictOperation: 'unknown', @@ -198,28 +186,16 @@ describe('registerFilesystemHandlers', () => { } registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue(cappedStatus) - const sshProvider = { getStatus: vi.fn().mockResolvedValue(cappedStatus) } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) await expect( handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) ).resolves.toEqual(cappedStatus) - await expect( - handlers.get('git:status')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1' - }) - ).resolves.toEqual(cappedStatus) }) - it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => { + it('forwards upstream-negative-cache bypass through local git status IPC', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - const sshProvider = { - getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) @@ -227,84 +203,46 @@ describe('registerFilesystemHandlers', () => { worktreePath: WORKTREE_FEATURE_PATH, bypassEffectiveUpstreamNegativeCache: true }) - await handlers.get('git:status')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1', - bypassEffectiveUpstreamNegativeCache: true - }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'status', includeIgnored: false, bypassEffectiveUpstreamNegativeCache: true }) - expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { - admissionTier: 'status', - includeIgnored: false, - bypassEffectiveUpstreamNegativeCache: true - }) }) - it('forwards line-stat reuse through local and SSH git status IPC', async () => { + it('forwards line-stat reuse through local git status IPC', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - const sshProvider = { - getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH, reuseLineStats: true }) - await handlers.get('git:status')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1', - reuseLineStats: true - }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'status', includeIgnored: false, reuseLineStats: true }) - expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { - admissionTier: 'status', - includeIgnored: false, - reuseLineStats: true - }) }) - it('forwards a false line-stats request through local and SSH git status IPC', async () => { + it('forwards a false line-stats request through local git status IPC', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - const sshProvider = { - getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH, includeLineStats: false }) - await handlers.get('git:status')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1', - includeLineStats: false - }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'status', includeIgnored: false, includeLineStats: false }) - expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { - admissionTier: 'status', - includeIgnored: false, - includeLineStats: false - }) }) it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( @@ -353,13 +291,9 @@ describe('registerFilesystemHandlers', () => { } ) - it('checks ignored paths through local and SSH git providers', async () => { + it('checks ignored paths through the local git provider', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) - const sshProvider = { - checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js']) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) @@ -369,66 +303,52 @@ describe('registerFilesystemHandlers', () => { paths: ['dist/bundle.js', 'src/index.ts'] }) ).resolves.toEqual(['dist/bundle.js']) - await expect( - handlers.get('git:checkIgnored')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1', - paths: ['build/output.js'] - }) - ).resolves.toEqual(['build/output.js']) expect(checkIgnoredPathsMock).toHaveBeenCalledWith( WORKTREE_FEATURE_PATH, [path.join('dist', 'bundle.js'), path.join('src', 'index.ts')], {} ) - expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [ - path.join('build', 'output.js') - ]) }) - it('routes abort merge through local and SSH git providers', async () => { + it('routes abort merge through the local git provider', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) abortMergeMock.mockResolvedValue(undefined) - const sshProvider = { - abortMerge: vi.fn().mockResolvedValue(undefined) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) - await handlers.get('git:abortMerge')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1' - }) expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'interactive' }) - expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo') }) - it('routes abort rebase through local and SSH git providers', async () => { + it('routes abort rebase through the local git provider', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) abortRebaseMock.mockResolvedValue(undefined) - const sshProvider = { - abortRebase: vi.fn().mockResolvedValue(undefined) - } - getSshGitProviderMock.mockReturnValue(sshProvider) registerFilesystemHandlers(store as never) await handlers.get('git:abortRebase')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) - await handlers.get('git:abortRebase')!(null, { - worktreePath: '/remote/repo', - connectionId: 'ssh-1' - }) expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { admissionTier: 'interactive' }) - expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo') + }) + + it('refuses SSH git IPC with the not-served error and never runs it locally', async () => { + getSshGitProviderMock.mockReturnValue({ getStatus: vi.fn(), abortMerge: vi.fn() }) + registerFilesystemHandlers(store as never) + + for (const channel of ['git:status', 'git:abortMerge', 'git:abortRebase']) { + await expect( + handlers.get(channel)!(null, { worktreePath: '/remote/repo', connectionId: 'ssh-1' }) + ).rejects.toThrow(SshHostNotServedError) + } + expect(getStatusMock).not.toHaveBeenCalled() + expect(abortMergeMock).not.toHaveBeenCalled() + expect(abortRebaseMock).not.toHaveBeenCalled() }) it('rejects git file paths that escape the selected worktree', async () => { diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts index ef69c89deef..d1b96b04e70 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -18,10 +18,7 @@ export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerCo if (typeof args.message !== 'string' || args.message.trim().length === 0) { throw new Error('Commit message is required') } - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.commit(args.worktreePath, args.message) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts index fcefca7b690..38145fe8eca 100644 --- a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -30,28 +30,7 @@ export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerCont connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - const results = await route.provider.getBranchDiff( - args.worktreePath, - args.compare.mergeBase, - { - includePatch: true, - headOid: args.compare.headOid, - filePath: args.filePath, - oldPath: args.oldPath - } - ) - return ( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const oldPath = args.oldPath @@ -90,15 +69,7 @@ export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerCont ): Promise => { const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getCommitDiff(args.worktreePath, { - commitOid, - parentOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const oldPath = args.oldPath diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts index e048fc9f74a..f2eb20f89ea 100644 --- a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts @@ -28,10 +28,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.stageFile(args.worktreePath, args.filePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -49,10 +46,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.unstageFile(args.worktreePath, args.filePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -70,10 +64,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.discardChanges(args.worktreePath, args.filePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -91,10 +82,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon _event, args: { worktreePath: string; filePaths: string[]; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.bulkDiscardChanges(args.worktreePath, args.filePaths) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -116,10 +104,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon args: { worktreePath: string; filePaths: string[]; connectionId?: string; scope?: unknown } ): Promise => { const scope = parseGitStageWorktreeScope(args.scope) - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.bulkStageFiles(args.worktreePath, args.filePaths, scope) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -149,10 +134,7 @@ export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerCon _event, args: { worktreePath: string; filePaths: string[]; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.bulkUnstageFiles(args.worktreePath, args.filePaths) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts index 61245be0832..946c1434690 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -68,11 +68,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo ...(controller ? { signal: controller.signal } : {}) } try { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). - return await route.provider.getStatus(args.worktreePath, options) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) // Why: one registered-worktree lookup feeds both — status polls this // handler, and the scan walks every repo's worktree meta. @@ -112,10 +108,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo area?: GitStagingArea } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -135,11 +128,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo _event, args: { worktreePath: string; paths: string[]; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) - return route.provider.checkIgnoredPaths(args.worktreePath, paths) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -180,10 +169,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => { const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getHistory(args.worktreePath, options) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -201,10 +187,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo _event, args: { worktreePath: string; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.detectConflictOperation(args.worktreePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -218,10 +201,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo ipcMain.handle( 'git:abortMerge', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.abortMerge(args.worktreePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -235,10 +215,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo ipcMain.handle( 'git:abortRebase', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.abortRebase(args.worktreePath) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -261,15 +238,7 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getDiff( - args.worktreePath, - args.filePath, - args.staged, - args.compareAgainstHead - ) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts index cebf3df48ba..85e4380bd0f 100644 --- a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -16,10 +16,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } ): Promise => { // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteFileUrl(worktreePath, args.relativePath, args.line) @@ -34,10 +31,7 @@ export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerConte ): Promise => { const sha = validateFullGitObjectId(args.sha, 'sha') // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getRemoteCommitUrl(args.worktreePath, sha) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) return getRemoteCommitUrl(worktreePath, sha) diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts index 092003b71f2..808046ae998 100644 --- a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -4,11 +4,7 @@ import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../. import { validateGitPushTarget } from '../../../git/push-target-validation' import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' -import { assertValidGitPushTarget } from '../../../../shared/git-push-target-validation' -import { - materializeWorktreePushTargetRemote, - materializeWorktreePushTargetRemoteSsh -} from '../../worktree-remote' +import { materializeWorktreePushTargetRemote } from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' import { requireReachableGitRoute } from '../../../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' @@ -31,26 +27,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl ): Promise => { // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. const publish = args.publish === true - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - if (args.pushTarget) { - assertValidGitPushTarget(args.pushTarget) - } - // Why: a fork remote deferred at create time (#17828) must exist before push. - const materializedPushTarget = args.pushTarget - ? await materializeWorktreePushTargetRemoteSsh( - route.provider, - args.worktreePath, - args.pushTarget, - store, - undefined, - args.worktreeId - ) - : undefined - return route.provider.pushBranch(args.worktreePath, publish, materializedPushTarget, { - forceWithLease: args.forceWithLease === true - }) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -92,23 +69,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl pushTarget?: GitPushTarget } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - if (args.pushTarget) { - assertValidGitPushTarget(args.pushTarget) - } - const materializedPushTarget = args.pushTarget - ? await materializeWorktreePushTargetRemoteSsh( - route.provider, - args.worktreePath, - args.pushTarget, - store, - undefined, - args.worktreeId - ) - : undefined - return route.provider.pullBranch(args.worktreePath, materializedPushTarget) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -149,23 +110,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl pushTarget?: GitPushTarget } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - if (args.pushTarget) { - assertValidGitPushTarget(args.pushTarget) - } - const materializedPushTarget = args.pushTarget - ? await materializeWorktreePushTargetRemoteSsh( - route.provider, - args.worktreePath, - args.pushTarget, - store, - undefined, - args.worktreeId - ) - : undefined - return route.provider.fastForwardBranch(args.worktreePath, materializedPushTarget) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -201,10 +146,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl _event, args: { worktreePath: string; baseRef: string; connectionId?: string } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.rebaseFromBase(args.worktreePath, args.baseRef) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts index 0a9ca1041d2..83e3bcf0164 100644 --- a/src/main/ipc/filesystem/git-remote/compare-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -26,14 +26,7 @@ export function registerGitRemoteCompareHandlers(context: FilesystemHandlerConte admissionTier?: GitAdmissionTier } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return args.admissionTier - ? route.provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : route.provider.getBranchCompare(args.worktreePath, args.baseRef) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -54,10 +47,7 @@ export function registerGitRemoteCompareHandlers(context: FilesystemHandlerConte args: { worktreePath: string; commitId: string; connectionId?: string } ): Promise => { const commitId = validateFullGitObjectId(args.commitId, 'commitId') - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.getCommitCompare(args.worktreePath, commitId) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts index e9ca5c9dad1..cfc1f05a5b3 100644 --- a/src/main/ipc/filesystem/git-remote/sync-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -11,12 +11,8 @@ import { getUpstreamStatus } from '../../../git/upstream' import { validateGitPushTarget } from '../../../git/push-target-validation' import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' -import { assertValidGitPushTarget } from '../../../../shared/git-push-target-validation' import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' -import { - materializeWorktreePushTargetRemote, - materializeWorktreePushTargetRemoteSsh -} from '../../worktree-remote' +import { materializeWorktreePushTargetRemote } from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' import { requireReachableGitRoute } from '../../../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' @@ -30,13 +26,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) _event, args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - if (args.pushTarget) { - assertValidGitPushTarget(args.pushTarget) - } - return route.provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -58,23 +48,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) pushTarget?: GitPushTarget } ): Promise => { - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - if (args.pushTarget) { - assertValidGitPushTarget(args.pushTarget) - } - const materializedPushTarget = args.pushTarget - ? await materializeWorktreePushTargetRemoteSsh( - route.provider, - args.worktreePath, - args.pushTarget, - store, - undefined, - args.worktreeId - ) - : undefined - return route.provider.fetchRemote(args.worktreePath, materializedPushTarget) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, @@ -117,10 +91,7 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { required: true }) - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return route.provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.test.ts b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts index d2d515dc620..62c3644f52c 100644 --- a/src/main/ipc/git-status-upstream-ref-watch-request.test.ts +++ b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts @@ -29,6 +29,7 @@ vi.mock('./worktree-base-directory-watcher', () => ({ })) import { applyGitStatusUpstreamRefWatchRequest } from './git-status-upstream-ref-watch-request' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' describe('applyGitStatusUpstreamRefWatchRequest', () => { beforeEach(() => { @@ -67,36 +68,28 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => { ) }) - it('routes SSH resolution through the current provider generation', async () => { - const exec = vi.fn().mockResolvedValue({ - stdout: - 'refs/heads/feature/main\0refs/remotes/team/fork/feature/main\0team/fork/feature/main\n' - }) + it('keeps the SSH provider generation but refuses to resolve on an SSH host', async () => { + const exec = vi.fn() mocks.getProvider.mockReturnValue({ exec }) mocks.getProviderGeneration.mockReturnValue(7) - await applyGitStatusUpstreamRefWatchRequest({} as Store, { - worktreeId: 'repo-1::/repo', - worktreePath: '/repo', - executionHostId: 'ssh:ssh-1', - connectionId: 'ssh-1', - branch: 'refs/heads/feature/main', - upstreamName: 'team/fork/feature/main' - }) + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH refusal throws before any store read. + applyGitStatusUpstreamRefWatchRequest({} as Store, { + worktreeId: 'repo-1::/repo', + worktreePath: '/repo', + executionHostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + branch: 'refs/heads/feature/main', + upstreamName: 'team/fork/feature/main' + }) + ).rejects.toThrow(SshHostNotServedError) expect(mocks.setWatch).toHaveBeenCalledWith( expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }), expect.any(Function) ) - expect(exec).toHaveBeenCalledWith( - [ - 'for-each-ref', - '--format=%(refname)%00%(upstream)%00%(upstream:short)', - '--count=1', - 'refs/heads/feature/main' - ], - '/repo', - expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 15_000 }) - ) + expect(exec).not.toHaveBeenCalled() + expect(mocks.gitExec).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.ts b/src/main/ipc/git-status-upstream-ref-watch-request.ts index c7ca686dec3..d3811c827d4 100644 --- a/src/main/ipc/git-status-upstream-ref-watch-request.ts +++ b/src/main/ipc/git-status-upstream-ref-watch-request.ts @@ -37,20 +37,7 @@ export function applyGitStatusUpstreamRefWatchRequest( return undefined } const signal = boundedSignal(bindingSignal) - const route = requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - if (route.kind === 'ssh') { - return resolveGitStatusUpstreamRef( - (gitArgs, cwd, requestSignal) => - route.provider.exec(gitArgs, cwd, { - signal: requestSignal, - timeoutMs: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS - }), - args.worktreePath, - args.branch, - args.upstreamName, - signal - ) - } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index dfe9dcf46f5..95f6878a251 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -206,43 +206,6 @@ describe('repos:addRemote', () => { }) }) - it.each([ - ['connectionId', { connectionId: 'conn-1' }], - ['executionHostId only', { executionHostId: 'ssh:conn-1' }] - ])( - 'returns an existing SSH repo (%s) instead of cloning the same target again', - async (_label, owner) => { - const existing = { - id: 'existing-id', - path: '/home/user/orca', - ...owner, - displayName: 'orca', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - mockStore.getRepos.mockReturnValue([existing]) - // What git on the SSH host answers inside a finished clone of the URL. - mockGitProvider.exec.mockImplementation((argv: string[], cwd?: string) => - cwd === '/home/user/orca' && argv[0] === 'rev-parse' - ? Promise.resolve({ stdout: '\n0123abcd\n', stderr: '' }) - : cwd === '/home/user/orca' && argv[0] === 'config' - ? Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' }) - : Promise.reject(new Error('fatal: not a git repository')) - ) - - const result = await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - - expect(result).toBe(existing) - expect(mockGitProvider.clone).not.toHaveBeenCalled() - expect(mockStore.addRepo).not.toHaveBeenCalled() - } - ) - it('refuses a saved SSH project at the destination whose folder is not this clone', async () => { mockStore.getRepos.mockReturnValue([ { diff --git a/src/main/ipc/workspace-cleanup-git-route.ts b/src/main/ipc/workspace-cleanup-git-route.ts index 2080dfcf367..5dd1344335b 100644 --- a/src/main/ipc/workspace-cleanup-git-route.ts +++ b/src/main/ipc/workspace-cleanup-git-route.ts @@ -27,6 +27,7 @@ import { ExecutionHostNotDispatchableError, resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' export type WorkspaceCleanupGitRoute = @@ -57,7 +58,7 @@ export function resolveWorkspaceCleanupRepoGitRoute( kind: 'ssh', hostId: route.hostId, connectionId: route.connectionId, - provider: route.provider + provider: getSshGitProvider(route.connectionId) ?? null } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) diff --git a/src/main/providers/execution-host-provider-dispatch.test.ts b/src/main/providers/execution-host-provider-dispatch.test.ts index 4792cf740a5..1197e8cc97d 100644 --- a/src/main/providers/execution-host-provider-dispatch.test.ts +++ b/src/main/providers/execution-host-provider-dispatch.test.ts @@ -1,8 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { ExecutionHostNotDispatchableError, - requireFilesystemProviderForHost, - requireGitProviderForHost, requireReachableFilesystemRoute, requireReachableGitRoute, resolveFilesystemRouteForHost, @@ -16,7 +14,7 @@ import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from './ssh-filesystem-dispatch' -import { clearSshPlainSshMode, setSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' +import { SSH_HOST_NOT_SERVED_MESSAGE, SshHostNotServedError } from './ssh-host-not-served-error' const connectionId = 'host-dispatch-target' const gitProvider = { listWorktrees: async () => [] } as never @@ -41,38 +39,13 @@ describe('execution host provider dispatch', () => { }) }) - it('routes an ssh host to its registered provider', () => { + it('routes an ssh host to a provider-less entry, even with a registered provider', () => { registerSshGitProvider(connectionId, gitProvider) registerSshFilesystemProvider(connectionId, filesystemProvider) - expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toEqual({ - kind: 'ssh', - hostId: `ssh:${connectionId}`, - connectionId, - provider: gitProvider - }) - expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toEqual({ - kind: 'ssh', - hostId: `ssh:${connectionId}`, - connectionId, - provider: filesystemProvider - }) - expect(requireGitProviderForHost(`ssh:${connectionId}`)).toBe(gitProvider) - expect(requireFilesystemProviderForHost(`ssh:${connectionId}`)).toBe(filesystemProvider) - }) - - it('answers `unreachable`, not `local`, for an ssh host with no registered provider', () => { - const route = resolveGitRouteForHost(`ssh:${connectionId}`) - - // The distinction the old `connectionId ? ssh : local` shape could not spell. - expect(route.kind).toBe('ssh') - expect(route.kind === 'ssh' && route.provider).toBeNull() - expect(() => requireGitProviderForHost(`ssh:${connectionId}`)).toThrow( - /Remote connection dropped/ - ) - expect(() => requireFilesystemProviderForHost(`ssh:${connectionId}`)).toThrow( - /Remote connection dropped/ - ) + const expected = { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId } + expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) + expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) }) it('routes a runtime host to its own entry instead of collapsing it into local', () => { @@ -88,26 +61,6 @@ describe('execution host provider dispatch', () => { }) }) - it('refuses to hand a runtime host to this process’s ssh table', () => { - // A runtime repo row carries the *server's* nested target id. Dialling it here would reach a - // same-named target in this client's namespace. - registerSshGitProvider(connectionId, gitProvider) - - expect(() => requireGitProviderForHost('runtime:env-7')).toThrow( - ExecutionHostNotDispatchableError - ) - expect(() => requireFilesystemProviderForHost('runtime:env-7')).toThrow( - ExecutionHostNotDispatchableError - ) - }) - - it('refuses to serve a local host from the remote-only accessor', () => { - expect(() => requireGitProviderForHost('local')).toThrow(ExecutionHostNotDispatchableError) - expect(() => requireFilesystemProviderForHost('local')).toThrow( - ExecutionHostNotDispatchableError - ) - }) - it.each([null, undefined, '', 'nonsense', 'ssh:', 'runtime:', 'ssh:a|b'])( 'throws instead of answering local for the unresolvable host %p', (hostId) => { @@ -117,41 +70,36 @@ describe('execution host provider dispatch', () => { ) describe('reachable routes for work this process runs', () => { - it('keeps `local` explicit and returns a connected ssh host with its provider', () => { + it('keeps `local` explicit', () => { expect(requireReachableGitRoute('local')).toMatchObject({ kind: 'local', hostId: 'local' }) expect(requireReachableFilesystemRoute('local')).toMatchObject({ kind: 'local' }) - - registerSshGitProvider(connectionId, gitProvider) - registerSshFilesystemProvider(connectionId, filesystemProvider) - expect(requireReachableGitRoute(`ssh:${connectionId}`)).toMatchObject({ - kind: 'ssh', - connectionId, - provider: gitProvider - }) - expect(requireReachableFilesystemRoute(`ssh:${connectionId}`)).toMatchObject({ - kind: 'ssh', - provider: filesystemProvider - }) }) - it('throws for an unreachable ssh host instead of running it here', () => { - expect(() => requireReachableGitRoute(`ssh:${connectionId}`)).toThrow( - /Remote connection dropped/ - ) - expect(() => requireReachableFilesystemRoute(`ssh:${connectionId}`)).toThrow( - /Remote connection dropped/ - ) - }) - - it('names a plain SSH host’s missing git as unsupported, not dropped', () => { - setSshPlainSshMode(connectionId, { reason: 'no_runtime', message: 'm' }) - try { - expect(() => requireReachableGitRoute(`ssh:${connectionId}`)).toThrow( - 'Git needs the Orca remote server' + // Characterization: the message is the one an unreachable ssh host already showed. + it.each([false, true])( + 'throws the typed not-served error for an ssh host (provider registered: %s)', + (registered) => { + if (registered) { + registerSshGitProvider(connectionId, gitProvider) + registerSshFilesystemProvider(connectionId, filesystemProvider) + } + for (const require of [requireReachableGitRoute, requireReachableFilesystemRoute]) { + expect(() => require(`ssh:${connectionId}`)).toThrow(SshHostNotServedError) + expect(() => require(`ssh:${connectionId}`)).toThrow( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) + } + expect(SSH_HOST_NOT_SERVED_MESSAGE).toBe( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' ) - } finally { - clearSshPlainSshMode(connectionId) } + ) + + it('never hands an ssh host a local provider factory', () => { + expect(resolveGitRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty('createProvider') + expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty( + 'createProvider' + ) }) it('refuses a runtime host', () => { diff --git a/src/main/providers/execution-host-provider-dispatch.ts b/src/main/providers/execution-host-provider-dispatch.ts index 7895b2e5e8f..2dbfc66edd6 100644 --- a/src/main/providers/execution-host-provider-dispatch.ts +++ b/src/main/providers/execution-host-provider-dispatch.ts @@ -11,7 +11,8 @@ * options (WSL distro, shared links, admission tier) a shared provider would drop. * - `runtime:` is forwarded to that server. Its repo's `connectionId` names the server's SSH * target, so it must never reach this client's SSH table. - * - `ssh` with `provider: null` means "remote and unreachable", never "local". + * - `ssh` names an SSH target this process does not serve; work on it throws + * `SshHostNotServedError`, never runs locally. */ import { @@ -22,13 +23,7 @@ import { } from '../../shared/execution-host' import { createLocalFilesystemProvider } from './local-filesystem-provider' import { createLocalGitProvider } from './local-git-provider' -import { getSshGitProvider, sshGitProviderMissingError } from './ssh-git-dispatch' -import type { SshGitProvider } from './ssh-git-provider' -import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from './ssh-filesystem-dispatch' -import type { IFilesystemProvider, IGitProvider } from './types' +import { SshHostNotServedError } from './ssh-host-not-served-error' /** An id that names no execution host. Never degrade to local — that is the whole defect class. */ export class UnresolvableExecutionHostError extends Error { @@ -55,21 +50,10 @@ type LocalGitRoute = LocalRoute & { createProvider: typeof createLocalGitProvide /** Local file work authorizes against the caller's store, so the route carries a factory too. */ type LocalFilesystemRoute = LocalRoute & { createProvider: typeof createLocalFilesystemProvider } type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string } -type SshRoute = { - kind: 'ssh' - hostId: `ssh:${string}` - connectionId: string - /** `null` is "remote, currently unreachable" — never "local". */ - provider: TProvider | null -} +type SshRoute = { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string } -// The SSH table stores `SshGitProvider`; narrowing the route to `IGitProvider` would drop the -// remote-only methods (commit-message plans, push-target materialization) that callers need. -export type ExecutionHostGitRoute = LocalGitRoute | RuntimeRoute | SshRoute -export type ExecutionHostFilesystemRoute = - | LocalFilesystemRoute - | RuntimeRoute - | SshRoute +export type ExecutionHostGitRoute = LocalGitRoute | RuntimeRoute | SshRoute +export type ExecutionHostFilesystemRoute = LocalFilesystemRoute | RuntimeRoute | SshRoute // Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host // ids also arrive from persistence and IPC where the compiler cannot vouch for them. @@ -87,12 +71,7 @@ export function resolveGitRouteForHost(hostId: string | null | undefined): Execu case 'local': return { kind: 'local', hostId: parsed.id, createProvider: createLocalGitProvider } case 'ssh': - return { - kind: 'ssh', - hostId: parsed.id, - connectionId: parsed.targetId, - provider: getSshGitProvider(parsed.targetId) ?? null - } + return { kind: 'ssh', hostId: parsed.id, connectionId: parsed.targetId } case 'runtime': return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId } } @@ -106,73 +85,32 @@ export function resolveFilesystemRouteForHost( case 'local': return { kind: 'local', hostId: parsed.id, createProvider: createLocalFilesystemProvider } case 'ssh': - return { - kind: 'ssh', - hostId: parsed.id, - connectionId: parsed.targetId, - provider: getSshFilesystemProvider(parsed.targetId) ?? null - } + return { kind: 'ssh', hostId: parsed.id, connectionId: parsed.targetId } case 'runtime': return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId } } } -type ReachableSshRoute = SshRoute & { provider: TProvider } -export type ReachableGitRoute = LocalGitRoute | ReachableSshRoute -export type ReachableFilesystemRoute = LocalFilesystemRoute | ReachableSshRoute - -/** For work this process runs itself: `runtime:` and an unreachable SSH host throw, never run here. */ -export function requireReachableGitRoute(hostId: string | null | undefined): ReachableGitRoute { - const route = resolveGitRouteForHost(hostId) - switch (route.kind) { - case 'local': - return route - case 'ssh': { - const { provider } = route - if (!provider) { - throw sshGitProviderMissingError(route.connectionId) - } - return { ...route, provider } - } - case 'runtime': - throw new ExecutionHostNotDispatchableError(route.hostId) - } +/** For work this process runs itself: `ssh:` and `runtime:` hosts throw, never run here. */ +export function requireReachableGitRoute(hostId: string | null | undefined): LocalGitRoute { + return requireLocalRoute(resolveGitRouteForHost(hostId)) } export function requireReachableFilesystemRoute( hostId: string | null | undefined -): ReachableFilesystemRoute { - const route = resolveFilesystemRouteForHost(hostId) +): LocalFilesystemRoute { + return requireLocalRoute(resolveFilesystemRouteForHost(hostId)) +} + +function requireLocalRoute( + route: TLocal | RuntimeRoute | SshRoute +): TLocal { switch (route.kind) { case 'local': return route - case 'ssh': { - const { provider } = route - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return { ...route, provider } - } + case 'ssh': + throw new SshHostNotServedError(route.connectionId) case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) } } - -/** For call sites that are structurally remote-only: local and runtime are both routing errors. */ -export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider { - const route = requireReachableGitRoute(hostId) - if (route.kind !== 'ssh') { - throw new ExecutionHostNotDispatchableError(route.hostId) - } - return route.provider -} - -export function requireFilesystemProviderForHost( - hostId: string | null | undefined -): IFilesystemProvider { - const route = requireReachableFilesystemRoute(hostId) - if (route.kind !== 'ssh') { - throw new ExecutionHostNotDispatchableError(route.hostId) - } - return route.provider -} diff --git a/src/main/providers/ssh-host-not-served-error.ts b/src/main/providers/ssh-host-not-served-error.ts new file mode 100644 index 00000000000..5830556b306 --- /dev/null +++ b/src/main/providers/ssh-host-not-served-error.ts @@ -0,0 +1,15 @@ +/** + * An `ssh:` host names an SSH target, never a route: this process serves no SSH provider, + * so work routed to one fails with this error. A managed host is reached as `runtime:`. + */ +export const SSH_HOST_NOT_SERVED_MESSAGE = + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + +export class SshHostNotServedError extends Error { + readonly code = 'ssh_host_not_served' + + constructor(readonly connectionId: string) { + super(SSH_HOST_NOT_SERVED_MESSAGE) + this.name = 'SshHostNotServedError' + } +} diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts index b513d835c43..67e84a70e3f 100644 --- a/src/main/repo-git-remote-identity.test.ts +++ b/src/main/repo-git-remote-identity.test.ts @@ -49,34 +49,13 @@ describe('probeGitRemoteIdentity', () => { }) }) - it('routes each SSH host to its own git provider', async () => { - const m4air = registerHost('m4air') - const openclaw = registerHost( - 'openclaw', - 'origin\tgit@gitlab.example.com:team/other.git (fetch)\n' - ) + it('reports unavailable for an SSH host, even with a registered git provider', async () => { + const exec = registerHost('builder') - await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:m4air')).resolves.toEqual({ - status: 'resolved', - identity: gitlabIdentity - }) - await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:openclaw')).resolves.toEqual({ - status: 'resolved', - identity: { - canonicalKey: 'gitlab.example.com/team/other', - remoteName: 'origin', - remoteUrl: 'git@gitlab.example.com:team/other.git' - } - }) - expect(m4air).toHaveBeenCalledTimes(1) - expect(openclaw).toHaveBeenCalledTimes(1) - expect(gitExecFileAsync).not.toHaveBeenCalled() - }) - - it('reports unavailable when the SSH host has no connected git provider', async () => { await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'unavailable' }) + expect(exec).not.toHaveBeenCalled() expect(gitExecFileAsync).not.toHaveBeenCalled() }) @@ -101,30 +80,6 @@ describe('probeGitRemoteIdentity', () => { }) }) - it('reports unavailable when a connected SSH provider cannot reach the host', async () => { - const exec = vi.fn().mockRejectedValue(new Error('ssh: connect to host builder: down')) - registerSshGitProvider('builder', { exec } as never) - registered.push('builder') - - await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ - status: 'unavailable' - }) - expect(exec).toHaveBeenCalledWith( - ['remote', '-v'], - '/repos/orca', - expect.objectContaining({ timeoutMs: expect.any(Number) }) - ) - expect(gitExecFileAsync).not.toHaveBeenCalled() - }) - - it('settles on no-remote for an SSH repo git answered for with no remotes', async () => { - registerHost('builder', '') - - await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ - status: 'no-remote' - }) - }) - it('bounds the local probe with a deadline and forwards the caller signal', async () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) const controller = new AbortController() @@ -143,21 +98,6 @@ describe('probeGitRemoteIdentity', () => { expect(options.timeout).toBeGreaterThan(0) }) - it('bounds the SSH probe under the relay request timeout and forwards the caller signal', async () => { - const exec = registerHost('builder') - const controller = new AbortController() - - await probeGitRemoteIdentity('/repos/orca', 'ssh:builder', { signal: controller.signal }) - - expect(exec).toHaveBeenCalledWith( - ['remote', '-v'], - '/repos/orca', - expect.objectContaining({ timeoutMs: expect.any(Number), signal: controller.signal }) - ) - const relayRequestTimeoutMs = 30_000 - expect(exec.mock.calls[0][2].timeoutMs).toBeLessThan(relayRequestTimeoutMs) - }) - it('maps a timed-out local probe to unavailable, never no-remote', async () => { vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('git timed out.')) diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index cb53319db24..c75f93e30b0 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -8,9 +8,6 @@ import { resolveGitRouteForHost } from './providers/execution-host-provider-disp // Matches the background local-git-read budget in git/git-username.ts; a probe cut off early just // reports `unavailable` and retries on the enrichment TTL. const LOCAL_PROBE_TIMEOUT_MS = 5000 -// Why looser but still bounded: the relay adds a round trip a local spawn does not have, and the -// budget must stay under the multiplexer's 30s request timeout so this deadline is the one that fires. -const SSH_PROBE_TIMEOUT_MS = 20_000 export type GitRemoteIdentityProbeOptions = { signal?: AbortSignal @@ -33,19 +30,11 @@ export async function runGitProbeOnHost( executionHostId: ExecutionHostId, options: GitRemoteIdentityProbeOptions = {} ): Promise<{ stdout: string } | null> { - const route = resolveGitRouteForHost(executionHostId) - if (route.kind === 'runtime') { - // That environment's server runs its own git, and the SSH target on its repo row is nested in - // that server's namespace — dialing it here answers for a same-named box of ours. + // A runtime server runs its own git (its repo row's SSH target is nested in that server's + // namespace), and this process serves no `ssh:` host. + if (resolveGitRouteForHost(executionHostId).kind !== 'local') { return null } - if (route.kind === 'ssh') { - const result = await route.provider?.exec(args, repoPath, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS - }) - return result ?? null - } return gitExecFileAsync(args, { cwd: repoPath, timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 9457ef998c9..008d6684615 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -212,25 +212,16 @@ describe('detectRepoIcon', () => { ).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) }) - it('routes each SSH host to its own filesystem provider', async () => { + it('reads nothing for an SSH host, even with a registered filesystem provider', async () => { const repoPath = await makeTempRepoDir() - // On disk on this machine, so a host-blind probe would answer with this one for both hosts. + // On disk on this machine, so a host-blind probe would answer with it. await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) - registerHomepageHost('m4air', 'https://m4air.example.com') - registerHomepageHost('openclaw', 'https://openclaw.example.com') + const host = registerHomepageHost('m4air', 'https://m4air.example.com') await expect( detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:m4air' }) - ).resolves.toMatchObject({ - source: 'favicon', - src: expect.stringContaining('m4air.example.com') - }) - await expect( - detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:openclaw' }) - ).resolves.toMatchObject({ - source: 'favicon', - src: expect.stringContaining('openclaw.example.com') - }) + ).resolves.toBeUndefined() + expect(host.stat).not.toHaveBeenCalled() }) it('reads nothing for a runtime host even when its nested SSH target is registered here', async () => { diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 542e28fb8b2..56bf63f5dbe 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -13,7 +13,6 @@ import { resolveFilesystemRouteForHost, resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' -import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' import { detectRepoFileIcon } from './repo-icon-file-detection' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' @@ -61,26 +60,6 @@ async function detectLocalPackageHomepageIcon(repoPath: string): Promise { - try { - const packageJsonPath = joinWorktreeRelativePath(repoPath, 'package.json') - const info = await fsProvider.stat(packageJsonPath) - if (info.type !== 'file' || info.size > 128 * 1024) { - return null - } - const result = await fsProvider.readFile(packageJsonPath) - if (result.isBinary) { - return null - } - return packageHomepageIcon(JSON.parse(result.content)) - } catch { - return null - } -} - /** * The connection this client may dial to read `executionHostId`'s remotes, or `refuse` when it may * dial none. `runtime:` is refused rather than degraded to `null`: that server runs its own git, @@ -135,14 +114,10 @@ export async function detectRepoIcon({ return fileIcon } - // Why the same route again: a remote repoPath with no provider, and every runtime host, must - // not be probed on the client filesystem — a same-named local path answers for the wrong repo. - const remoteProvider = route.kind === 'ssh' ? route.provider : null - const homepageIcon = remoteProvider - ? await detectRemotePackageHomepageIcon(repoPath, remoteProvider) - : route.kind === 'local' - ? await detectLocalPackageHomepageIcon(repoPath) - : null + // Why the same route again: a remote repoPath must not be probed on the client filesystem — + // a same-named local path answers for the wrong repo. + const homepageIcon = + route.kind === 'local' ? await detectLocalPackageHomepageIcon(repoPath) : null if (homepageIcon) { return homepageIcon } diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts index 13c78ecbc67..0bf8e8cfbef 100644 --- a/src/main/repo-icon-file-detection.test.ts +++ b/src/main/repo-icon-file-detection.test.ts @@ -3,20 +3,9 @@ import { tmpdir } from 'node:os' import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import type * as FsPromisesModule from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' -import { - resolveFilesystemRouteForHost, - type ExecutionHostFilesystemRoute -} from './providers/execution-host-provider-dispatch' -import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types' +import { resolveFilesystemRouteForHost } from './providers/execution-host-provider-dispatch' import { detectRepoFileIcon } from './repo-icon-file-detection' -function sshRoute( - connectionId: string, - provider: IFilesystemProvider | null -): ExecutionHostFilesystemRoute { - return { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId, provider } -} - // Why: the boundary assertion is "no local read happened", which needs the real // fs entrypoints spied rather than stubbed. vi.mock('node:fs/promises', async (importOriginal) => { @@ -24,91 +13,17 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, stat: vi.fn(actual.stat), readFile: vi.fn(actual.readFile) } }) -const WEBP_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' -function remoteFilesystemProvider({ - stat, - readFile -}: { - stat: (filePath: string) => Promise - readFile: (filePath: string) => Promise -}): IFilesystemProvider { - return { stat, readFile } as IFilesystemProvider -} - -describe('detectRepoFileIcon remote probing', () => { - it('detects binary WebP icons through a remote filesystem provider', async () => { - const provider = remoteFilesystemProvider({ - stat: async (filePath) => { - if (!filePath.endsWith('/public/icon.webp')) { - throw new Error('ENOENT') - } - return { type: 'file', size: 34, mtime: 0 } - }, - readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' }) - }) - - await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toEqual({ - type: 'image', - src: `data:image/webp;base64,${WEBP_BASE64}`, - source: 'file', - label: 'public/icon.webp' - }) - }) - - it('keeps conventional-path priority when probes resolve concurrently', async () => { - const provider = remoteFilesystemProvider({ - stat: async (filePath) => { - if (filePath.endsWith('/favicon.png') || filePath.endsWith('/public/favicon.png')) { - return { type: 'file', size: 8, mtime: 0 } - } - throw new Error('ENOENT') - }, - readFile: async (filePath) => { - if (filePath.endsWith('/favicon.png')) { - await Promise.resolve() - } - return { content: PNG_BASE64, isBinary: true, mimeType: 'image/png' } - } - }) - - await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toMatchObject({ - source: 'file', - label: 'favicon.png' - }) - }) - - it('bounds concurrent remote probes when no conventional icon exists', async () => { - let activeStats = 0 - let maxActiveStats = 0 - const stat = vi.fn(async (): Promise => { - activeStats += 1 - maxActiveStats = Math.max(maxActiveStats, activeStats) - await Promise.resolve() - activeStats -= 1 - throw new Error('ENOENT') - }) - const provider = remoteFilesystemProvider({ - stat, - readFile: async () => { - throw new Error('unexpected read') - } - }) - - await expect(detectRepoFileIcon('/repo', sshRoute('m4air', provider))).resolves.toBeNull() - expect(maxActiveStats).toBeGreaterThan(1) - expect(maxActiveStats).toBeLessThanOrEqual(6) - }) -}) - describe('detectRepoFileIcon connection boundary', () => { - it('never reads the client filesystem for a remote repo whose provider is missing', async () => { + it('never reads the client filesystem for a repo on an SSH target', async () => { vi.mocked(stat).mockClear() vi.mocked(readFile).mockClear() - await expect(detectRepoFileIcon('/repo', sshRoute('ssh-target-1', null))).resolves.toBeNull() + await expect( + detectRepoFileIcon('/repo', resolveFilesystemRouteForHost('ssh:ssh-target-1')) + ).resolves.toBeNull() expect(stat).not.toHaveBeenCalled() expect(readFile).not.toHaveBeenCalled() @@ -143,12 +58,7 @@ describe('detectRepoFileIcon connection boundary', () => { }) describe('declared repo icons through production filesystem routes', () => { - it.each([ - ['local', false], - ['ssh', false], - ['local', true], - ['ssh', true] - ] as const)('preserves declared icon detection on %s (no icon: %s)', async (kind, noIcon) => { + it.each([false, true])('preserves declared icon detection (no icon: %s)', async (noIcon) => { const directory = await mkdtemp(join(tmpdir(), 'orca-icon-href-')) const source = noIcon ? 'a'.repeat(256 * 1024) @@ -157,30 +67,9 @@ describe('declared repo icons through production filesystem routes', () => { await mkdir(join(directory, 'public')) await writeFile(join(directory, 'index.html'), source) await writeFile(join(directory, 'public', 'chosen.png'), Buffer.from(PNG_BASE64, 'base64')) - const provider = remoteFilesystemProvider({ - stat: async (path) => { - const info = await stat(path) - return { - type: info.isFile() ? 'file' : 'directory', - size: info.size, - mtime: info.mtimeMs - } - }, - readFile: async (path) => { - const buffer = await readFile(path) - const isBinary = path.endsWith('.png') - return { - content: buffer.toString(isBinary ? 'base64' : 'utf8'), - isBinary, - mimeType: isBinary ? 'image/png' : 'text/html' - } - } - }) - const route: ExecutionHostFilesystemRoute = - kind === 'local' - ? resolveFilesystemRouteForHost('local') - : sshRoute('icon-oracle', provider) - await expect(detectRepoFileIcon(directory, route)).resolves.toEqual( + await expect( + detectRepoFileIcon(directory, resolveFilesystemRouteForHost('local')) + ).resolves.toEqual( noIcon ? null : { diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts index 831248b94b3..2ff785e20ca 100644 --- a/src/main/repo-icon-file-detection.ts +++ b/src/main/repo-icon-file-detection.ts @@ -2,7 +2,6 @@ import { readFile, stat } from 'node:fs/promises' import { buildImageDataUri } from '../shared/image-data-uri' import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch' -import type { IFilesystemProvider } from './providers/types' import { extractIconHref } from './repo-icon-source-href' import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' @@ -124,32 +123,11 @@ async function readLocalImageIcon( return repoIconFromImageBuffer(buffer, relativePath) } -async function readRemoteImageIcon( - repoPath: string, - fsProvider: IFilesystemProvider, - relativePath: string -): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await fsProvider.stat(filePath) - if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const result = await fsProvider.readFile(filePath) - if (!result.content) { - return null - } - // Why: detect the binary format after decoding remote file content. - const buffer = result.isBinary - ? Buffer.from(result.content, 'base64') - : Buffer.from(result.content, 'utf8') - return repoIconFromImageBuffer(buffer, relativePath) -} - async function detectConventionalImageIcon( readIcon: (relativePath: string) => Promise ): Promise { - // Why: SSH stats are network round trips; bounded batches avoid making the - // expanded candidate list serial without flooding the remote filesystem. + // Why: bounded batches keep the expanded candidate list from probing serially (slow on + // WSL/network mounts) without flooding the filesystem. for ( let offset = 0; offset < REPO_ICON_FILE_CANDIDATES.length; @@ -209,48 +187,6 @@ async function detectLocalImageIcon(repoPath: string): Promise return null } -async function detectRemoteImageIcon( - repoPath: string, - fsProvider: IFilesystemProvider -): Promise { - const conventionalIcon = await detectConventionalImageIcon((relativePath) => - readRemoteImageIcon(repoPath, fsProvider, relativePath) - ) - if (conventionalIcon) { - return conventionalIcon - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await fsProvider.stat(sourcePath) - if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const result = await fsProvider.readFile(sourcePath) - if (result.isBinary) { - continue - } - const href = extractIconHref(result.content) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readRemoteImageIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - /** * Takes the resolved host route rather than a `connectionId`, because the incumbent shape spelled * "this is local", "this host is unreachable" and "this is a runtime host" all as a falsy id — and @@ -264,13 +200,8 @@ export function detectRepoFileIcon( case 'local': return detectLocalImageIcon(repoPath) case 'ssh': - // A dropped provider fails closed: repoPath lives on the SSH host, so a same-named local - // path would hand back another repository's icon. - return route.provider - ? detectRemoteImageIcon(repoPath, route.provider) - : Promise.resolve(null) case 'runtime': - // That environment's server holds these files; this process has no route to them. + // These files live on another host; a same-named local path would answer for another repo. return Promise.resolve(null) } } diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 02495c9ff5c..694e5b2da3e 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -10,6 +10,7 @@ import { import { isFolderRepo } from '../shared/repo-kind' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' +import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' @@ -87,12 +88,13 @@ async function listRoutedRepoWorktrees( // Why: runtime worktree resolution can run before SSH providers have reattached during startup. // Never fall back to local git against a server path, and never report the unreachable host as an // empty catalog (#14004) — callers treat a resolved listing as authoritative. - if (!route.provider) { + const provider = getSshGitProvider(route.connectionId) + if (!provider) { throw new WorktreeCatalogUnavailableError( `Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.` ) } - return await route.provider.listWorktrees(repo.path) + return await provider.listWorktrees(repo.path) } const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listLocal(repo.path, options) @@ -122,7 +124,8 @@ export async function listRepoWorktreeGraph( return [] } if (route.kind === 'ssh') { - return route.provider ? await route.provider.listWorktrees(repo.path) : [] + const provider = getSshGitProvider(route.connectionId) + return provider ? await provider.listWorktrees(repo.path) : [] } const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listWorktreeGraph(repo.path, options) diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts index 27a667ebfc6..9817ee9b48d 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts @@ -12,6 +12,7 @@ import { useTerminalArtifactTempFiles } from './orca-runtime-files-terminal-artifact-fixtures' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './orca-runtime-files' import type { RuntimeFileCommands } from './orca-runtime-files' @@ -436,66 +437,15 @@ describe('RuntimeFileCommands', () => { expect(writeTerminalArtifact).toHaveBeenCalled() }) - it('reads a remote non-temp artifact cited by native chat', async () => { - const artifactPath = '/home/me/report.json' + it('refuses a native-chat artifact on an SSH target as not served', async () => { const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture( - artifactPath, + '/home/me/report.json', true ) - const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) - const target = absoluteFileTarget(result) await expect( - commands.readTerminalArtifactFile( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).resolves.toMatchObject({ content: '{"ok":true}' }) - expect(readTerminalArtifact).toHaveBeenCalledWith( - artifactPath, - expect.objectContaining({ expectedRealPath: artifactPath }) - ) - }) - - it('rejects a retargeted remote native-chat artifact grant', async () => { - const artifactPath = '/home/me/report.json' - const { commands, readTerminalArtifact, moveArtifactTarget } = - createRemoteTerminalArtifactGrantFixture(artifactPath, true) - const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) - const target = absoluteFileTarget(result) - - moveArtifactTarget('/home/me/private.json') - - await expect( - commands.readTerminalArtifactFile( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).rejects.toThrow('terminal_file_grant_stale') - expect(readTerminalArtifact).not.toHaveBeenCalled() - }) - - it('rejects a replaced remote native-chat artifact grant', async () => { - const artifactPath = '/home/me/report.json' - const { commands, readTerminalArtifact, replaceArtifact } = - createRemoteTerminalArtifactGrantFixture(artifactPath, true) - const result = await resolveRemoteNativeChatArtifact(commands, artifactPath) - const target = absoluteFileTarget(result) - - replaceArtifact() - - await expect( - commands.readTerminalArtifactFile( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).rejects.toThrow('terminal_file_grant_stale') + resolveRemoteNativeChatArtifact(commands, '/home/me/report.json') + ).rejects.toThrow(SshHostNotServedError) expect(readTerminalArtifact).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts index 93ef7412f47..236b0194012 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -345,8 +345,7 @@ vi.mock('../../providers/ssh-git-dispatch', () => ({ vi.mock('../../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: getActiveMultiplexerMock, - getRegisteredSshState: () => ({ remotePlatform: 'linux' }), - setSshActiveMultiplexerResolver: vi.fn() + getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) vi.mock('../../preflight/agent-detection', () => ({ diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts index 21d22cb079b..16e95666674 100644 --- a/src/main/runtime/runtime-file-command-target.ts +++ b/src/main/runtime/runtime-file-command-target.ts @@ -10,7 +10,10 @@ import type { LocalFilesystemProvider, LocalFilesystemProviderOptions } from '../providers/local-filesystem-provider' -import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from '../providers/types' export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } @@ -60,7 +63,11 @@ export function runtimeFileRouteForTarget(target: { case 'local': return { kind: 'local', createProvider: route.createProvider } case 'ssh': - return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider } + return { + kind: 'ssh', + connectionId: route.connectionId, + provider: getSshFilesystemProvider(route.connectionId) ?? null + } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) } diff --git a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts index 97f73394985..a13aaf37bff 100644 --- a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts +++ b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts @@ -8,7 +8,8 @@ import { import type { IFilesystemProvider } from '../providers/types' import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, - getSshFilesystemProvider + getSshFilesystemProvider, + requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { requireRuntimeFileProvider, @@ -30,8 +31,6 @@ import { watchFileExplorerInWatcherProcess } from './file-watcher-host' import { registerRuntimeFileWatcherRelease } from './runtime-file-watcher-leases' -import { requireFilesystemProviderForHost } from '../providers/execution-host-provider-dispatch' -import { toSshExecutionHostId } from '../../shared/execution-host' export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical extends RuntimeFileCommandsWithWriteTerminalArtifactFile { protected async assertRemoteTerminalFileGrantPathStillCanonical( @@ -40,7 +39,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni if (!grant.connectionId) { throw new Error('terminal_file_grant_mismatch') } - const provider = requireFilesystemProviderForHost(toSshExecutionHostId(grant.connectionId)) + const provider = requireSshFilesystemProvider(grant.connectionId) const canonicalPath = grant.provenance === 'native-chat' ? await provider.realpath(grant.absolutePath) diff --git a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts index e6a7c62b16b..cfd6c935c14 100644 --- a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts @@ -24,11 +24,9 @@ import { runtimeFileSshTargetId, type ResolvedRuntimeFileTarget } from './runtime-file-command-target' -import { - requireReachableFilesystemRoute, - requireFilesystemProviderForHost -} from '../providers/execution-host-provider-dispatch' -import { getConnectionExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host' +import { requireReachableFilesystemRoute } from '../providers/execution-host-provider-dispatch' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { getConnectionExecutionHostId } from '../../shared/execution-host' export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath { protected async resolveAllowedTerminalArtifactPath(args: { @@ -46,10 +44,8 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R absolutePath: string, connectionId?: string ): Promise { - const route = requireReachableFilesystemRoute(getConnectionExecutionHostId(connectionId)) - return route.kind === 'ssh' - ? route.provider.realpath(absolutePath) - : canonicalPathForArtifactComparison(absolutePath) + requireReachableFilesystemRoute(getConnectionExecutionHostId(connectionId)) + return canonicalPathForArtifactComparison(absolutePath) } protected async resolveAbsoluteFileGrant(args: { @@ -115,7 +111,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R absolutePath: string, connectionId: string ): Promise { - const provider = requireFilesystemProviderForHost(toSshExecutionHostId(connectionId)) + const provider = requireSshFilesystemProvider(connectionId) const roots = ['/tmp', '/private/tmp'] const providerTempDir = await provider.getTempDir?.().catch(() => null) if (providerTempDir) { diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index a913335836e..01c6bcdd4d8 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -9,7 +9,7 @@ import { resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' import type { createLocalGitProvider, LocalGitProvider } from '../providers/local-git-provider' -import { sshGitProviderMissingError } from '../providers/ssh-git-dispatch' +import { getSshGitProvider, sshGitProviderMissingError } from '../providers/ssh-git-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' @@ -71,7 +71,11 @@ export function runtimeGitRouteForTarget(target: RuntimeGitTarget): RuntimeGitRo case 'local': return { kind: 'local', createProvider: route.createProvider } case 'ssh': - return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider } + return { + kind: 'ssh', + connectionId: route.connectionId, + provider: getSshGitProvider(route.connectionId) ?? null + } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) } diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index 4f29919ca95..9c028e01eea 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -4,6 +4,7 @@ import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import type { Repo } from '../../shared/repo-types' import type { Worktree } from '../../shared/worktree/types' import { gitExecFileAsync } from '../git/runner' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isENOENT } from '../ipc/filesystem-auth' import { getLocalWorktreePathAccess, @@ -44,7 +45,7 @@ export async function isRuntimeWorktreePathMissing( access.statPath ) } - const fsProvider = route.provider + const fsProvider = getSshFilesystemProvider(route.connectionId) return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false } diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 205df659b2e..fa0e02d5c55 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -757,7 +757,7 @@ describe('getHostedReviewCreationEligibility', () => { expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) - it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { + it('resolves remote eligibility from SSH repo metadata without running git or PR copy', async () => { const remoteGit = { exec: vi.fn(async (args: string[]) => ({ stdout: @@ -793,13 +793,8 @@ describe('getHostedReviewCreationEligibility', () => { expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( expect.objectContaining({ repoPath: '/remote/repo', executionHostId: 'ssh:ssh-1' }) ) - // Why: the base-on-remote probe must run on the SSH host that will execute - // the provider create, so it flows through the relay exec, not local git. - expect(remoteGit.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') - expect(remoteGit.exec).toHaveBeenCalledWith( - ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/main'], - '/remote/repo' - ) + // An SSH host is not served here, so its base probe never runs as local git either. + expect(remoteGit.exec).not.toHaveBeenCalled() }) it('offers push as the next action for authenticated branches with local-only commits', async () => { diff --git a/src/main/source-control/hosted-review-creation-git-state.ts b/src/main/source-control/hosted-review-creation-git-state.ts index 57ee336bbee..a989dee9337 100644 --- a/src/main/source-control/hosted-review-creation-git-state.ts +++ b/src/main/source-control/hosted-review-creation-git-state.ts @@ -3,7 +3,6 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import type { GitUpstreamStatus } from '../../shared/git-status-types' import { gitExecFileAsync } from '../github/gh-utils' @@ -13,11 +12,7 @@ import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelai import { resolveDefaultBaseRefViaExec } from '../git/repo' import { getUpstreamStatus } from '../git/upstream' import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection' -import { - ExecutionHostNotDispatchableError, - resolveGitRouteForHost, - type ExecutionHostGitRoute -} from '../providers/execution-host-provider-dispatch' +import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import type { ExecutionHostId } from '../../shared/execution-host' import { getHostedReviewLocalGitOptions, @@ -125,25 +120,8 @@ async function listSuffixRemoteBaseRefs( /** * Why not a `connectionId` check: `null` used to mean "local", "runtime host" and "unresolved" * alike, so a worktree whose owner is named only by `executionHostId` had its preflight git run - * against this machine's copy of a remote path. `runtime:` is a routing mistake here rather than a - * fallback — that environment's server runs its own git. + * against this machine's copy of a remote path. `runtime:` and `ssh:` hosts throw instead. */ -function requireHostedReviewGitRoute(executionHostId: ExecutionHostId): ExecutionHostGitRoute { - const route = resolveGitRouteForHost(executionHostId) - if (route.kind === 'runtime') { - throw new ExecutionHostNotDispatchableError(route.hostId) - } - return route -} - -/** Loss of contact is not locality: an SSH host with no provider refuses, it does not run here. */ -function requireHostedReviewSshProvider(route: ExecutionHostGitRoute) { - if (route.kind !== 'ssh' || !route.provider) { - throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') - } - return route.provider -} - async function runGitForHostedReview( repoPath: string, args: string[], @@ -151,13 +129,7 @@ async function runGitForHostedReview( options: HostedReviewExecutionOptions = {}, commandOptions: HostedReviewGitRunOptions = {} ): Promise<{ stdout: string; stderr?: string }> { - const route = requireHostedReviewGitRoute(executionHostId) - if (route.kind === 'ssh') { - const provider = requireHostedReviewSshProvider(route) - return commandOptions.timeoutMs === undefined - ? provider.exec(args, repoPath) - : provider.exec(args, repoPath, { timeoutMs: commandOptions.timeoutMs }) - } + requireReachableGitRoute(executionHostId) return gitExecFileAsync(args, { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), @@ -267,13 +239,7 @@ export async function hasUncommittedChanges( executionHostId: ExecutionHostId, options: HostedReviewExecutionOptions = {} ): Promise { - const route = requireHostedReviewGitRoute(executionHostId) - if (route.kind === 'ssh') { - // Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks. - // No shared-link exclusion here: remote worktree creation skips the symlink - // and shared-directory passes entirely, so a remote worktree never has one. - return (await requireHostedReviewSshProvider(route).getStatus(repoPath)).entries.length > 0 - } + requireReachableGitRoute(executionHostId) // Why: `-z` keeps paths raw so the shared-link comparison below can't be // defeated by Git quoting a path with spaces or non-ASCII bytes. const { stdout } = await gitExecFileAsync(['status', '--porcelain', '-z'], { @@ -321,18 +287,6 @@ export async function getHostedReviewUpstreamStatus( executionHostId: ExecutionHostId, options: HostedReviewExecutionOptions = {} ): Promise { - const route = requireHostedReviewGitRoute(executionHostId) - if (route.kind !== 'ssh') { - return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options)) - } - const provider = requireHostedReviewSshProvider(route) - try { - // Why: the relay blocks generic git.exec, so use its dedicated upstream RPC for SSH divergence. - return await provider.getUpstreamStatus(repoPath) - } catch (error) { - if (isNoUpstreamError(error)) { - return { hasUpstream: false, ahead: 0, behind: 0 } - } - throw new Error(normalizeGitErrorMessage(error, 'upstream')) - } + requireReachableGitRoute(executionHostId) + return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options)) } diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 7845c7bcebb..8ce2eae3459 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -572,81 +572,6 @@ describe('createHostedReview', () => { expect(createGitLabMergeRequestMock).not.toHaveBeenCalled() }) - it('uses the SSH git provider for remote hosted-review preflight', async () => { - const remoteGit = { - getStatus: vi.fn(async () => ({ entries: [], conflictOperation: 'unknown' })), - getUpstreamStatus: vi.fn(async () => ({ - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 0, - behind: 0 - })), - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { - return { stdout: 'feature\n', stderr: '' } - } - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - return { stdout: 'abc refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'log' && args.includes('--pretty=%s')) { - return { stdout: 'Feature title\n', stderr: '' } - } - if (args[0] === 'log') { - return { stdout: '- Feature title\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - - await expect( - createHostedReview( - '/remote/repo', - { - provider: 'github', - base: 'main', - head: 'feature', - title: 'Feature' - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 12, - url: 'https://github.com/acme/orca/pull/12' - }) - - expect(remoteGit.exec).toHaveBeenCalledWith( - ['rev-parse', '--abbrev-ref', 'HEAD'], - '/remote/repo' - ) - expect(remoteGit.getStatus).toHaveBeenCalledWith('/remote/repo') - expect(remoteGit.exec).not.toHaveBeenCalledWith(['status', '--porcelain'], '/remote/repo') - expect(remoteGit.getUpstreamStatus).toHaveBeenCalledWith('/remote/repo') - expect(remoteGit.exec).not.toHaveBeenCalledWith( - ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], - '/remote/repo' - ) - expect(getUpstreamStatusMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'github.com'], - { host: 'github.com' } - ) - expect(createGitHubPullRequestMock).toHaveBeenCalledWith( - '/remote/repo', - { - provider: 'github', - base: 'main', - head: 'feature', - title: 'Feature' - }, - 'ssh:ssh-1' - ) - }) - it('returns the existing review instead of creating a duplicate', async () => { getHostedReviewForBranchMock.mockResolvedValue({ provider: 'github', diff --git a/src/main/source-control/hosted-review-execution-host-routing.test.ts b/src/main/source-control/hosted-review-execution-host-routing.test.ts index 8a555ed4acc..1a84f5909bb 100644 --- a/src/main/source-control/hosted-review-execution-host-routing.test.ts +++ b/src/main/source-control/hosted-review-execution-host-routing.test.ts @@ -165,6 +165,7 @@ import { hostedReviewSshConnectionId } from './hosted-review-execution-host' import { RuntimeHostedReviewCommands } from '../runtime/runtime-hosted-review-commands' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' const REPO_PATH = '/remote/workspace/repo' @@ -354,43 +355,17 @@ describe('hosted-review lookups route on the resolved execution host', () => { }) describe('hosted-review creation routes on the resolved execution host', () => { - it('runs the GitLab preflight and create on the executionHostId-only SSH host', async () => { + it('refuses GitLab preflight on an executionHostId-only SSH host without running git here', async () => { getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' }) const commands = makeCommands(makeRepo({ executionHostId: 'ssh:ssh-a' })) - const result = await commands.createHostedReview({ - repoSelector: 'repo-1', - provider: 'gitlab', - ...CREATE_INPUT - }) + await expect( + commands.createHostedReview({ repoSelector: 'repo-1', provider: 'gitlab', ...CREATE_INPUT }) + ).rejects.toThrow(SshHostNotServedError) - expect(result.ok).toBe(true) - expect(sshTargetThatRanGit()).toBe('ssh-a') + expect(sshTargetThatRanGit()).toBeNull() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(createGitLabMergeRequestMock).toHaveBeenCalledWith( - REPO_PATH, - expect.objectContaining({ provider: 'gitlab' }), - 'ssh:ssh-a' - ) - }) - - it('runs the GitHub preflight on the executionHostId host, not the connectionId one', async () => { - getRepoSlugMock.mockResolvedValue({ host: 'github.com', owner: 'a', repo: 'b' }) - const commands = makeCommands(makeRepo({ connectionId: 'ssh-b', executionHostId: 'ssh:ssh-a' })) - - const result = await commands.createHostedReview({ - repoSelector: 'repo-1', - provider: 'github', - ...CREATE_INPUT - }) - - expect(result.ok).toBe(true) - expect(sshTargetThatRanGit()).toBe('ssh-a') - expect(createGitHubPullRequestMock).toHaveBeenCalledWith( - REPO_PATH, - expect.objectContaining({ provider: 'github' }), - 'ssh:ssh-a' - ) + expect(createGitLabMergeRequestMock).not.toHaveBeenCalled() }) it('creates a Bitbucket review locally for a runtime row rather than dialing its nested target', async () => { @@ -428,7 +403,7 @@ describe('hosted-review creation routes on the resolved execution host', () => { }) expect(getAzureDevOpsRepoSlugMock).toHaveBeenCalledWith(REPO_PATH, 'ssh-a') - expect(sshTargetThatRanGit()).toBe('ssh-a') + expect(sshTargetThatRanGit()).toBeNull() }) it('resolves the GitHub slug for a repo listing on the row it is asked about', async () => { diff --git a/src/main/ssh/ssh-target-registry.test.ts b/src/main/ssh/ssh-target-registry.test.ts new file mode 100644 index 00000000000..660736311ea --- /dev/null +++ b/src/main/ssh/ssh-target-registry.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' +import { getActiveMultiplexer } from './ssh-target-registry' + +describe('getActiveMultiplexer', () => { + it('refuses with the typed not-served error, not a resolver wiring error', () => { + expect(() => getActiveMultiplexer('ssh-1')).toThrow(SshHostNotServedError) + expect(() => getActiveMultiplexer('ssh-1')).toThrow( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) + expect(() => getActiveMultiplexer('ssh-1')).not.toThrow( + 'ssh_active_multiplexer_resolver_not_installed' + ) + }) +}) diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index 90636a99310..b03b0d88444 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -1,4 +1,5 @@ import type { SshConnectionStore } from './ssh-connection-store' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { SshConnectionManager } from './ssh-connection-manager' import type { @@ -87,32 +88,12 @@ export function getRegisteredSshTargetMutations(): SshTargetMutations { return registeredSshTargetMutations } -let registeredGetActiveMultiplexer: - | ((connectionId: string) => SshChannelMultiplexer | undefined) - | null = null - -export function setSshActiveMultiplexerResolver( - resolve: ((connectionId: string) => SshChannelMultiplexer | undefined) | null -): void { - registeredGetActiveMultiplexer = resolve -} - /** - * The live channel multiplexer for a connection, or undefined when the target is not - * connected. Undefined means "not connected", never "the connection died" — callers - * must not read absence here as an `exited` verdict (docs/reference/ssh-execution-boundary.md). - * - * Why it throws when no resolver is installed rather than returning undefined: that - * case is a wiring error, not a connection state, and the two are indistinguishable to - * callers. A host that never loaded the SSH layer would otherwise report every target as - * quietly "not connected" — which is precisely the unverifiable-reported-as-exited - * conflation the execution-boundary doc exists to prevent. + * The relay channel multiplexer is gone, so no SSH target is served here: every caller is a dead + * SSH arm and gets the typed "not served" error, never an `exited` verdict. */ export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { - if (!registeredGetActiveMultiplexer) { - throw new Error('ssh_active_multiplexer_resolver_not_installed') - } - return registeredGetActiveMultiplexer(connectionId) + throw new SshHostNotServedError(connectionId) } let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index 3bf3c6cf1f4..fe2edbcebd3 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -16,6 +16,8 @@ import { resolveFilesystemRouteForHost, resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' @@ -102,14 +104,15 @@ async function listWorktreesForSpaceScan( } } if (route.kind === 'ssh') { - if (!route.provider) { + const provider = getSshGitProvider(route.connectionId) + if (!provider) { return { ok: false, status: 'unavailable', error: `SSH connection "${route.connectionId}" is not connected.` } } - const worktrees = await route.provider.listWorktrees(repo.path, { signal }) + const worktrees = await provider.listWorktrees(repo.path, { signal }) throwIfWorkspaceSpaceScanAborted(signal) return { ok: true, worktrees } } @@ -205,6 +208,8 @@ export async function scanWorkspaceSpaceRepo(args: { options.onProgress ) const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo)) + const sshFilesystemProvider = + filesystemRoute.kind === 'ssh' ? getSshFilesystemProvider(filesystemRoute.connectionId) : null const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { throwIfWorkspaceSpaceScanAborted(options.signal) reportProgress( @@ -217,12 +222,12 @@ export async function scanWorkspaceSpaceRepo(args: { ) const row = filesystemRoute.kind !== 'local' - ? filesystemRoute.kind === 'ssh' && filesystemRoute.provider + ? sshFilesystemProvider ? await scanRemoteWorkspaceSpaceWorktree( repo, worktree, scannedAt, - filesystemRoute.provider, + sshFilesystemProvider, limiters.remoteFallbackTraversal, options.signal ) diff --git a/src/main/worktree-removal-execution-host-route.ts b/src/main/worktree-removal-execution-host-route.ts index 0b5db5a3b8c..b040985d915 100644 --- a/src/main/worktree-removal-execution-host-route.ts +++ b/src/main/worktree-removal-execution-host-route.ts @@ -39,10 +39,11 @@ import { } from './worktree-removal-home-guard' import { ExecutionHostNotDispatchableError, - resolveFilesystemRouteForHost, resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './providers/ssh-git-dispatch' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { SshHostNotServedError } from './providers/ssh-host-not-served-error' import type { SshGitProvider } from './providers/ssh-git-provider' import type { IFilesystemProvider } from './providers/types' @@ -69,16 +70,16 @@ export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRe case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) case 'ssh': { - if (!route.provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + const provider = getSshGitProvider(route.connectionId) + if (!provider) { + throw new SshHostNotServedError(route.connectionId) } - const fsRoute = resolveFilesystemRouteForHost(hostId) return { kind: 'ssh', hostId: route.hostId, connectionId: route.connectionId, - provider: route.provider, - fsProvider: fsRoute.kind === 'ssh' ? fsRoute.provider : null + provider, + fsProvider: getSshFilesystemProvider(route.connectionId) ?? null } } } @@ -87,7 +88,7 @@ export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRe /** * Reads the `$HOME` an active SSH session resolved on its host. * - * Injected the same way `setSshActiveMultiplexerResolver` is, because importing + * Injected rather than imported, because importing * the session table here would run its registration inside every suite that * partially mocks the SSH registry. Unresolved stays `null` — "unknown", never * "this client's home".