diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts index b48150f8a02..894d80edf80 100644 --- a/src/main/azure-devops/pull-request-creation.test.ts +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -7,20 +7,14 @@ import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-requ import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -32,7 +26,6 @@ describe('Azure DevOps pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n', stderr: '' @@ -183,44 +176,6 @@ describe('Azure DevOps pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) - it('resolves Azure DevOps remotes through the SSH git provider', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ - stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n', - stderr: '' - })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - globalThis.fetch = vi.fn(async () => - Response.json({ - pullRequestId: 38, - title: 'Remote Azure create', - status: 'active', - creationDate: '2026-06-01T00:00:00Z' - }) - ) as never - - await expect( - createAzureDevOpsPullRequest( - '/remote/repo', - { - provider: 'azure-devops', - base: 'main', - head: 'feature/azure', - title: 'Remote Azure create' - }, - 'ssh:ssh-1' - ) - ).resolves.toMatchObject({ - ok: true, - number: 38 - }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('classifies auth failures without retrying shell commands', async () => { globalThis.fetch = vi.fn(async () => Response.json({ message: 'Unauthorized' }, { status: 401 }) diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 8b8a98f625b..425d2548059 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -15,19 +14,15 @@ import { getAzureDevOpsRepoRefForRemote, parseAzureDevOpsRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('parseAzureDevOpsRepoRef', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetAzureDevOpsRepoRefCache() }) afterEach(() => { - unregisterSshGitProvider('conn-1') _resetAzureDevOpsRepoRefCache() }) @@ -102,28 +97,6 @@ describe('parseAzureDevOpsRepoRef', () => { expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull() }) - it('resolves repository refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - host: 'dev.azure.com', - organization: 'acme', - project: 'Project', - repository: 'repo', - apiBaseUrl: 'https://dev.azure.com/acme/Project', - webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('keeps local host and local WSL repository-ref cache entries separate', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ @@ -165,14 +138,13 @@ describe('parseAzureDevOpsRepoRef', () => { }) it('bounds cached repository refs for distinct repo paths', async () => { - sshExecMock.mockResolvedValue({ + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) for (let i = 0; i < 513; i += 1) { - await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin', 'conn-1') + await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin') } expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512) diff --git a/src/main/bitbucket/pull-request-creation.test.ts b/src/main/bitbucket/pull-request-creation.test.ts index 88fc73bc5de..cb0e0b26236 100644 --- a/src/main/bitbucket/pull-request-creation.test.ts +++ b/src/main/bitbucket/pull-request-creation.test.ts @@ -5,20 +5,14 @@ import { } from './pull-request-creation' import { _resetBitbucketRepoRefCache } from './repository-ref' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -49,7 +43,6 @@ describe('Bitbucket pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://bitbucket.org/team/repo.git\n', stderr: '' diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index 42aa0454c1f..2831efae16c 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -12,24 +11,18 @@ vi.mock('../git/runner', () => ({ import { _getBitbucketRepoRefCacheSize, _resetBitbucketRepoRefCache, - getBitbucketRepoRefForRemote, getBitbucketRepoRef, parseBitbucketRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Bitbucket repository refs', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetBitbucketRepoRefCache() }) - afterEach(() => { - unregisterSshGitProvider('conn-1') - }) + afterEach(() => {}) it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => { expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({ @@ -108,22 +101,4 @@ describe('Bitbucket repository refs', () => { expect(_getBitbucketRepoRefCacheSize()).toBe(512) }) - - it('resolves project refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@bitbucket.org:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - workspace: 'remote', - repoSlug: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index 73b6414ebd0..fc450d1c305 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -1,17 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - callRuntimeEnvironment: vi.fn(), - readDocPreviewFile: vi.fn(), - requireSshFilesystemProvider: vi.fn() + callRuntimeEnvironment: vi.fn() })) vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: mocks.callRuntimeEnvironment })) -vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/user-data' })) import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' @@ -53,9 +50,6 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType { vi.clearAllMocks() revokeAllDocPreviewGrants() - mocks.requireSshFilesystemProvider.mockReturnValue({ - readDocPreviewFile: mocks.readDocPreviewFile - }) }) describe('docPreviewContentType', () => { @@ -68,131 +62,15 @@ describe('docPreviewContentType', () => { }) describe('readDocPreviewFile — ssh owner', () => { - it('reads text through the SSH filesystem provider', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + it('404s with the not-served message instead of reading anything', async () => { + const grant = sshGrant() - const outcome = await readDocPreviewFile(sshGrant(), 'index.html') - - expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') - expect(mocks.readDocPreviewFile).toHaveBeenCalledWith({ - boundaryPath: '/home/alice/docs', - entryPath: '/home/alice/docs/index.html', - implicitRootPath: null, - authorizedRootPaths: ['/home/alice/docs'], - targetPath: '/home/alice/docs/index.html', - maxTextBytes: 10 * 1024 * 1024, - maxBinaryBytes: 10 * 1024 * 1024 - }) - expect(outcome).toEqual({ - ok: true, - bytes: Buffer.from('

hi

', 'utf8'), - contentType: 'text/html; charset=utf-8' - }) - }) - - it('decodes a base64 binary asset', async () => { - const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) - mocks.readDocPreviewFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) - }) - - // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is - // always whole and needs no truncation flag. - it('serves a whole SSH read that carries no truncation flag', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '

whole

', isBinary: false }) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) - }) - - // Older hosts refuse fonts without bytes or a MIME type; keep that answer explicit. - it('reports a file type the host will not send as unsupported-asset', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '', isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') - - expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) - }) - - // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — - // reporting it as a refused format would be a failure the workspace never reported. - it('serves an empty file the host still typed instead of calling it unsupported', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ - content: '', - isBinary: true, - mimeType: 'image/png' - }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) - }) - - it('404s when the host cannot canonicalize the path at all', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + await expect(readDocPreviewFile(grant, 'index.html')).resolves.toMatchObject({ ok: false, - status: 404 - }) - expect(mocks.readDocPreviewFile).toHaveBeenCalledOnce() - }) - - it('404s a path outside the grant root without touching the provider', async () => { - const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') - - expect(outcome).toMatchObject({ ok: false, status: 404 }) - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - }) - - it('requires approval for a sibling directory before touching the SSH provider', async () => { - const grant = mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - requestBase: '/home/alice', - root: '/home/alice/docs', - entryRelativePath: 'docs/index.html', - browserPageId: 'page-1' - }) - - await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ - ok: false, - status: 403, - reason: 'authorization-required' - }) - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - - authorizeDocPreviewDirectory(grant.id, 'assets/logo.png') - mocks.readDocPreviewFile.mockResolvedValue({ content: 'logo', isBinary: false }) - - await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true }) - expect(mocks.readDocPreviewFile).toHaveBeenCalledWith( - expect.objectContaining({ - boundaryPath: '/home/alice', - implicitRootPath: '/home/alice/docs', - authorizedRootPaths: ['/home/alice/assets'], - targetPath: '/home/alice/assets/logo.png' - }) - ) - }) - - it('reports an over-cap SSH file as too large rather than unreadable', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('file_too_large')) - - expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ - ok: false, - status: 413 - }) - }) - - it('404s when the provider read fails', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ - ok: false, - status: 404 + status: 404, + message: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() }) }) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts index 3531d5065aa..959d314ca0f 100644 --- a/src/main/browser/doc-preview-file-reader.ts +++ b/src/main/browser/doc-preview-file-reader.ts @@ -3,9 +3,8 @@ import type { RuntimeFilePreviewResult } from '../../shared/runtime-file-contrac import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' import { FONT_FILE_MIME_TYPES } from '../../shared/font-file-extensions' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { getCanonicalUserDataPath } from '../persistence' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { resolveDocPreviewAuthorityPaths, resolveDocPreviewCandidatePath, @@ -16,8 +15,6 @@ import { } from './doc-preview-grant-registry' const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 -const DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES = 10 * 1024 * 1024 -const DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES = 10 * 1024 * 1024 /** Older hosts may refuse a binary format, so report the file type they cannot send. */ const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' @@ -29,16 +26,12 @@ const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to /** The paired host rejects an over-cap asset outright instead of clamping it. */ const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' -/** Same stance as the SSH relay message: previews fail closed on a host without scoped reads. */ +/** Previews fail closed on a host without scoped reads. */ const RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE = 'Secure document previews require a newer Orca on the paired machine. Update it and try again.' -/** Both owners refuse an over-cap file; only their error shapes differ. */ function isTooLargeReadError(error: unknown): boolean { - return ( - error instanceof FileReadCapExceededError || - (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) - ) + return error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR } export type DocPreviewReadOutcome = @@ -168,23 +161,7 @@ export async function readDocPreviewFile( const contentType = docPreviewContentType(relativePath) try { if (grant.owner.kind === 'ssh') { - const provider = requireSshFilesystemProvider(grant.owner.connectionId) - const authority = resolveDocPreviewAuthorityPaths(grant) - if (!authority.entryPath || !provider.readDocPreviewFile) { - return notFoundOutcome() - } - return toOutcome( - await provider.readDocPreviewFile({ - boundaryPath: grant.requestBase, - entryPath: authority.entryPath, - implicitRootPath: authority.implicitRootPath, - authorizedRootPaths: authority.authorizedRootPaths, - targetPath: absolutePath, - maxTextBytes: DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES, - maxBinaryBytes: DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES - }), - contentType - ) + return notFoundOutcome(SSH_HOST_NOT_SERVED_MESSAGE) } const runtimeOwner = grant.owner const worktreeRelativePath = toRuntimeWorktreeRelativePath( diff --git a/src/main/ephemeral-vm-runtime-ssh.test.ts b/src/main/ephemeral-vm-runtime-ssh.test.ts index 9e65afe6349..46f27166309 100644 --- a/src/main/ephemeral-vm-runtime-ssh.test.ts +++ b/src/main/ephemeral-vm-runtime-ssh.test.ts @@ -5,8 +5,10 @@ const mocks = vi.hoisted(() => ({ remove: vi.fn(async () => {}) })) +vi.mock('./ssh/ssh-target-registry', () => ({ + connectRegisteredSshTarget: mocks.connect +})) vi.mock('./ipc/ssh', () => ({ - connectRegisteredSshTarget: mocks.connect, getSshConnectionStore: () => ({ upsertRuntimeOwnedTarget: (runtimeId: string) => ({ id: `runtime-ssh-${runtimeId}`, diff --git a/src/main/ephemeral-vm-runtime-ssh.ts b/src/main/ephemeral-vm-runtime-ssh.ts index 63d23ff0094..6b6580ecae0 100644 --- a/src/main/ephemeral-vm-runtime-ssh.ts +++ b/src/main/ephemeral-vm-runtime-ssh.ts @@ -1,4 +1,5 @@ -import { connectRegisteredSshTarget, getSshConnectionStore } from './ipc/ssh' +import { getSshConnectionStore } from './ipc/ssh' +import { connectRegisteredSshTarget } from './ssh/ssh-target-registry' import { disconnectRegisteredSshTarget, removeRegisteredSshTarget diff --git a/src/main/git/remote-ref-probe-cache.test.ts b/src/main/git/remote-ref-probe-cache.test.ts index c1f6b0f0981..3058facc9c0 100644 --- a/src/main/git/remote-ref-probe-cache.test.ts +++ b/src/main/git/remote-ref-probe-cache.test.ts @@ -1,17 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { getSshGitProviderMock, getSshGitProviderGenerationMock, gitExecFileAsyncMock } = vi.hoisted( - () => ({ - getSshGitProviderMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - gitExecFileAsyncMock: vi.fn() - }) -) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable' +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -35,9 +25,6 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { describe('remote ref probe cache (P1-D)', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) gitExecFileAsyncMock.mockReset() vi.useFakeTimers() vi.setSystemTime(1_000_000) @@ -89,27 +76,6 @@ describe('remote ref probe cache (P1-D)', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('does not let a lookup on a reconnected provider join the old connection probe', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - let releaseStalled = (): void => {} - const execMock = vi - .fn() - .mockImplementationOnce( - async () => await new Promise((resolve) => (releaseStalled = () => resolve({ stdout: '' }))) - ) - .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) - getSshGitProviderMock.mockReturnValue({ exec: execMock }) - - const stalled = cache.get('/repo', 'origin', 'conn-1') - getSshGitProviderGenerationMock.mockReturnValue(1) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - expect(execMock).toHaveBeenCalledTimes(2) - - releaseStalled() - await expect(stalled).resolves.toBeNull() - }) - it('does not let a probe abandoned as stale overwrite its successor answer', async () => { const cache = createRemoteRefProbeCache(parseExampleRef) const stalled = deferred<{ stdout: string }>() @@ -143,46 +109,12 @@ describe('remote ref probe cache (P1-D)', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('never caches a miss from a runtime that could not be asked at all', async () => { + it('answers an SSH repo with null without running git on this machine', async () => { const cache = createRemoteRefProbeCache(parseExampleRef) - getSshGitProviderMock.mockReturnValueOnce(undefined) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - - // The provider was gone, not the remote: reconnecting must not need the TTL. - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(async () => ({ stdout: 'git@example.com:team/repo.git\n' })) - }) - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - }) - - it('holds an SSH repo that has no such remote instead of re-asking every poll', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - const exec = vi.fn(async () => { - throw new Error("fatal: No such remote 'origin'") - }) - getSshGitProviderMock.mockReturnValue({ exec }) await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('keeps re-asking an SSH repo whose probe died with its transport', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - const exec = vi - .fn() - .mockRejectedValueOnce(new Error('relay request failed: connection closed')) - .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) - getSshGitProviderMock.mockReturnValue({ exec }) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - expect(exec).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('does not cache a probe killed on its deadline as a definitive miss', async () => { diff --git a/src/main/git/remote-ref-probe-cache.ts b/src/main/git/remote-ref-probe-cache.ts index 8f8255c1046..f69780c4f2b 100644 --- a/src/main/git/remote-ref-probe-cache.ts +++ b/src/main/git/remote-ref-probe-cache.ts @@ -1,7 +1,5 @@ -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe' -import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' import type { GitAdmissionTier } from './command-runner/git-exec-options' /** @@ -86,10 +84,7 @@ export function createRemoteRefProbeCache( }, remoteName ) - // Why: null is the SSH runtime being disconnected, not an answer about the - // remote — and it costs no `git`, so there is nothing here to spare. It is - // deliberately the one negative with no TTL floor: flooring it would make a - // reconnected host wait the interval out for a probe it could serve now. + // Why: null is an SSH repo this process cannot read, not an answer about the remote. if (stdout === null) { return null } @@ -97,16 +92,10 @@ export function createRemoteRefProbeCache( publish(result) return result } catch (error) { - // Why: a probe killed on its deadline says nothing about the remote, and an - // SSH failure is usually a reconnect or tunnel state rather than an answer. - // Only "no such remote" is the repo itself saying it is not this provider's - // — anything else cached would poison it, on SSH for the generation's life. + // Why: a probe killed on its deadline says nothing about the remote. if (isTransientGitProbeError(error)) { return null } - if (connectionId && !isStableMissingGitRemoteError(error)) { - return null - } publish(null) return null } @@ -114,12 +103,7 @@ export function createRemoteRefProbeCache( return { async get(repoPath, remoteName, connectionId, localGitOptions = {}) { - // Why: a reconnect retires the connection an answer came from, and with it - // the probe still running on it — stamping the generation stops a caller on - // the new connection from adopting either. - const runtimeKey = connectionId - ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` - : `local:${localGitOptions.wslDistro ?? 'host'}` + const runtimeKey = connectionId || `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` const cached = repoRefCache.get(cacheKey) if (cached) { diff --git a/src/main/git/remote-url-probe.test.ts b/src/main/git/remote-url-probe.test.ts index a075f188b11..feb4ed4b1fd 100644 --- a/src/main/git/remote-url-probe.test.ts +++ b/src/main/git/remote-url-probe.test.ts @@ -1,16 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getSshGitProviderMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ - getSshGitProviderMock: vi.fn(), +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable', - sshGitProviderMissingError: () => new Error('SSH Git provider unavailable') -})) - vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' @@ -23,7 +16,6 @@ import { describe('remote URL probe', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockReset() }) @@ -48,27 +40,6 @@ describe('remote URL probe', () => { }) }) - it('bounds the SSH remote read with the same deadline as the local one', async () => { - const exec = vi.fn( - async (_args: string[], _cwd: string, _options?: { signal?: AbortSignal }) => ({ - stdout: 'git@github.com:acme/orca.git\n' - }) - ) - getSshGitProviderMock.mockReturnValue({ exec }) - - await expect( - readRemoteUrl({ repoPath: '/repo', connectionId: 'ssh-1' }, 'origin') - ).resolves.toContain('github.com') - - const [args, cwd, options] = exec.mock.calls[0] - expect(args).toEqual(['remote', 'get-url', 'origin']) - expect(cwd).toBe('/repo') - // The relay bounds each phase separately and resets on every frame, so only a - // signal spans the whole round trip. - expect(options?.signal).toBeInstanceOf(AbortSignal) - expect(options?.signal?.aborted).toBe(false) - }) - 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' @@ -93,8 +64,6 @@ describe('remote URL probe', () => { }) 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.toThrow(SshHostNotServedError) diff --git a/src/main/git/remote-url-probe.ts b/src/main/git/remote-url-probe.ts index 8cc90896165..bd232a361b8 100644 --- a/src/main/git/remote-url-probe.ts +++ b/src/main/git/remote-url-probe.ts @@ -1,4 +1,3 @@ -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { gitExecFileAsync } from './runner' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' import type { GitAdmissionTier } from './command-runner/git-exec-options' @@ -13,9 +12,7 @@ import { getConnectionExecutionHostId } from '../../shared/execution-host' * wedged host — a dead network mount or stalled WSL interop. Unbounded, the call * never returns and every caller above it hangs with it. Passing a timeout is * also what arms the runner's kill path; Node's own waits forever on a child - * that ignores signals. The SSH branch spends the same budget as one deadline - * over the whole round trip: the relay's own bounds are per-phase and restart on - * every frame, so a relay dribbling output outlives them. + * that ignores signals. */ export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000 @@ -26,20 +23,13 @@ export type RemoteUrlProbeContext = { admissionTier?: GitAdmissionTier } -/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */ +/** Reads a remote URL, or null for an SSH repo, which this process does not serve. */ export async function readRemoteUrl( context: RemoteUrlProbeContext, remoteName: string ): Promise { if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath, { - signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS) - }) - return stdout + return null } const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: context.repoPath, diff --git a/src/main/gitea/client-scan-invalidation.test.ts b/src/main/gitea/client-scan-invalidation.test.ts index b93bf4e1824..d25fcd07f24 100644 --- a/src/main/gitea/client-scan-invalidation.test.ts +++ b/src/main/gitea/client-scan-invalidation.test.ts @@ -6,10 +6,6 @@ vi.mock('../git/runner', () => ({ stderr: '' })) })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSSHGitProvider: vi.fn(() => undefined), - getSSHGitProviderGeneration: () => 0 -})) vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => '') })) diff --git a/src/main/gitea/pull-request-creation.test.ts b/src/main/gitea/pull-request-creation.test.ts index a94123737d3..30a79586db9 100644 --- a/src/main/gitea/pull-request-creation.test.ts +++ b/src/main/gitea/pull-request-creation.test.ts @@ -2,20 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation' import { _resetGiteaRepoRefCache } from './repository-ref' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -27,7 +21,6 @@ describe('Gitea pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://git.example.com/code/team/repo.git\n', stderr: '' @@ -102,46 +95,6 @@ describe('Gitea pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) - it('resolves Gitea remotes through the SSH git provider', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ - stdout: 'git@git.example.com:code/team/repo.git\n', - stderr: '' - })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - globalThis.fetch = vi.fn(async () => - Response.json({ - number: 14, - title: 'Remote Gitea create', - state: 'open', - html_url: 'https://git.example.com/code/team/repo/pulls/14', - updated_at: '2026-06-01T00:00:00Z', - mergeable: true - }) - ) as never - - await expect( - createGiteaPullRequest( - '/remote/repo', - { - provider: 'gitea', - base: 'main', - head: 'feature/gitea', - title: 'Remote Gitea create' - }, - 'ssh:ssh-1' - ) - ).resolves.toMatchObject({ - ok: true, - number: 14 - }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('classifies validation failures from the REST API', async () => { globalThis.fetch = vi.fn(async () => Response.json({ message: 'Validation failed' }, { status: 422 }) diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index ddd4076fcbd..b33a3f7abb0 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -13,23 +12,17 @@ import { _getGiteaRepoRefCacheSize, _resetGiteaRepoRefCache, getGiteaRepoRef, - getGiteaRepoRefForRemote, parseGiteaRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Gitea repository ref parsing', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetGiteaRepoRefCache() }) - afterEach(() => { - unregisterSshGitProvider('conn-1') - }) + afterEach(() => {}) it('parses HTTPS remotes and derives the API base URL', () => { expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({ @@ -139,23 +132,4 @@ describe('Gitea repository ref parsing', () => { expect(_getGiteaRepoRefCacheSize()).toBe(512) }) - - it('resolves repository refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@gitea.example.test:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({ - host: 'gitea.example.test', - owner: 'remote', - repo: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index f2b9dc39ad1..6abfa5908cf 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { readFile } from 'node:fs/promises' const { ghExecFileAsyncMock, @@ -8,8 +7,7 @@ const { getEnterpriseGitHubRepoSlugMock, extractExecErrorMock, acquireMock, - releaseMock, - getSshFilesystemProviderMock + releaseMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), @@ -23,12 +21,7 @@ const { } }), acquireMock: vi.fn(), - releaseMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock + releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ @@ -88,7 +81,6 @@ describe('createGitHubPullRequest', () => { extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() - getSshFilesystemProviderMock.mockReset() acquireMock.mockResolvedValue(undefined) }) @@ -354,76 +346,6 @@ describe('createGitHubPullRequest', () => { expect(options).not.toHaveProperty('cwd') }) - it.each([ - ['.github/pull_request_template.md', ['.github/pull_request_template.md']], - [ - '.github/PULL_REQUEST_TEMPLATE.md', - ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md'] - ], - [ - 'docs/PULL_REQUEST_TEMPLATE.md', - [ - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - 'pull_request_template.md', - 'PULL_REQUEST_TEMPLATE.md', - 'docs/pull_request_template.md', - 'docs/PULL_REQUEST_TEMPLATE.md' - ] - ] - ] as [string, string[]][])( - 'reads PR templates from the SSH filesystem provider at %s', - async (relativeTemplatePath, expectedRelativeLookups) => { - const templateBody = `Remote template body from ${relativeTemplatePath}` - const readRemoteFile = vi.fn(async (path: string) => { - if (path === `/remote/repo-root/${relativeTemplatePath}`) { - return { - content: templateBody, - isBinary: false - } - } - throw new Error('missing template') - }) - getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - const writtenBodies: string[] = [] - ghExecFileAsyncMock.mockImplementationOnce(async (args: string[]) => { - const bodyPath = args[args.indexOf('--body-file') + 1] - writtenBodies.push(await readFile(bodyPath, 'utf8')) - return { - stdout: JSON.stringify({ - number: 46, - url: 'https://github.com/acme/widgets/pull/46' - }) - } - }) - - await expect( - createGitHubPullRequest( - '/remote/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/ssh-template', - title: 'SSH Template PR', - body: '', - useTemplate: true - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 46, - url: 'https://github.com/acme/widgets/pull/46' - }) - - expect(readRemoteFile.mock.calls.map(([path]) => path)).toEqual( - expectedRelativeLookups.map((relativeLookup) => `/remote/repo-root/${relativeLookup}`) - ) - expect(writtenBodies).toEqual([templateBody]) - } - ) - it('falls back to parsing the PR URL for older gh output', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index f0d62ff75c6..60694245d48 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts index e24c903b891..6ec9cd5242a 100644 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ b/src/main/github/client-merged-pr-visibility.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 3db1e981ed1..ca41a3361f3 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-comment-reactions.test.ts b/src/main/github/client-pr-comment-reactions.test.ts index 4123dbc94a8..31436e42139 100644 --- a/src/main/github/client-pr-comment-reactions.test.ts +++ b/src/main/github/client-pr-comment-reactions.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts index 2df6b81b609..79455340ff7 100644 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ b/src/main/github/client-pr-conflict-summary.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 397a22fa1b5..c3e6282347d 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index a7ce6cce203..4e0d94da810 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 0633b137b1c..f9d7eb91111 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -84,11 +84,6 @@ vi.mock('./github-enterprise-repository', () => ({ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: vi.fn(() => 0), - getSshGitProvider: vi.fn() -})) - vi.mock('./conflict-summary', () => ({ getPRConflictSummary: vi.fn() })) diff --git a/src/main/github/client-pr-lookup-coalescing.test.ts b/src/main/github/client-pr-lookup-coalescing.test.ts index 6227f9ece75..28e159a3da9 100644 --- a/src/main/github/client-pr-lookup-coalescing.test.ts +++ b/src/main/github/client-pr-lookup-coalescing.test.ts @@ -28,7 +28,6 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client/lookup/branch-lookup-resolution', () => ({ resolvePRForBranchOutcome: mocks.resolve })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 })) import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 } diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index 210cfbf6d51..1e13e41c3ec 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-state.test.ts b/src/main/github/client-pr-state.test.ts index 3522d302206..50a7fc04f41 100644 --- a/src/main/github/client-pr-state.test.ts +++ b/src/main/github/client-pr-state.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-rate-limit-block.test.ts b/src/main/github/client-rate-limit-block.test.ts index a5070fbb4ba..54c695607ff 100644 --- a/src/main/github/client-rate-limit-block.test.ts +++ b/src/main/github/client-rate-limit-block.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) 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 6b6ce2da5db..875fd10406d 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) @@ -34,7 +33,6 @@ const { ghExecFileAsyncMock, getOwnerRepoMock, gitExecFileAsyncMock, - getSshGitProviderMock, resolvePRRepositoryCandidatesMock } = clientMocks @@ -123,7 +121,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => { - getSshGitProviderMock.mockReturnValue(undefined) mockMergedBranchPRLookup() gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }) @@ -171,7 +168,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => { - getSshGitProviderMock.mockReturnValue(undefined) mockUpstreamOnlyPRLookup() gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, @@ -186,9 +182,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => { - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn().mockRejectedValue(new Error('SSH transport closed')) - }) mockMergedBranchPRLookup() const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1') @@ -198,9 +191,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => { - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn().mockRejectedValue(new Error('SSH transport closed')) - }) mockUpstreamOnlyPRLookup() const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1') diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index ce2cdc540f3..859ef902d98 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 48a7f2d284e..3e3bf4357f3 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-test-harness.ts b/src/main/github/client-test-harness.ts index 645c4f8a0c5..ae8e3c8a692 100644 --- a/src/main/github/client-test-harness.ts +++ b/src/main/github/client-test-harness.ts @@ -41,7 +41,6 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void { mocks.noteRateLimitSpendMock.mockReset() mocks.ghRepoExecOptionsMock.mockClear() mocks.githubRepoContextMock.mockClear() - mocks.getSshGitProviderMock.mockReset() mocks.readLocalGitConfigSignatureMock.mockReset() mocks.readLocalGitConfigSignatureMock.mockResolvedValue(undefined) mocks.acquireMock.mockReset() diff --git a/src/main/github/client-test-mocks.ts b/src/main/github/client-test-mocks.ts index 15fe0f36214..4868f41fb11 100644 --- a/src/main/github/client-test-mocks.ts +++ b/src/main/github/client-test-mocks.ts @@ -23,7 +23,6 @@ export type GitHubClientMocks = { noteRateLimitSpendMock: Mock ghRepoExecOptionsMock: Mock githubRepoContextMock: Mock - getSshGitProviderMock: Mock readLocalGitConfigSignatureMock: Mock acquireMock: Mock releaseMock: Mock @@ -62,7 +61,6 @@ export function createGitHubClientMocks(): GitHubClientMocks { ...localGitOptions }) ), - getSshGitProviderMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -131,22 +129,10 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo } } -export type SshGitDispatchModuleMock = { - getSshGitProviderGeneration: Mock - getSshGitProvider: Mock - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string +export type LocalGitConfigSignatureModuleMock = { + readLocalGitConfigSignature: Mock } -export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock { - return { - getSshGitProviderGeneration: vi.fn(() => 0), - getSshGitProvider: mocks.getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.' - } -} - -export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock } - export function localGitConfigSignatureModuleMock( mocks: GitHubClientMocks ): LocalGitConfigSignatureModuleMock { diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index e92dfc76c48..3cf563fb61d 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -10,7 +10,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) 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 b46f9463256..617bbaff365 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index bc70d2ff01c..bb1d78a6212 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index 0fc997c8793..d9ff4097ee0 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -1,5 +1,4 @@ import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' -import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch' import { githubReadExecutionScope } from '../../github-read-execution-scope' import type { PRCheckDetail } from '../../../../shared/github/check-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' @@ -253,7 +252,6 @@ export function getPRChecks( prRepo ?? null, Boolean(options?.noCache), connectionId ?? null, - connectionId ? getSshGitProviderGeneration(connectionId) : null, githubReadExecutionScope(localGitOptions) ]) return runCoalescedProbe(checksReads, key, () => diff --git a/src/main/github/client/create/pull-request-template.ts b/src/main/github/client/create/pull-request-template.ts index 5d60966a0c2..5ceceb74372 100644 --- a/src/main/github/client/create/pull-request-template.ts +++ b/src/main/github/client/create/pull-request-template.ts @@ -1,7 +1,5 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' import { getHostedReviewLocalGitOptions, @@ -60,21 +58,11 @@ export async function readPullRequestTemplate( 'docs/pull_request_template.md', 'docs/PULL_REQUEST_TEMPLATE.md' ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of relativeCandidates) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional PR template path. diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts index 9de76705737..33e741b4c24 100644 --- a/src/main/github/client/lookup/pr-for-branch-outcome.ts +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -4,7 +4,6 @@ import { scopeGeneration } from '../../../source-control/hosted-review-scope-generations' import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' -import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch' import { githubReadExecutionScope } from '../../github-read-execution-scope' import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' @@ -35,7 +34,6 @@ export async function getPRForBranchOutcome( const key = JSON.stringify([ executionScope, - connectionId ? getSshGitProviderGeneration(connectionId) : null, repoPath, scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))), branchName, diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index c4faa88a528..803d02d3f72 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -35,7 +35,6 @@ const { noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, - getSshGitProviderMock, readLocalGitConfigSignatureMock, acquireMock, releaseMock @@ -67,7 +66,6 @@ const { connectionId: connectionId ?? null, ...localGitOptions })), - getSshGitProviderMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -107,10 +105,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) @@ -221,7 +215,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open noteRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() - getSshGitProviderMock.mockReset() readLocalGitConfigSignatureMock.mockReset() readLocalGitConfigSignatureMock.mockResolvedValue(undefined) acquireMock.mockReset() diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index c239dc026f2..5ccddbd57fe 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -3,24 +3,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - getSshGitProviderMock: vi.fn() - }) -) +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - getSshGitProvider: getSshGitProviderMock -})) - import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { _getOwnerRepoCacheSize, @@ -73,9 +64,6 @@ function gitRemoteListCalls(): unknown[][] { describe('github owner/repo resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) - getSshGitProviderMock.mockReset() _resetOwnerRepoCache() _resetRemoteNameListingCache() __resetLocalGitConfigSignatureCacheForTests() @@ -200,52 +188,6 @@ describe('github owner/repo resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('resolves SSH repo remotes through the registered SSH git provider', async () => { - const sshProvider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] !== 'get-url') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[2] === 'upstream') { - throw new Error("fatal: No such remote 'upstream'") - } - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) - - await expect(getOwnerRepo('/home/user/orca', 'openclaw-2')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2') - expect(sshProvider.exec).toHaveBeenCalledWith( - ['remote', 'get-url', 'origin'], - '/home/user/orca', - { - signal: expect.any(AbortSignal) - } - ) - }) - - it('keeps local and SSH owner/repo cache entries separate for the same path', async () => { - const sshProvider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] !== 'get-url') { - return { stdout: 'origin\n', stderr: '' } - } - return { stdout: 'git@github.com:remote/orca.git\n', stderr: '' } - }) - } - mockGitRemoteCommands({ origin: 'git@github.com:local/orca.git\n' }) - getSshGitProviderMock.mockReturnValue(sshProvider) - - await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'local', repo: 'orca' }) - await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' }) - }) - it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => { gitExecFileAsyncMock.mockImplementation( async (args: string[], options: { wslDistro?: string } = {}) => { @@ -747,7 +689,6 @@ describe('github owner/repo resolution', () => { describe('resolveIssueSource', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() _resetOwnerRepoCache() _resetRemoteNameListingCache() }) diff --git a/src/main/github/github-api-repository-probe.ts b/src/main/github/github-api-repository-probe.ts index 08d0c5fd6e3..569f4153bb0 100644 --- a/src/main/github/github-api-repository-probe.ts +++ b/src/main/github/github-api-repository-probe.ts @@ -1,5 +1,4 @@ import type { LocalGitExecOptions } from './github-repository-identity' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' export function githubApiRepositoryProbeCacheKey( repoPath: string, @@ -9,7 +8,7 @@ export function githubApiRepositoryProbeCacheKey( requireVerifiedSshProbe: boolean ): string { const runtimeKey = connectionId - ? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}` } diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts index b9fc0f159f2..a660ff71c93 100644 --- a/src/main/github/github-api-repository.test.ts +++ b/src/main/github/github-api-repository.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as SshGitDispatch from '../providers/ssh-git-dispatch' import type * as GitHubEnterpriseRepository from './github-enterprise-repository' import type * as GhUtils from './gh-utils' @@ -7,23 +6,16 @@ const { getEnterpriseGitHubRepoSlugMock, getOwnerRepoMock, getOwnerRepoForRemoteMock, - getSshGitProviderGenerationMock, isGitHubHostAuthenticatedMock, shouldProbeGitRemoteMock } = vi.hoisted(() => ({ getEnterpriseGitHubRepoSlugMock: vi.fn(), getOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), isGitHubHostAuthenticatedMock: vi.fn(), shouldProbeGitRemoteMock: vi.fn(async () => true) })) -vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({ - ...(await importOriginal()), - getSshGitProviderGeneration: getSshGitProviderGenerationMock -})) - vi.mock('./gh-utils', async (importOriginal) => ({ ...(await importOriginal()), getOwnerRepo: getOwnerRepoMock, @@ -57,7 +49,6 @@ beforeEach(() => { getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null) getOwnerRepoMock.mockReset().mockResolvedValue(null) getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null) - getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false) shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true) }) @@ -204,23 +195,6 @@ describe('resolveGitHubRepoExecution', () => { }) describe('origin repository cache', () => { - it('isolates Enterprise identity across SSH provider generations', async () => { - const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } - const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' } - getEnterpriseGitHubRepoSlugMock - .mockResolvedValueOnce(beforeReconnect) - .mockResolvedValueOnce(afterReconnect) - - await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual( - beforeReconnect - ) - getSshGitProviderGenerationMock.mockReturnValue(1) - await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual( - afterReconnect - ) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => { getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined) @@ -280,83 +254,6 @@ describe('origin repository cache', () => { await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) }) - - it('does not reuse a cached answer after the SSH provider reconnects', async () => { - const oldRepository = { - owner: 'old-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - const newRepository = { - owner: 'new-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - getSshGitProviderGenerationMock.mockReturnValue(1) - getEnterpriseGitHubRepoSlugMock - .mockResolvedValueOnce(oldRepository) - .mockResolvedValueOnce(newRepository) - - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - - getSshGitProviderGenerationMock.mockReturnValue(2) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - - it('does not reuse a cached negative after the SSH provider reconnects', async () => { - const repository = { - owner: 'acme', - repo: 'widgets', - host: 'github.acme-corp.com' - } - getSshGitProviderGenerationMock.mockReturnValue(1) - getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository) - - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - - getSshGitProviderGenerationMock.mockReturnValue(2) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - - it('isolates an in-flight probe from an older SSH provider generation', async () => { - const oldRepository = { - owner: 'old-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - const newRepository = { - owner: 'new-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - let resolveOldProbe: (repository: typeof oldRepository) => void = () => {} - const oldProbeResult = new Promise((resolve) => { - resolveOldProbe = resolve - }) - getEnterpriseGitHubRepoSlugMock - .mockImplementationOnce(() => oldProbeResult) - .mockResolvedValueOnce(newRepository) - getSshGitProviderGenerationMock.mockReturnValue(1) - - const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') - await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)) - - getSshGitProviderGenerationMock.mockReturnValue(2) - const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') - await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)) - await expect(newProbe).resolves.toEqual(newRepository) - - resolveOldProbe(oldRepository) - await expect(oldProbe).resolves.toEqual(oldRepository) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) }) describe('skip missing upstream remote probes', () => { diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index c6605be58c5..d6db52e01b9 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -27,7 +27,6 @@ import { isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' function mockOriginRemote(url: string): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -437,74 +436,6 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('does not reuse a cached answer after the SSH provider reconnects', async () => { - const connectionId = 'ssh-reconnected-auth-cache' - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', - stderr: '' - }) - .mockResolvedValueOnce({ - stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', - stderr: '' - }) - - registerSshGitProvider(connectionId, {} as never) - try { - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(true) - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - - registerSshGitProvider(connectionId, {} as never) - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(false) - } finally { - unregisterSshGitProvider(connectionId) - } - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('does not join an in-flight auth probe from an older SSH provider generation', async () => { - const connectionId = 'ssh-reconnected-auth-in-flight' - let finishOldProbe: (() => void) | undefined - ghExecFileAsyncMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishOldProbe = () => - resolve({ - stdout: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', - stderr: '' - }) - }) - ) - .mockResolvedValueOnce({ - stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', - stderr: '' - }) - - registerSshGitProvider(connectionId, {} as never) - try { - const oldProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - registerSshGitProvider(connectionId, {} as never) - const newProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - - await expect(newProbe).resolves.toBe(false) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - finishOldProbe?.() - await expect(oldProbe).resolves.toBe(true) - } finally { - unregisterSshGitProvider(connectionId) - } - }) - // The probe answers differ by runtime: a local repo gets a cwd, a // connection-backed repo does not. Sharing one entry attributes whichever // resolved first to both hosts. diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 6e356665e5a..83bd264baee 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -19,7 +19,6 @@ import { } from './github-remote-identity-parsing' import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution' import { parseWslPath } from '../wsl' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../shared/execution-host' @@ -46,14 +45,13 @@ const hostAuthInFlight = new Map>() // repos, but the answer still describes that connection's runtime (it is probed // without the repository cwd). Keying it per connection stops a local repo and // an SSH-hosted repo at the same path from adopting each other's auth state. -// Provider generation fences stale answers when reconnect replaces the runtime. function runtimeCacheKey( repoPath: string, connectionId?: string | null, wslDistro?: string ): string { if (connectionId) { - return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + return `connection:${connectionId}` } const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 4eef43a27af..67657046933 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -20,10 +20,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ ghExecFileAsync: ghExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: () => null -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) diff --git a/src/main/github/github-repository-identity.signed-cache.test.ts b/src/main/github/github-repository-identity.signed-cache.test.ts index 0b11e2f8331..862b0673308 100644 --- a/src/main/github/github-repository-identity.signed-cache.test.ts +++ b/src/main/github/github-repository-identity.signed-cache.test.ts @@ -13,12 +13,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: () => null, - getSshGitProviderGeneration: () => 0, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'ssh git provider unavailable' -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) 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 bd3a2dab6a6..5b718e397ab 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 @@ -3,15 +3,11 @@ import type * as GitRunner from '../git/runner' const { commandExecFileAsyncMock, - getSshGitProviderGenerationMock, - getSshGitProviderMock, gitExecFileAsyncMock, resolveWithSshGMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ commandExecFileAsyncMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - getSshGitProviderMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), resolveWithSshGMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284') @@ -23,13 +19,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', - sshGitProviderMissingError: () => new Error('Remote connection dropped.') -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) @@ -73,29 +62,10 @@ function mockRemoteUrl(url: string): void { }) } -function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') { - return { - exec: vi.fn().mockResolvedValue({ - stdout: `${remoteUrl}\n`, - stderr: '' - }), - execNonInteractive: vi.fn().mockResolvedValue({ - stdout: `hostname ${hostname}\nport 22\n`, - stderr: '', - exitCode: 0, - timedOut: false, - canceled: false - }) - } -} - beforeEach(() => { _resetOwnerRepoCache() _resetSshHostnameResolutionCache() commandExecFileAsyncMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockReset() resolveWithSshGMock.mockReset() readLocalGitConfigSignatureMock.mockClear() @@ -158,27 +128,7 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(resolveWithSshGMock).not.toHaveBeenCalled() }) - it('resolves aliases inside the repository SSH runtime', async () => { - const provider = sshProvider('github.com') - getSshGitProviderMock.mockReturnValue(provider) - getSshGitProviderGenerationMock.mockReturnValue(4) - - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(provider.execNonInteractive).toHaveBeenCalledWith( - 'ssh', - ['-G', '--', 'github-work'], - '/remote/repo', - 5_000 - ) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - it('does not read an SSH repoPath locally when its provider is missing', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - await expect( getOwnerRepoForRemote( '/remote/repo', @@ -213,43 +163,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('invalidates alias resolution when an SSH provider reconnects', async () => { - const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' } - getSshGitProviderGenerationMock.mockReturnValue(1) - getSshGitProviderMock.mockReturnValue(sshProvider('github.com')) - - await expect( - resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) - ).resolves.toEqual({ owner: 'team', repo: 'orca' }) - - getSshGitProviderGenerationMock.mockReturnValue(2) - getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com')) - await expect( - resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) - ).resolves.toBeNull() - }) - - it('invalidates owner/repo identity when an SSH provider reconnects', async () => { - getSshGitProviderGenerationMock.mockReturnValue(1) - getSshGitProviderMock.mockReturnValue( - sshProvider('github.com', 'git@github-work:team/orca.git') - ) - - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - - getSshGitProviderGenerationMock.mockReturnValue(2) - getSshGitProviderMock.mockReturnValue( - sshProvider('github.com', 'git@github-work:acme/widgets.git') - ) - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - }) - it('does not call ssh -G for literal github.com remotes', async () => { mockRemoteUrl('git@github.com:team/orca.git') diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 26b03d768d0..98d835568cf 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -2,7 +2,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { readRemoteUrl } from '../git/remote-url-probe' import type { GhAccountBinding } from '../../shared/github/account-binding' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { parseGitHubOwnerRepo, @@ -138,7 +137,7 @@ export async function getOwnerRepoForRemote( requireReachableGitRoute(getConnectionExecutionHostId(context.connectionId)) } const runtimeKey = context.connectionId - ? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}` + ? `ssh:${context.connectionId}` : `local:${context.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` const now = Date.now() diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts index 82c0f8b3fb4..8decd2ec8d0 100644 --- a/src/main/github/github-ssh-host-alias-resolution.ts +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -1,6 +1,5 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { commandExecFileAsync } from '../git/runner' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { parseWslPath } from '../wsl' import { resolveWithSshG } from '../ssh/ssh-g-config-resolution' import { @@ -58,8 +57,7 @@ function pruneSshHostnameCache(now: number): void { function sshRuntimeCacheKey(context: SshConfigResolutionContext): string { if (context.connectionId) { - const generation = getSshGitProviderGeneration(context.connectionId) - return `ssh:${context.connectionId}:${generation}` + return `ssh:${context.connectionId}` } const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro return `local:${distro?.toLowerCase() ?? 'host'}` @@ -80,23 +78,7 @@ async function resolveSshHostnameInRuntime( context: SshConfigResolutionContext ): Promise { if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - try { - const result = await provider.execNonInteractive( - 'ssh', - ['-G', '--', host], - context.repoPath, - SSH_G_TIMEOUT_MS - ) - return result.exitCode === 0 && !result.timedOut && !result.canceled - ? parseSshGHostname(result.stdout) - : null - } catch { - return null - } + return null } const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro diff --git a/src/main/github/pr-head-tracking-ref.test.ts b/src/main/github/pr-head-tracking-ref.test.ts index c70576f772f..df70d6ba060 100644 --- a/src/main/github/pr-head-tracking-ref.test.ts +++ b/src/main/github/pr-head-tracking-ref.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshGitProvider } from '../providers/ssh-git-provider' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -26,7 +25,7 @@ describe('fetchPrHeadTrackingRef', () => { }) it('fetches into the remote-tracking ref with real git for local repos', async () => { - await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, null, 'origin', 'feature/x') + await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, 'origin', 'feature/x') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['fetch', 'origin', '+refs/heads/feature/x:refs/remotes/origin/feature/x'], @@ -34,28 +33,9 @@ describe('fetchPrHeadTrackingRef', () => { ) }) - it('uses the SSH tracking-ref RPC for connected repos and never runs git directly', async () => { - const fetchRemoteTrackingRef = vi.fn(async () => {}) - - await fetchPrHeadTrackingRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchRemoteTrackingRef } as unknown as SshGitProvider, - 'origin', - 'feature/x' - ) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/repo', - 'origin', - 'feature/x', - 'refs/remotes/origin/feature/x' - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('throws when a connected repo has no available SSH provider', async () => { + it('throws for an SSH repo, which this process does not serve', async () => { await expect( - fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 'feature/x') + fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 'feature/x') ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -63,7 +43,6 @@ describe('fetchPrHeadTrackingRef', () => { it('fetches a GitHub pull head into its remote-scoped Orca ref for local repos', async () => { const localRef = await fetchGitHubPullRequestHeadRef( { path: '/repo', connectionId: null }, - null, 'origin', 42 ) @@ -86,7 +65,7 @@ describe('fetchPrHeadTrackingRef', () => { }) await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42) ).rejects.toThrow('Remote "origin" is not configured.') expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), @@ -95,7 +74,7 @@ describe('fetchPrHeadTrackingRef', () => { }) it('keeps WSL routing while bounding the pull-head fetch', async () => { - await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42, { + await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, { localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } }) @@ -109,40 +88,19 @@ describe('fetchPrHeadTrackingRef', () => { ) }) - it('uses the SSH GitHub pull-head RPC and never runs git directly', async () => { - const expectedRef = `refs/orca/pull/${ORIGIN_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => expectedRef) - - const localRef = await fetchGitHubPullRequestHeadRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchGitHubPullRequestHead } as unknown as SshGitProvider, - 'origin', - 42 - ) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/repo', 'origin', 42) - expect(localRef).toBe(expectedRef) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('rejects a connected GitHub pull-head fetch without an SSH provider', async () => { + it('rejects a GitHub pull-head fetch for an SSH repo', async () => { await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 42) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 42) ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('rejects invalid PR numbers and option-shaped remotes before running git', async () => { await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 4.2) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 4.2) ).rejects.toThrow('Invalid pull request number') await expect( - fetchGitHubPullRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - '--upload-pack=x', - 42 - ) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42) ).rejects.toThrow('must not start with "-"') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 9ffa7374225..f74057aa3e0 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -6,39 +6,33 @@ import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../providers/ssh-host-not-served-error' type LocalGitExecOptions = { cwd: string wslDistro?: string } -// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos -// must use the dedicated git.fetchRemoteTrackingRef RPC. export async function fetchPrHeadTrackingRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, branch: string, options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { - const ref = `refs/remotes/${remote}/${branch}` - if (!repo.connectionId) { - await gitExecFileAsync( - ['fetch', remote, `+refs/heads/${branch}:${ref}`], - options.localGitExecOptions ?? { cwd: repo.path } - ) - return + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - await sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) + await gitExecFileAsync( + ['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`], + options.localGitExecOptions ?? { cwd: repo.path } + ) } export async function fetchGitHubPullRequestHeadRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, prNumber: number, options: { localGitExecOptions?: LocalGitExecOptions } = {} @@ -49,22 +43,16 @@ export async function fetchGitHubPullRequestHeadRef( if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Pull request fetch remote must not start with "-".') } - if (!repo.connectionId) { - const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } - const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. - const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) - await gitExecFileAsync( - ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], - { - ...localGitExecOptions, - timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS - } - ) - return localRef + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - return sshGitProvider.fetchGitHubPullRequestHead(repo.path, remote, prNumber) + const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } + const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) + // Why: return the same path the fetch wrote so callers don't re-resolve identity. + const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) + await gitExecFileAsync( + ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], + { ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return localRef } diff --git a/src/main/github/pr-mutation-review-invalidation.test.ts b/src/main/github/pr-mutation-review-invalidation.test.ts index 5dcacd5ff55..5e3cc0a242a 100644 --- a/src/main/github/pr-mutation-review-invalidation.test.ts +++ b/src/main/github/pr-mutation-review-invalidation.test.ts @@ -42,7 +42,6 @@ vi.mock('./client/lookup/pr-number-lookup', () => ({ vi.mock('./client/lookup/branch-lookup-resolution', () => ({ resolvePRForBranchOutcome: mocks.resolve })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 })) vi.mock('../ipc/github-work-item-mutation-events', () => ({ broadcastGitHubWorkItemMutation: vi.fn() })) diff --git a/src/main/gitlab/gitlab-known-host-probe.test.ts b/src/main/gitlab/gitlab-known-host-probe.test.ts index e3bab30c803..ec999ab2d03 100644 --- a/src/main/gitlab/gitlab-known-host-probe.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe.test.ts @@ -12,7 +12,6 @@ import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('getGlabKnownHosts', () => { // A locally-keyed probe must never answer from the default WSL distro. @@ -280,24 +279,4 @@ describe('getGlabKnownHosts', () => { ]) expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - - it('does not reuse a successful result after an SSH provider reconnects', async () => { - const connectionId = 'conn-reconnected' - registerSshGitProvider(connectionId, {} as never) - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' }) - - await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ - 'gitlab.com', - 'old-tunnel.test' - ]) - registerSshGitProvider(connectionId, {} as never) - await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ - 'gitlab.com', - 'new-tunnel.test' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) - unregisterSshGitProvider(connectionId) - }) }) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 6f65e189d6f..0376ed62b2b 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,7 +1,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' @@ -13,10 +12,7 @@ export type LocalGitExecOptions = { const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128 export const KNOWN_HOSTS_CACHE_MAX_ENTRIES = 128 -const knownHostsCacheByExecutionContext = new Map< - string, - { key: string; hosts: readonly string[] } ->() +const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext: CoalescedProbes = new Map() const unauthenticatedHostExpiries = new Map() @@ -25,25 +21,11 @@ function knownHostsExecutionKey( localGitOptions: LocalGitExecOptions = {} ): string { if (connectionId) { - // Why: reconnecting can replace the SSH/relay execution host under the same id. - return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + return `connection:${connectionId}` } return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' } -function knownHostsCacheContext( - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): { key: string; cacheKey: string } { - const key = knownHostsExecutionKey(connectionId, localGitOptions) - const cacheKey = connectionId ? `connection:${connectionId}` : key - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - if (cached && cached.key !== key) { - knownHostsCacheByExecutionContext.delete(cacheKey) - } - return { key, cacheKey } -} - /** @internal - exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() @@ -135,8 +117,8 @@ export function rememberGlabKnownHosts( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): void { - const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts ?? DEFAULT_GITLAB_HOSTS + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) ?? DEFAULT_GITLAB_HOSTS const seen = new Set(cached.map(normalizeGitLabHost)) const additions: string[] = [] for (const host of hosts) { @@ -153,7 +135,7 @@ export function rememberGlabKnownHosts( if (additions.length === 0) { return } - knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: [...cached, ...additions] }) + knownHostsCacheByExecutionContext.set(key, [...cached, ...additions]) trimKnownHostsCache() } @@ -161,33 +143,29 @@ export async function getGlabKnownHosts( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) if (cached) { - const entry = knownHostsCacheByExecutionContext.get(cacheKey) - if (entry) { - knownHostsCacheByExecutionContext.delete(cacheKey) - knownHostsCacheByExecutionContext.set(cacheKey, entry) - } + knownHostsCacheByExecutionContext.delete(key) + knownHostsCacheByExecutionContext.set(key, cached) return cached } // Why: only join a probe still young enough to answer, so a wedged one cannot // pin every later retry for the life of the process (P1-D). return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, (ownsKey) => - probeGlabKnownHosts(key, cacheKey, ownsKey, connectionId, localGitOptions) + probeGlabKnownHosts(key, ownsKey, connectionId, localGitOptions) ) } async function probeGlabKnownHosts( key: string, - cacheKey: string, ownsKey: () => boolean, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { try { - // Why: auth config belongs to the executing host; do not share native, WSL, - // or reconnected SSH/relay results, and bound an otherwise global probe. + // Why: auth config belongs to the executing host; do not share native, WSL or + // connection results, and bound an otherwise global probe. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, // Why: a local probe would cache the default distro's auth under 'native' @@ -200,18 +178,16 @@ async function probeGlabKnownHosts( ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - const remembered = cached?.key === key ? cached.hosts : [] + const remembered = knownHostsCacheByExecutionContext.get(key) ?? [] const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts])) - if (ownsKey() && knownHostsExecutionKey(connectionId, localGitOptions) === key) { - knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: merged }) + if (ownsKey()) { + knownHostsCacheByExecutionContext.set(key, merged) trimKnownHostsCache() } return merged } catch { // Keep failures uncached so auth or tunnel recovery is discovered later. - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - return cached?.key === key ? cached.hosts : [...DEFAULT_GITLAB_HOSTS] + return knownHostsCacheByExecutionContext.get(key) ?? [...DEFAULT_GITLAB_HOSTS] } } diff --git a/src/main/gitlab/gitlab-known-host-retirement.test.ts b/src/main/gitlab/gitlab-known-host-retirement.test.ts index 60bb2be13b2..fe2a75fd85c 100644 --- a/src/main/gitlab/gitlab-known-host-retirement.test.ts +++ b/src/main/gitlab/gitlab-known-host-retirement.test.ts @@ -1,42 +1,16 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const { execute, generations } = vi.hoisted(() => ({ - execute: vi.fn(), - generations: new Map() -})) +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })) vi.mock('../git/runner', () => ({ glabExecFileAsync: execute })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: (connectionId: string) => generations.get(connectionId) ?? 0 -})) -import { - _resetKnownHostsCache, - getGlabKnownHosts, - rememberGlabKnownHost -} from './gitlab-known-host-probe' +import { _resetKnownHostsCache, getGlabKnownHosts } from './gitlab-known-host-probe' import { PROBE_COALESCE_STALE_MS } from '../git/coalesced-probe' const response = (host: string) => ({ stdout: `Logged in to ${host} as user`, stderr: '' }) const deferred = () => Promise.withResolvers>() -async function collect(): Promise { - if (typeof globalThis.gc !== 'function') { - throw new Error('Run with the repository Vitest --expose-gc config') - } - for (let index = 0; index < 6; index++) { - await new Promise((resolve) => setImmediate(resolve)) - globalThis.gc() - } - await new Promise((resolve) => setImmediate(resolve)) -} - -async function weakResult(connectionId: string): Promise> { - return new WeakRef(await getGlabKnownHosts(connectionId)) -} - beforeEach(() => { _resetKnownHostsCache() - generations.clear() execute.mockReset() }) afterEach(() => { @@ -44,48 +18,6 @@ afterEach(() => { vi.restoreAllMocks() }) -it('releases successful host arrays from superseded SSH generations', async () => { - const results: WeakRef[] = [] - for (let generation = 1; generation <= 32; generation++) { - generations.set('connection', generation) - execute.mockResolvedValue(response(`host${generation}.test`)) - results.push(await weakResult('connection')) - } - await collect() - expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host32.test']) - expect(execute).toHaveBeenCalledTimes(32) -}) - -it('retires remembered generations without requiring an auth-status probe', async () => { - const results: WeakRef[] = [] - for (let generation = 1; generation <= 16; generation++) { - generations.set('connection', generation) - rememberGlabKnownHost(`host${generation}.test`, 'connection') - results.push(await weakResult('connection')) - } - await collect() - expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host16.test']) - expect(execute).not.toHaveBeenCalled() -}) - -it('does not retain a delayed old-generation result after a replacement answers', async () => { - const old = deferred() - generations.set('connection', 1) - execute.mockReturnValueOnce(old.promise) - const oldResult = weakResult('connection') - generations.set('connection', 2) - execute.mockResolvedValueOnce(response('replacement.test')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) - old.resolve(response('retired.test')) - const reference = await oldResult - await collect() - expect(reference.deref()).toBeUndefined() - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) - expect(execute).toHaveBeenCalledTimes(2) -}) - it('does not repopulate an explicitly reset cache from an earlier probe', async () => { const old = deferred() execute.mockReturnValueOnce(old.promise) @@ -133,39 +65,3 @@ it('prevents an abandoned same-generation probe from publishing over its success await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) expect(execute).toHaveBeenCalledTimes(2) }) - -it('keeps native, WSL and other connection caches when one generation changes', async () => { - execute - .mockResolvedValueOnce(response('native.test')) - .mockResolvedValueOnce(response('ubuntu.test')) - .mockResolvedValueOnce(response('debian.test')) - .mockResolvedValueOnce(response('connection-a.test')) - .mockResolvedValueOnce(response('connection-b.test')) - const native = await getGlabKnownHosts() - const ubuntu = await getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) - const debian = await getGlabKnownHosts(undefined, { wslDistro: 'Debian' }) - await getGlabKnownHosts('connection-a') - const other = await getGlabKnownHosts('connection-b') - generations.set('connection-a', 1) - rememberGlabKnownHost('replacement.test', 'connection-a') - await expect(getGlabKnownHosts('connection-a')).resolves.toEqual([ - 'gitlab.com', - 'replacement.test' - ]) - await expect(getGlabKnownHosts()).resolves.toBe(native) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toBe(ubuntu) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Debian' })).resolves.toBe(debian) - await expect(getGlabKnownHosts('connection-b')).resolves.toBe(other) - expect(execute).toHaveBeenCalledTimes(5) -}) - -it('does not serve a retired generation after the current probe fails', async () => { - execute.mockResolvedValueOnce(response('retired.test')) - await getGlabKnownHosts('connection') - generations.set('connection', 1) - execute.mockRejectedValueOnce(new Error('current host unavailable')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com']) - execute.mockResolvedValueOnce(response('current.test')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'current.test']) - expect(execute).toHaveBeenCalledTimes(3) -}) diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 293030960e7..d15eda18684 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -4,7 +4,6 @@ import { shouldProbeGitRemote } from '../git/remote-name-listing' import { addWslEnvKeys } from '../wsl-env' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import type { IssueSourcePreference } from '../../shared/repo-types' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { @@ -75,13 +74,9 @@ export async function getProjectRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: a reconnect replaces the host an answer came from under the same id, so - // the generation is part of the signature; `knownHosts` carries the glab auth - // state, so logging into a self-hosted instance re-asks rather than reusing a - // ref resolved while that host was unknown. - const runtimeKey = connectionId - ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` - : `local:${localGitOptions.wslDistro ?? 'host'}` + // Why: `knownHosts` carries the glab auth state, so logging into a self-hosted + // instance re-asks rather than reusing a ref resolved while that host was unknown. + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` const cached = projectRefCache.get(cacheKey) if (cached) { diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index a895e4702e9..82344aa2cc5 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -1,9 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ +const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - glabExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() + glabExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -33,7 +32,6 @@ import { resolveIssueSource } from './gl-utils' import { GlabNonListResponseError } from './glab-api-response' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' @@ -71,15 +69,12 @@ describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() glabExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetProjectRefCache() _resetRemoteNameListingCache() }) afterEach(() => { vi.useRealTimers() - unregisterSshGitProvider('conn-1') }) it('keeps getProjectRef origin-based', async () => { @@ -215,21 +210,6 @@ describe('gitlab project ref resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('resolves project refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('bounds cached project refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:stablyai/orca.git\n', @@ -243,34 +223,6 @@ describe('gitlab project ref resolution', () => { expect(_getProjectRefCacheSize()).toBe(512) }) - it('does not cache a missing SSH provider as a permanent null project ref', async () => { - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() - - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@gitlab.com:remote/orca.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - }) - - it('does not cache transient SSH exec failures as permanent null project refs', async () => { - sshExecMock - .mockRejectedValueOnce(new Error('ssh tunnel not ready')) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - }) - it('does not cache a local probe killed on its deadline as a definitive miss', async () => { gitExecFileAsyncMock .mockRejectedValueOnce(new Error('git timed out.')) @@ -352,28 +304,6 @@ describe('gitlab project ref resolution', () => { } expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - - it('does not serve a project ref resolved on a retired SSH connection', async () => { - sshExecMock - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:before/orca.git\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:after/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'before/orca' - }) - - // A reconnect can swap the execution host under the same connection id. - unregisterSshGitProvider('conn-1') - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'after/orca' - }) - expect(sshExecMock).toHaveBeenCalledTimes(2) - }) }) describe('GitLab operation admission', () => { diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index 6efdbd45d82..b57406c1b58 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -7,8 +7,7 @@ const { glabHostEnvOptionsMock, glabRepoExecOptionsMock, acquireMock, - releaseMock, - getSshFilesystemProviderMock + releaseMock } = vi.hoisted(() => ({ getProjectSlugMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -20,8 +19,7 @@ const { connectionId ? {} : { cwd: repoPath } ), acquireMock: vi.fn(), - releaseMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() + releaseMock: vi.fn() })) vi.mock('./client', () => ({ @@ -37,10 +35,6 @@ vi.mock('./gl-utils', () => ({ glabRepoExecOptions: glabRepoExecOptionsMock })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock -})) - import { createGitLabMergeRequest } from './merge-request-creation' describe('createGitLabMergeRequest', () => { @@ -52,7 +46,6 @@ describe('createGitLabMergeRequest', () => { glabRepoExecOptionsMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() - getSshFilesystemProviderMock.mockReset() acquireMock.mockResolvedValue(undefined) getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' }) }) @@ -193,45 +186,6 @@ describe('createGitLabMergeRequest', () => { expect(options).not.toHaveProperty('cwd') }) - it('reads merge request templates from the SSH filesystem provider', async () => { - const readRemoteFile = vi.fn(async (path: string) => { - if (path === '/remote/repo-root/.gitlab/merge_request_templates/Default.md') { - return { content: 'Remote MR template body', isBinary: false } - } - throw new Error('missing template') - }) - getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/46\n', - stderr: '' - }) - - await expect( - createGitLabMergeRequest( - '/remote/repo-root', - { - provider: 'gitlab', - base: 'main', - head: 'feature/ssh-template', - title: 'SSH Template MR', - body: '', - useTemplate: true - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 46, - url: 'https://gitlab.com/acme/widgets/-/merge_requests/46' - }) - - const [args] = glabExecFileAsyncMock.mock.calls[0] - expect(readRemoteFile).toHaveBeenCalledWith( - '/remote/repo-root/.gitlab/merge_request_templates/Default.md' - ) - expect(args[args.indexOf('--description') + 1]).toBe('Remote MR template body') - }) - it('returns an existing merge request when GitLab reports a duplicate', async () => { glabExecFileAsyncMock .mockRejectedValueOnce(new Error('merge request already exists')) diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index 57153fb40ae..ca3f71a442e 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -7,8 +7,6 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { getHostedReviewLocalGitOptions, hasHostedReviewLocalGitOptions, @@ -100,21 +98,11 @@ async function readMergeRequestTemplate( '.gitlab/merge_request_template.md', '.gitlab/MERGE_REQUEST_TEMPLATE.md' ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of relativeCandidates) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional GitLab merge-request template path. diff --git a/src/main/gitlab/mr-head-tracking-ref.test.ts b/src/main/gitlab/mr-head-tracking-ref.test.ts index a83a94f8842..6d646d1ee28 100644 --- a/src/main/gitlab/mr-head-tracking-ref.test.ts +++ b/src/main/gitlab/mr-head-tracking-ref.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshGitProvider } from '../providers/ssh-git-provider' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -28,7 +27,6 @@ describe('fetchGitLabMergeRequestHeadRef', () => { it('fetches a GitLab MR head into its remote-scoped Orca ref for local repos', async () => { const localRef = await fetchGitLabMergeRequestHeadRef( { path: '/repo', connectionId: null }, - null, 'origin', 42 ) @@ -56,7 +54,7 @@ describe('fetchGitLabMergeRequestHeadRef', () => { }) await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42) ).rejects.toThrow('Remote "origin" is not configured.') expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), @@ -65,15 +63,9 @@ describe('fetchGitLabMergeRequestHeadRef', () => { }) it('keeps WSL routing while bounding the MR-head fetch', async () => { - await fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - 'origin', - 42, - { - localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } - } - ) + await fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, { + localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } + }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { cwd: '/repo', @@ -90,40 +82,19 @@ describe('fetchGitLabMergeRequestHeadRef', () => { ) }) - it('uses the SSH GitLab MR-head RPC and never runs git directly', async () => { - const expectedRef = `refs/orca/merge-requests/${ORIGIN_COMPONENT}/77` - const fetchGitLabMergeRequestHead = vi.fn(async () => expectedRef) - - const localRef = await fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchGitLabMergeRequestHead } as unknown as SshGitProvider, - 'origin', - 77 - ) - - expect(fetchGitLabMergeRequestHead).toHaveBeenCalledWith('/repo', 'origin', 77) - expect(localRef).toBe(expectedRef) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('rejects a connected GitLab MR-head fetch without an SSH provider', async () => { + it('rejects a GitLab MR-head fetch for an SSH repo', async () => { await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 77) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 77) ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('rejects invalid MR iids and option-shaped remotes before running git', async () => { await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 0) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 0) ).rejects.toThrow('Invalid merge request iid') await expect( - fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - '--upload-pack=x', - 42 - ) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42) ).rejects.toThrow('must not start with "-"') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitlab/mr-head-tracking-ref.ts b/src/main/gitlab/mr-head-tracking-ref.ts index e68a8235a9a..9f5f6170f77 100644 --- a/src/main/gitlab/mr-head-tracking-ref.ts +++ b/src/main/gitlab/mr-head-tracking-ref.ts @@ -6,20 +6,19 @@ import { } from '../../shared/review-head-tracking-ref' import { gitExecFileAsync } from '../git/runner' import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../providers/ssh-host-not-served-error' type LocalGitExecOptions = { cwd: string wslDistro?: string } -// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos -// must use the dedicated git.fetchGitLabMergeRequestHeadRef RPC. Mirrors -// fetchGitHubPullRequestHeadRef so both providers pin the durable head ref -// the same way. +// Why: mirrors fetchGitHubPullRequestHeadRef so both forges pin the durable head ref the same way. export async function fetchGitLabMergeRequestHeadRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, mrIid: number, options: { localGitExecOptions?: LocalGitExecOptions } = {} @@ -30,22 +29,16 @@ export async function fetchGitLabMergeRequestHeadRef( if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Merge request fetch remote must not start with "-".') } - if (!repo.connectionId) { - const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } - const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. - const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid) - await gitExecFileAsync( - ['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`], - { - ...localGitExecOptions, - timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS - } - ) - return localRef + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - return sshGitProvider.fetchGitLabMergeRequestHead(repo.path, remote, mrIid) + const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } + const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) + // Why: return the same path the fetch wrote so callers don't re-resolve identity. + const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid) + await gitExecFileAsync( + ['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`], + { ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return localRef } diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index 47782408b9c..86be671848b 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -22,10 +22,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -39,10 +35,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 159efb3ade7..63d600449e6 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -24,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -41,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index 4cda90638dc..fc356913ce2 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -20,10 +20,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -37,10 +33,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-conflict-operation-routing.test.ts b/src/main/ipc/filesystem-conflict-operation-routing.test.ts index 0ebdd270eec..b79868408b3 100644 --- a/src/main/ipc/filesystem-conflict-operation-routing.test.ts +++ b/src/main/ipc/filesystem-conflict-operation-routing.test.ts @@ -5,7 +5,6 @@ import { REPO_PATH, WORKTREE_FEATURE_PATH, detectConflictOperationMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -21,10 +20,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -38,10 +33,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock, getLocalGitOptionsForRepo: vi.fn(() => ({})), @@ -92,7 +83,6 @@ describe('git:conflictOperation ssh routing', () => { }) 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-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index 6e8e50ad205..bca599738b0 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -4,10 +4,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' type Handler = (event: unknown, args: unknown) => Promise @@ -302,11 +298,9 @@ describe('a project file opened by its full path', () => { }) describe('an SSH rename', () => { - it('goes to the remote host as before, whatever access it declares', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - try { - await call('fs:rename', { + it('is refused without touching the local document, whatever access it declares', async () => { + await expect( + call('fs:rename', { oldPath: note, newPath: join(base, 'note.md'), access: documentFolder(note), @@ -314,11 +308,8 @@ describe('an SSH rename', () => { expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 0 }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } + ).rejects.toThrow('Remote connection dropped') - expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md')) expect(await readdir(docFolder)).toEqual(['note.md']) }) }) diff --git a/src/main/ipc/filesystem-download-folder.ts b/src/main/ipc/filesystem-download-folder.ts deleted file mode 100644 index 60e915956ef..00000000000 --- a/src/main/ipc/filesystem-download-folder.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { BrowserWindow, dialog, ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import { rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { promoteLocalDownloadedFolder } from '../local-downloaded-folder-promotion' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { isENOENT } from './filesystem-path-containment' - -type DownloadFolderResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion uses rename/no-clobber operations that must stay on the - // destination volume, so transfer paths intentionally remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function assertDownloadFolderDestinationAvailable(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination folder already exists') -} - -async function cleanupLocalTransferDirectory(dirPath: string): Promise { - try { - await rm(dirPath, { recursive: true, force: true }) - } catch (error) { - // Why: cleanup must not mask the transfer error, but a leaked recursive - // download tree needs enough visibility to diagnose and remove it. - console.warn(`[filesystem] Failed to remove temporary folder download '${dirPath}'`, error) - } -} - -// Why: keep folder-download IPC out of filesystem.ts — that module is already large. -export function registerFilesystemDownloadFolderHandlers(): void { - ipcMain.handle( - 'fs:downloadFolder', - async ( - event, - args: { dirPath?: string; connectionId?: string } - ): Promise => { - const dirPath = validateRequiredString(args?.dirPath, 'dirPath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - if (!provider.downloadFolder) { - throw new Error( - 'Remote folder download is unavailable. Reconnect the SSH target and retry.' - ) - } - const abortController = new AbortController() - const abortOnSenderDestroyed = (): void => { - abortController.abort(new Error('Folder download canceled because the window closed')) - } - event.sender.once('destroyed', abortOnSenderDestroyed) - if (event.sender.isDestroyed()) { - abortOnSenderDestroyed() - } - try { - abortController.signal.throwIfAborted() - const remoteBasename = getRuntimePathBasename(dirPath) - const destinationBasename = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - // Why: after the local capability/abort checks, open the picker before - // remote tree validation so SSH latency does not delay click feedback. - const dialogOptions: Electron.OpenDialogOptions = { - properties: ['openDirectory', 'createDirectory'] - } - const dialogResult = parentWindow - ? await dialog.showOpenDialog(parentWindow, dialogOptions) - : await dialog.showOpenDialog(dialogOptions) - const destinationParent = dialogResult.filePaths?.[0] - if (dialogResult.canceled || !destinationParent) { - return { canceled: true } - } - abortController.signal.throwIfAborted() - - const destinationPath = join(destinationParent, destinationBasename) - await assertDownloadFolderDestinationAvailable(destinationPath) - - const tempPath = createSiblingTransferPath(destinationPath, 'download') - try { - await provider.downloadFolder(dirPath, tempPath, { signal: abortController.signal }) - abortController.signal.throwIfAborted() - await promoteLocalDownloadedFolder(tempPath, destinationPath, abortController.signal) - return { canceled: false, destinationPath } - } finally { - await cleanupLocalTransferDirectory(tempPath) - } - } finally { - event.sender.removeListener('destroyed', abortOnSenderDestroyed) - } - } - ) -} diff --git a/src/main/ipc/filesystem-download-lifetime.test.ts b/src/main/ipc/filesystem-download-lifetime.test.ts index 037c3329be3..97a8f662c6c 100644 --- a/src/main/ipc/filesystem-download-lifetime.test.ts +++ b/src/main/ipc/filesystem-download-lifetime.test.ts @@ -20,10 +20,6 @@ vi.mock('electron', () => ({ } })) vi.mock('node:fs/promises', () => ({ ...mocks, writeFile: vi.fn() })) -vi.mock('./filesystem-download-folder', () => ({ - registerFilesystemDownloadFolderHandlers: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: vi.fn() })) import { createFilesystemHandlerContext } from './filesystem/filesystem-handler-context' import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 5948c89ec94..1e56e9d79c8 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -12,8 +12,6 @@ import { renameMock, rmMock, writeFileMock, - getSshFilesystemProviderMock, - promoteLocalDownloadedFolderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -27,10 +25,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -44,10 +38,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -285,14 +275,7 @@ describe('registerFilesystemHandlers', () => { expect(rmMock).toHaveBeenCalledWith(backupPath, { force: true }) }) - it('downloads remote folders into a temporary sibling before promotion', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + it('refuses remote folder downloads before opening a picker', async () => { registerFilesystemHandlers(store as never) await expect( @@ -300,208 +283,10 @@ describe('registerFilesystemHandlers', () => { dirPath: '/remote/src', connectionId: 'ssh-1' }) - ).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') }) - - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(path.dirname(tempPath)).toBe(path.normalize('/downloads')) - expect(showOpenDialogMock).toHaveBeenCalledWith({ - properties: ['openDirectory', 'createDirectory'] - }) - expect(provider.downloadFolder).toHaveBeenCalledWith( - '/remote/src', - tempPath, - expect.objectContaining({ signal: expect.anything() }) + ).rejects.toThrow( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' ) - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) - expect(promoteLocalDownloadedFolderMock).toHaveBeenCalledWith( - tempPath, - path.join('/downloads', 'src'), - expect.anything() - ) - }) - - it('returns canceled remote folder downloads without transferring', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ canceled: true }) - - expect(provider.downloadFolder).not.toHaveBeenCalled() - }) - - it('aborts before opening the folder picker when the renderer is already destroyed', async () => { - const provider = { - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - folderDownloadSender.isDestroyed.mockReturnValue(true) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('window closed') expect(showOpenDialogMock).not.toHaveBeenCalled() - expect(provider.downloadFolder).not.toHaveBeenCalled() - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) - }) - - it('opens the folder picker before SSH folder validation', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockImplementation(async () => { - expect(provider.downloadFolder).not.toHaveBeenCalled() - return { canceled: false, filePaths: ['/downloads'] } - }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') }) - - expect(provider.downloadFolder).toHaveBeenCalledTimes(1) - }) - - it('rejects remote folder downloads when the destination folder already exists', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockResolvedValue({ isDirectory: () => true }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('Destination folder already exists') - - expect(provider.downloadFolder).not.toHaveBeenCalled() - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - }) - - it('rejects remote folder downloads when the SSH provider cannot transfer folders', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('Remote folder download is unavailable') - - expect(showOpenDialogMock).not.toHaveBeenCalled() - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - }) - - it('cleans up a temporary remote folder download when transfer fails', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true }) - }) - - it('logs a recursive temporary-folder cleanup failure without masking the transfer error', async () => { - const provider = { - downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - const cleanupError = new Error('cleanup denied') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - rmMock.mockRejectedValueOnce(cleanupError) - registerFilesystemHandlers(store as never) - - try { - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to remove temporary folder download'), - cleanupError - ) - } finally { - warn.mockRestore() - } - }) - - it('aborts and cleans up a remote folder download when its renderer closes', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn( - (_source: string, _destination: string, options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - ) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - const result = handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - await vi.waitFor(() => expect(provider.downloadFolder).toHaveBeenCalledTimes(1)) - folderDownloadSender.emit('destroyed') - - await expect(result).rejects.toThrow('window closed') - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true }) - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) }) }) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 8bec934ff8b..d09d8d96e00 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, - getSshGitProviderMock, resolveCommitMessageSettingsMock, generatePullRequestFieldsFromContextMock, getPullRequestDraftContextMock, @@ -25,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -42,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -94,7 +85,6 @@ describe('registerFilesystemHandlers', () => { 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 }) registerFilesystemHandlers(store as never) @@ -114,7 +104,6 @@ describe('registerFilesystemHandlers', () => { it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => { const sshRemoteCommitUrlMock = vi.fn() - getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock }) registerFilesystemHandlers(store as never) @@ -144,7 +133,6 @@ describe('registerFilesystemHandlers', () => { it('rejects git:commit with whitespace-only message before SSH dispatch', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) - getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) 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 af7a8e7d0d5..dea7d29d00a 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -18,7 +18,6 @@ import { discardChangesMock, checkIgnoredPathsMock, listWorktreesMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -32,10 +31,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -49,10 +44,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -338,7 +329,6 @@ describe('registerFilesystemHandlers', () => { }) 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']) { diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts deleted file mode 100644 index d4dc794a7cc..00000000000 --- a/src/main/ipc/filesystem-import-ssh-directory.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { lstat, readdir, realpath } from 'node:fs/promises' -import { isAbsolute, join, relative, sep } from 'node:path' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' - -export async function captureLocalUploadRoot( - sourcePath: string, - sourceStat: Awaited> -): Promise { - const rootRealPath = await realpath(sourcePath) - const rootRealStat = await lstat(rootRealPath) - if ( - statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) || - statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) || - !rootRealStat.isDirectory() - ) { - throw new Error(`Upload source changed while being inspected: ${sourcePath}`) - } - return rootRealPath -} - -export async function preScanSshImportDirectory( - dirPath: string, - remotePathFlavor: RemotePathFlavor -): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - if (entry.isSymbolicLink()) { - return true - } - if (entry.isDirectory()) { - const childPath = join(dirPath, entry.name) - if (await preScanSshImportDirectory(childPath, remotePathFlavor)) { - return true - } - } - } - return false -} - -export async function uploadSshImportDirectory( - provider: IFilesystemProvider, - uploadSession: FileUploadSession, - localDir: string, - remoteDir: string, - rootRealPath: string, - remotePathFlavor: RemotePathFlavor, - assertCurrent?: () => void -): Promise { - await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - const entries = await readdir(localDir, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - const localPath = join(localDir, entry.name) - const remotePath = `${remoteDir}/${entry.name}` - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) - - // Why: skip symlinks and special files even after the up-front pre-scan; - // this closes the TOCTOU gap if one is created during upload. - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - - if (statResult.isDirectory()) { - assertCurrent?.() - await provider.createDirNoClobber(remotePath) - await uploadSshImportDirectory( - provider, - uploadSession, - localPath, - remotePath, - rootRealPath, - remotePathFlavor, - assertCurrent - ) - continue - } - assertCurrent?.() - await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) - } -} - -function statIdentityPartChanged( - left: number | bigint | undefined, - right: number | bigint | undefined -): boolean { - const leftKnown = left !== undefined && left !== 0 && left !== 0n - const rightKnown = right !== undefined && right !== 0 && right !== 0n - return leftKnown && rightKnown && left !== right -} - -async function assertLocalUploadPathInsideRoot( - rootRealPath: string, - candidatePath: string -): Promise { - const candidateRealPath = await realpath(candidatePath) - const relativeToRoot = relative(rootRealPath, candidateRealPath) - if ( - relativeToRoot !== '' && - (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) - ) { - throw new Error(`Upload source escapes selected directory: ${candidatePath}`) - } -} diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts deleted file mode 100644 index afe6519dfc8..00000000000 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const handlers = new Map Promise>() -const { - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - readdirMock, - getConnMgrMock -} = vi.hoisted(() => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - readdirMock: vi.fn(), - getConnMgrMock: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) -vi.mock('fs/promises', () => ({ - lstat: lstatMock, - mkdir: mkdirMock, - rename: vi.fn(), - writeFile: vi.fn(), - realpath: realpathMock, - copyFile: copyFileMock, - readdir: readdirMock -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) - -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - advanceSshConnectionGeneration, - resetSshConnectionGenerations -} from '../ssh/ssh-connection-generation' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' - -const store = { - getRepos: () => [ - { - id: 'r1', - path: path.resolve('/workspace/repo'), - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - ], - getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) -} -const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - return { - readDir: vi.fn(), - readFile: vi.fn(), - downloadFile: vi.fn(), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), - writeFile: vi.fn().mockResolvedValue(undefined), - writeFileBase64: vi.fn(), - writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(enoent()), - deletePath: vi.fn().mockResolvedValue(undefined), - createFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - rename: vi.fn(), - renameNoClobber: vi.fn(), - copy: vi.fn(), - realpath: vi.fn(), - search: vi.fn(), - listFiles: vi.fn(), - watch: vi.fn() - } as unknown as IFilesystemProvider -} - -describe('fs:importExternalPaths — SSH operations', () => { - const destDir = '/home/user/project/src' - const connId = 'ssh-conn-1' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - const makeConn = () => ({ - getState: () => ({ status: 'connected' }), - sftp: vi.fn() - }) - const mockDir = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - throw enoent() - }) - } - const mockFile = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - } - const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!(null, { - ...args, - ...(typeof args.connectionId === 'string' - ? { - expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId, - expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0 - } - : {}) - }) as Promise<{ - results: Record[] - }> - - beforeEach(() => { - handlers.clear() - ;[ - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - readdirMock, - getConnMgrMock - ].forEach((m) => m.mockReset()) - handleMock.mockImplementation((ch: string, h: never) => { - handlers.set(ch, h) - }) - realpathMock.mockImplementation(async (p: string) => p) - lstatMock.mockRejectedValue(enoent()) - readdirMock.mockResolvedValue([]) - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - registerSshFilesystemProvider(connId, provider) - registerFilesystemMutationHandlers(store as never) - }) - - afterEach(() => { - unregisterSshFilesystemProvider(connId) - resetSshConnectionGenerations() - }) - - it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => { - resetSshConnectionGenerations(71) - const stagedGeneration = advanceSshConnectionGeneration(connId) - const sourcePath = path.resolve('/tmp/dropped/restart.txt') - lstatMock.mockImplementation(async (candidate: string) => { - if (candidate !== sourcePath) { - throw enoent() - } - resetSshConnectionGenerations(72) - advanceSshConnectionGeneration(connId) - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - }) - - const { results } = await invoke({ - sourcePaths: [sourcePath], - destDir, - connectionId: connId, - expectedSshTargetId: connId, - expectedSshConnectionGeneration: stagedGeneration - }) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: 'SSH connection changed; refresh and try again' - }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - }) - - it('deconflicts file names via provider stat', async () => { - mockFile('/tmp/dropped/logo.png') - vi.mocked(provider.stat).mockImplementation(async (p: string) => { - if (p === `${destDir}/logo.png`) { - return { type: 'file', size: 1, mtime: 1 } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/logo.png'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${destDir}/logo copy.png`, - renamed: true - }) - }) - - it('rejects symlink sources', async () => { - const rp = path.resolve('/tmp/dropped/link.txt') - lstatMock.mockImplementation(async (p: string) => { - if (p === rp) { - return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/link.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - }) - - it('handles partial failure with correct per-item results', async () => { - const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt'] - const resolved = new Set(sources.map((s) => path.resolve(s))) - lstatMock.mockImplementation(async (p: string) => { - if (resolved.has(p)) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - vi.mocked(uploadSession.uploadFile).mockImplementation(async (_localPath, remotePath) => { - if (remotePath.endsWith('/bad.txt')) { - throw new Error('permission denied') - } - }) - - const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId }) - expect(results).toHaveLength(3) - expect(results[0]).toMatchObject({ status: 'imported' }) - expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' }) - expect(results[2]).toMatchObject({ status: 'imported' }) - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('does not delete a file another client created during an exclusive-upload race', async () => { - mockFile('/tmp/dropped/report.txt') - vi.mocked(uploadSession.uploadFile).mockRejectedValue( - Object.assign(new Error('EEXIST: destination already exists'), { code: 'EEXIST' }) - ) - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/report.txt'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: 'EEXIST: destination already exists' - }) - // Why: a failed exclusive create does not prove Orca owns the destination. - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('uploads directories via provider createDirNoClobber and binary writes', async () => { - const root = path.resolve('/tmp/dropped/assets') - const child = path.join(root, 'logo.png') - lstatMock.mockImplementation(async (p: string) => { - if (p === root) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (p === child) { - return { - size: 3, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - readdirMock.mockImplementation(async (p: string) => - p === root - ? [ - { - name: 'logo.png', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - : [] - ) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) - expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, { - exclusive: true - }) - }) - - it('reports per-item failure when deconfliction throws', async () => { - mockFile('/tmp/dropped/file.txt') - vi.mocked(provider.stat).mockRejectedValue(new Error('Remote connection not found')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' }) - }) - - it('reports failure when creating a directory rejects', async () => { - mockDir('/tmp/dropped/mydir') - vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/mydir'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) - }) - - it('removes a created SSH directory import root when nested upload fails', async () => { - const root = path.resolve('/tmp/dropped/assets') - const child = path.join(root, 'logo.png') - lstatMock.mockImplementation(async (p: string) => { - if (p === root) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (p === child) { - return { - size: 3, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - readdirMock.mockImplementation(async (p: string) => - p === root - ? [ - { - name: 'logo.png', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - : [] - ) - vi.mocked(uploadSession.uploadFile).mockRejectedValue(new Error('disk full')) - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) - expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) - }) - - it('deconflicts directory names via provider stat', async () => { - mockDir('/tmp/dropped/assets') - vi.mocked(provider.stat).mockImplementation(async (p: string) => { - if (p === `${destDir}/assets`) { - return { type: 'directory', size: 1, mtime: 1 } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${destDir}/assets copy`, - renamed: true - }) - }) - - it('rejects directory containing nested symlinks', async () => { - mockDir('/tmp/dropped/project') - const rd = path.resolve('/tmp/dropped/project') - readdirMock.mockImplementation(async (p: string) => { - if (p === rd) { - return [ - { - name: 'l.txt', - isFile: () => false, - isDirectory: () => false, - isSymbolicLink: () => true - } - ] - } - return [] - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/project'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - }) - - it('reports skipped when source lstat returns EACCES', async () => { - const rp = path.resolve('/tmp/dropped/secret.txt') - lstatMock.mockImplementation(async (p: string) => { - if (p === rp) { - throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/secret.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'permission-denied' }) - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts deleted file mode 100644 index 6e1841facee..00000000000 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ - getConnMgrMock: vi.fn(), - lstatMock: vi.fn(), - providerMock: vi.fn(), - readdirMock: vi.fn(), - realpathMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - lstat: lstatMock, - readdir: readdirMock, - realpath: realpathMock -})) -vi.mock('./filesystem-path-containment', () => ({ - isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: providerMock -})) - -import { importExternalPathsSsh } from './filesystem-import-ssh' - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - return { - stat: vi.fn().mockRejectedValue(missing), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession) - } as unknown as IFilesystemProvider -} - -describe('SSH import remote path safety', () => { - const connectionId = 'ssh-windows' - const destDir = 'C:/Users/me/project/.orca/drops' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - - beforeEach(() => { - vi.clearAllMocks() - getConnMgrMock.mockReturnValue({ - getConnection: () => ({ getState: () => ({ status: 'connected' }) }) - }) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - providerMock.mockReturnValue(provider) - readdirMock.mockResolvedValue([]) - realpathMock.mockImplementation(async (value: string) => value) - }) - - it.runIf(process.platform !== 'win32')( - 'rejects a POSIX filename containing Windows traversal before remote stat', - async () => { - const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop' - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(lstatMock).not.toHaveBeenCalled() - expect(provider.stat).not.toHaveBeenCalled() - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - } - ) - - it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])( - 'rejects Windows-special top-level name %j before remote stat', - async (name) => { - const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(lstatMock).not.toHaveBeenCalled() - expect(provider.stat).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - } - ) - - it('rejects a nested Windows traversal name before creating the import root', async () => { - const sourcePath = path.resolve('/tmp/assets') - lstatMock.mockResolvedValue({ - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - readdirMock.mockResolvedValue([ - { - name: '..\\..\\.ssh\\orca_drop', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ]) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(provider.stat).not.toHaveBeenCalled() - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('keeps exclusive creation for safe file names', async () => { - const sourcePath = path.resolve('/tmp/report.txt') - lstatMock.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, { - exclusive: true - }) - }) - - it.runIf(process.platform !== 'win32')( - 'preserves backslashes in valid POSIX names for POSIX remotes', - async () => { - const sourcePath = path.resolve('/tmp/notes\\2026.txt') - const posixDestDir = '/home/me/project' - lstatMock.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }) - - const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${posixDestDir}/notes\\2026.txt` - }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - sourcePath, - `${posixDestDir}/notes\\2026.txt`, - { exclusive: true } - ) - } - ) - - it('streams an N-file directory through guarded per-file uploads', async () => { - const sourcePath = path.resolve('/tmp/assets') - const names = ['one.txt', 'two.txt', 'three.txt'] - const entries = names.map((name) => ({ - name, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - })) - lstatMock.mockImplementation(async (value: string) => - value === sourcePath - ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - : { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - ) - readdirMock.mockResolvedValue(entries) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3) - for (const name of names) { - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - path.join(sourcePath, name), - `${destDir}/assets/${name}`, - { exclusive: true } - ) - } - }) - - it('skips special entries without failing the directory import', async () => { - const sourcePath = path.resolve('/tmp/assets') - const regularPath = path.join(sourcePath, 'report.txt') - const socketPath = path.join(sourcePath, 'server.sock') - const entries = [ - { - name: 'report.txt', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }, - { - name: 'server.sock', - isFile: () => false, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - lstatMock.mockImplementation(async (value: string) => { - if (value === sourcePath) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (value === regularPath) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - } - expect(value).toBe(socketPath) - return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false } - }) - readdirMock.mockResolvedValue(entries) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(uploadSession.uploadFile).toHaveBeenCalledOnce() - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - regularPath, - `${destDir}/assets/report.txt`, - { exclusive: true } - ) - }) - - it('rejects a selected root replaced after its realpath is captured', async () => { - const sourcePath = path.resolve('/tmp/assets') - lstatMock.mockResolvedValue({ - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement') - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('escapes selected directory') - }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) - }) - - it('rejects a selected root replaced while its realpath is captured', async () => { - const sourcePath = path.resolve('/tmp/assets') - const directoryStat = (ino: number) => ({ - dev: 1, - ino, - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2)) - realpathMock.mockResolvedValue('/private/replacement') - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('changed while being inspected') - }) - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh-remote-existence.ts b/src/main/ipc/filesystem-import-ssh-remote-existence.ts deleted file mode 100644 index e853f291f80..00000000000 --- a/src/main/ipc/filesystem-import-ssh-remote-existence.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IFilesystemProvider } from '../providers/types' - -/** Whether a remote path exists; only a definite "missing" answer reads as false. */ -export async function remotePathExists( - provider: IFilesystemProvider, - remotePath: string -): Promise { - try { - await provider.stat(remotePath) - return true - } catch (error) { - if (isRemoteMissingError(error)) { - return false - } - throw error - } -} - -function isRemoteMissingError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && error.code === 'ENOENT') || - /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( - error.message - ) - ) -} diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts deleted file mode 100644 index 80c801cfd56..00000000000 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import path from 'node:path' -import { constants } from 'node:fs' -import { Readable, Writable } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const handlers = new Map Promise>() -const { - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - openMock, - readdirMock, - unlinkMock, - getConnMgrMock -} = vi.hoisted(() => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - openMock: vi.fn(), - readdirMock: vi.fn(), - unlinkMock: vi.fn(), - getConnMgrMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { getPath: () => '/orca-test-user-data' }, - ipcMain: { handle: handleMock } -})) -vi.mock('fs/promises', () => ({ - lstat: lstatMock, - mkdir: mkdirMock, - rename: vi.fn(), - writeFile: vi.fn(), - realpath: realpathMock, - copyFile: copyFileMock, - open: openMock, - readdir: readdirMock, - unlink: unlinkMock, - rm: vi.fn() -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) - -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' - -const store = { - getRepos: () => [ - { - id: 'r1', - path: path.resolve('/workspace/repo'), - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - ], - getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) -} -const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - return { - readDir: vi.fn(), - readFile: vi.fn(), - downloadFile: vi.fn(), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), - writeFile: vi.fn().mockResolvedValue(undefined), - writeFileBase64: vi.fn(), - writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(enoent()), - deletePath: vi.fn().mockResolvedValue(undefined), - createFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - rename: vi.fn(), - renameNoClobber: vi.fn(), - copy: vi.fn(), - realpath: vi.fn(), - search: vi.fn(), - listFiles: vi.fn(), - watch: vi.fn() - } as unknown as IFilesystemProvider -} - -describe('fs:importExternalPaths — SSH routing & connection', () => { - const destDir = '/home/user/project/src' - const connId = 'ssh-conn-1' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - - const makeConn = (status = 'connected') => ({ - getState: () => ({ status }), - sftp: vi.fn() - }) - const mockFile = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - } - const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!( - null, - typeof args.connectionId === 'string' - ? { - ...args, - expectedSshTargetId: args.connectionId, - expectedSshConnectionGeneration: 0 - } - : args - ) as Promise<{ - results: Record[] - }> - - beforeEach(() => { - handlers.clear() - resetSshConnectionGenerations() - ;[ - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - openMock, - readdirMock, - unlinkMock, - getConnMgrMock - ].forEach((m) => m.mockReset()) - handleMock.mockImplementation((ch: string, h: never) => { - handlers.set(ch, h) - }) - realpathMock.mockImplementation(async (p: string) => p) - lstatMock.mockRejectedValue(enoent()) - openMock.mockImplementation(async (_p: string, flags: unknown) => { - if (flags === 'wx') { - return { - createWriteStream: () => - new Writable({ - write(_chunk, _encoding, callback) { - callback() - } - }), - close: vi.fn().mockResolvedValue(undefined) - } - } - return { - stat: vi.fn().mockResolvedValue({ - size: 12, - ino: 1, - dev: 1, - isFile: () => true - }), - createReadStream: () => Readable.from([Buffer.from('file-content')]), - close: vi.fn().mockResolvedValue(undefined) - } - }) - unlinkMock.mockResolvedValue(undefined) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - registerSshFilesystemProvider(connId, provider) - registerFilesystemMutationHandlers(store as never) - }) - - afterEach(() => { - unregisterSshFilesystemProvider(connId) - }) - - it('routes SSH imports through the filesystem provider when connectionId is present', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - path.resolve('/tmp/dropped/file.txt'), - `${destDir}/file.txt`, - { exclusive: true } - ) - expect(uploadSession.close).toHaveBeenCalledOnce() - expect(copyFileMock).not.toHaveBeenCalled() - }) - - it('falls back to local import when connectionId is absent', async () => { - mockFile('/tmp/dropped/file.txt') - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir: path.resolve('/workspace/repo/src') - }) - expect(results[0]).toMatchObject({ status: 'imported' }) - expect(openMock).toHaveBeenCalledWith( - path.resolve('/tmp/dropped/file.txt'), - constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) - ) - }) - - it('returns empty results without opening SFTP or requiring a provider', async () => { - unregisterSshFilesystemProvider(connId) - const conn = makeConn() - getConnMgrMock.mockReturnValue({ getConnection: () => conn }) - const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId }) - expect(results).toHaveLength(0) - expect(conn.sftp).not.toHaveBeenCalled() - }) - - it('throws when connectionId has no matching connection', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => null }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('No SSH connection') - }) - - it('throws when connection is reconnecting', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('reconnecting') }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('reconnecting') - }) - - it('throws when connection is not active', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('disconnected') }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('not active') - }) - - it('throws when the SSH filesystem provider is unavailable', async () => { - unregisterSshFilesystemProvider(connId) - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('Remote connection dropped') - }) - - it('uploads terminal-drop staging marker through provider writes', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - - await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir: '/home/user/project/.orca/drops', - connectionId: connId, - ensureDir: true - }) - - expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca') - expect(provider.writeFile).toHaveBeenCalledWith( - '/home/user/project/.orca/.gitignore', - '*\n!.gitignore\n' - ) - expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca/drops') - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts deleted file mode 100644 index c44144460a5..00000000000 --- a/src/main/ipc/filesystem-import-ssh.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { basename, posix, resolve } from 'node:path' -import { classifyImportSource } from './filesystem-import-source-stat' -import { deconflictCopyName } from '../../shared/import-copy-name' -import { getSshConnectionManager } from '../ssh/ssh-target-registry' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type { ImportItemResult } from '../../shared/filesystem-import-result-types' -import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { remotePathExists } from './filesystem-import-ssh-remote-existence' -import { - captureLocalUploadRoot, - preScanSshImportDirectory, - uploadSshImportDirectory -} from './filesystem-import-ssh-directory' -import { errorMessage } from '../../shared/error-message' - -// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so -// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. -export async function importExternalPathsSsh( - sourcePaths: string[], - destDir: string, - connectionId: string, - options?: { ensureDir?: boolean; assertCurrent?: () => void } -): Promise<{ results: ImportItemResult[] }> { - if (sourcePaths.length === 0) { - return { results: [] } - } - - const connManager = getSshConnectionManager() - const conn = connManager?.getConnection(connectionId) - if (!conn) { - throw new Error(`No SSH connection for "${connectionId}"`) - } - - const state = conn.getState() - if (state.status !== 'connected') { - if (state.status === 'reconnecting') { - throw new Error('SSH connection is reconnecting — please try again in a moment') - } - throw new Error('SSH connection is not active — please reconnect and try again') - } - - const provider = requireSshFilesystemProvider(connectionId) - - if (options?.ensureDir) { - // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist - // before the first upload. .orca/ is reserved as Orca-owned remote state; - // see docs/terminal-drop-ssh.md. - await ensureDropStagingDir(provider, destDir, options.assertCurrent) - } - - const results: ImportItemResult[] = [] - const reservedNames = new Set() - if (!provider.openFileUploadSession) { - throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') - } - options?.assertCurrent?.() - const uploadSession = await provider.openFileUploadSession() - // Why: filename legality follows the remote filesystem, not the client's OS. - const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir) - ? 'windows' - : 'posix' - try { - for (const sourcePath of sourcePaths) { - const result = await importOneSourceSsh( - provider, - uploadSession, - sourcePath, - destDir, - reservedNames, - remotePathFlavor, - options?.assertCurrent - ) - results.push(result) - if (result.status === 'imported') { - // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). - // Node's basename() uses the OS separator, which on Windows would - // return the entire string instead of just the filename. - reservedNames.add(posix.basename(result.destPath)) - } - } - } finally { - uploadSession.close() - } - - return { results } -} - -async function importOneSourceSsh( - provider: IFilesystemProvider, - uploadSession: FileUploadSession, - sourcePath: string, - destDir: string, - reservedNames: Set, - remotePathFlavor: RemotePathFlavor, - assertCurrent?: () => void -): Promise { - const resolvedSource = resolve(sourcePath) - - const originalName = basename(resolvedSource) - try { - assertSafeRemotePathSegment(originalName, remotePathFlavor) - } catch (error) { - return { - sourcePath, - status: 'failed', - reason: errorMessage(error) - } - } - - const classified = await classifyImportSource(sourcePath, resolvedSource) - if ('rejection' in classified) { - return classified.rejection - } - const sourceStat = classified.stat - const isDir = sourceStat.isDirectory() - - let createdDestDir: string | null = null - try { - const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null - if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) { - return { sourcePath, status: 'skipped', reason: 'symlink' } - } - - // Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write. - assertCurrent?.() - const finalName = await deconflictCopyName(originalName, async (n) => { - assertCurrent?.() - return (await remotePathExists(provider, `${destDir}/${n}`)) || reservedNames.has(n) - }) - const destPath = `${destDir}/${finalName}` - const renamed = finalName !== originalName - - if (isDir) { - assertCurrent?.() - await provider.createDirNoClobber(destPath) - createdDestDir = destPath - await uploadSshImportDirectory( - provider, - uploadSession, - resolvedSource, - destPath, - rootRealPath!, - remotePathFlavor, - assertCurrent - ) - } else { - assertCurrent?.() - await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) - } - - return { - sourcePath, - status: 'imported', - destPath, - kind: isDir ? 'directory' : 'file', - renamed - } - } catch (error) { - if (createdDestDir) { - // Why: local directory imports roll back partial output; SSH imports - // should not leave the no-clobber root after a nested upload failure. - try { - assertCurrent?.() - await provider.deletePath(createdDestDir, true) - } catch { - // Best effort; a replacement session must never inherit cleanup from the retired owner. - } - } - return { - sourcePath, - status: 'failed', - reason: errorMessage(error) - } - } -} - -async function ensureDropStagingDir( - provider: IFilesystemProvider, - destDir: string, - assertCurrent?: () => void -): Promise { - const parent = posix.dirname(destDir) - assertCurrent?.() - await provider.createDir(parent) - const gitignorePath = `${parent}/.gitignore` - assertCurrent?.() - if (!(await remotePathExists(provider, gitignorePath))) { - assertCurrent?.() - await provider.writeFile(gitignorePath, '*\n!.gitignore\n') - } - assertCurrent?.() - await provider.createDir(destDir) -} diff --git a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts index 5a192a41637..e936c6385c0 100644 --- a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts @@ -14,10 +14,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -31,10 +27,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index 5eb44a36719..34e412d4de1 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -34,10 +34,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -51,10 +47,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 36db8630dc5..199e029e178 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -29,10 +29,6 @@ vi.mock('fs/promises', () => ({ })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { resetSshConnectionGenerations, setSshConnectionGeneration @@ -346,107 +342,40 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) - it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + it('rejects direct SSH rename without target-bound generation provenance', async () => { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('SSH connection changed') + }) - try { - await handlers.get('fs:rename')!(null, { + it('rejects equal-generation provenance for another direct SSH target', async () => { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-b', + expectedSshTargetId: 'ssh-a', + expectedSshConnectionGeneration: 0 + }) + ).rejects.toThrow('SSH connection changed') + }) + + it('rejects stale generation provenance for a direct SSH target', async () => { + setSshConnectionGeneration('ssh-1', 8) + + await expect( + handlers.get('fs:rename')!(null, { oldPath: '/home/me/repo/old.ts', newPath: '/home/me/repo/new.ts', connectionId: 'ssh-1', expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 + expectedSshConnectionGeneration: 7 }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts') - expect(renameMock).not.toHaveBeenCalled() - }) - - it('propagates SSH no-clobber rename failures', async () => { - const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 - }) - ).rejects.toThrow('destination exists') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameMock).not.toHaveBeenCalled() - }) - - it('rejects direct SSH rename without target-bound generation provenance', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).not.toHaveBeenCalled() - }) - - it('rejects equal-generation provenance for another direct SSH target', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-b', - expectedSshTargetId: 'ssh-a', - expectedSshConnectionGeneration: 0 - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-b') - } - - expect(renameNoClobber).not.toHaveBeenCalled() - }) - - it('rejects stale generation provenance for a direct SSH target', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - setSshConnectionGeneration('ssh-1', 8) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 7 - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).not.toHaveBeenCalled() + ).rejects.toThrow('SSH connection changed') }) it('rejects stale SSH provenance when a direct mutation resolves local', async () => { @@ -513,26 +442,6 @@ describe('registerFilesystemMutationHandlers', () => { expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number)) }) - it('routes copy through the SSH filesystem provider when a connection is present', async () => { - const copy = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { copy } as never) - - try { - await handlers.get('fs:copy')!(null, { - sourcePath: '/home/me/repo/source.ts', - destinationPath: '/home/me/repo/source copy.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts') - expect(copyFileMock).not.toHaveBeenCalled() - }) - // ── Edge cases ───────────────────────────────────────────────── it('propagates non-ENOENT lstat errors in assertNotExists', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e96650a7442..112dff10bad 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -9,16 +9,14 @@ import { resolveLocalRequestPath } from './local-file-access-resolution' import type { LocalFileAccess } from '../../shared/local-file-access' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' -import { importExternalPathsSsh } from './filesystem-import-ssh' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' import { assertNotExists, rethrowWithUserMessage } from './filesystem-create-path-guards' import type { ImportItemResult, - ImportSkipReason, ResolveDroppedPathsResult, StagedExternalImportSource } from '../../shared/filesystem-import-result-types' @@ -51,8 +49,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.createFile(args.filePath) + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveDesktopAuthorizedPath(args.filePath, store) await mkdir(dirname(filePath), { recursive: true }) @@ -78,8 +75,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.createDir(args.dirPath) + throw new SshHostNotServedError(args.connectionId) } const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) await assertNotExists(dirPath) @@ -108,8 +104,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.renameNoClobber(args.oldPath, args.newPath) + throw new SshHostNotServedError(args.connectionId) } // Why: rename() operates on directory entries, not file contents. If // oldPath is a symlink, we must rename the link itself rather than @@ -145,8 +140,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.copy(args.sourcePath, args.destinationPath) + throw new SshHostNotServedError(args.connectionId) } const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, { preserveSymlink: true @@ -180,22 +174,11 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { - ensureDir: args.ensureDir, - assertCurrent: () => - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - }) + throw new SshHostNotServedError(args.connectionId) } // Why: destDir must be authorized before any copy work begins. If the // destination is outside allowed roots, the entire import fails. - // This only applies to local imports — remote paths are authorized by - // the SSH connection boundary (see importExternalPathsSsh). // An image inserted into a document the user opened lands in that document's own folder. const resolvedDest = await resolveLocalRequestPath( args.destDir, @@ -275,11 +258,9 @@ export function registerFilesystemMutationHandlers(store: Store): void { ) // Why: terminal drag-and-drop resolver. Local worktrees pass paths through - // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees - // upload each path into `${worktreePath}/.orca/drops/` and return remote - // paths the remote agent can read. Kept as a separate IPC from - // fs:importExternalPaths because terminal semantics differ from the - // explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md. + // unchanged (reference-in-place; preserves zero-latency drop). Kept as a + // separate IPC from fs:importExternalPaths because terminal semantics differ + // from the explorer's "copy into user-picked destDir". ipcMain.handle( 'fs:resolveDroppedPathsForAgent', async ( @@ -305,32 +286,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { failed: [] } } - const worktreePath = args.worktreePath.replace(/\/+$/, '') - const destDir = `${worktreePath}/.orca/drops` - const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, { - ensureDir: true, - assertCurrent: () => - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - }) - const resolvedPaths: string[] = [] - const skipped: { sourcePath: string; reason: ImportSkipReason }[] = [] - const failed: { sourcePath: string; reason: string }[] = [] - // Iterate in input order so injected paths align with the user's drop order. - for (const r of results) { - if (r.status === 'imported') { - resolvedPaths.push(r.destPath) - } else if (r.status === 'skipped') { - skipped.push({ sourcePath: r.sourcePath, reason: r.reason }) - } else { - failed.push({ sourcePath: r.sourcePath, reason: r.reason }) - } - } - return { resolvedPaths, skipped, failed } + throw new SshHostNotServedError(args.connectionId) } ) } diff --git a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts index 5fedebc34d2..0d36c3da182 100644 --- a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts +++ b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts @@ -24,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -41,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 8e2cf2b5a25..e326ac21b06 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -54,10 +54,8 @@ export const getPullRequestDraftContextMock: IpcMock = vi.fn() export const resolveHostedReviewBodyForGenerationMock: IpcMock = vi.fn() export const loadPullRequestLinkedIssueMock: IpcMock = vi.fn() export const getSshFilesystemProviderMock: IpcMock = vi.fn() -export const getSshGitProviderMock: IpcMock = vi.fn() export const tryDeleteWslUncPathMock: IpcMock = vi.fn() export const recordCrashBreadcrumbMock: IpcMock = vi.fn() -export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn() export const electronMock = { app: { getPath: () => '/orca-test-user-data' }, @@ -83,10 +81,6 @@ export const wslUncDeleteMock = { tryDeleteWslUncPath: tryDeleteWslUncPathMock } export const crashBreadcrumbMock = { recordCrashBreadcrumb: recordCrashBreadcrumbMock } -export const folderPromotionMock = { - promoteLocalDownloadedFolder: promoteLocalDownloadedFolderMock -} - export const gitStatusModuleMock = { commitChanges: commitChangesMock, getStatus: getStatusMock, @@ -114,25 +108,8 @@ export const gitWorktreeMock = { listWorktreesStrict: listWorktreesMock } -const PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - export const sshFilesystemDispatchMock = { - getSshFilesystemProvider: getSshFilesystemProviderMock, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error(PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider - } -} - -export const sshGitDispatchMock = { - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE, - sshGitProviderMissingError: () => new Error(PROVIDER_UNAVAILABLE_MESSAGE) + getSshFilesystemProvider: getSshFilesystemProviderMock } export const textGenerationModuleMock = { @@ -227,12 +204,10 @@ const ALL_MOCKS = [ fsPromisesMock, wslUncDeleteMock, crashBreadcrumbMock, - folderPromotionMock, gitStatusModuleMock, gitIgnoredPathsMock, gitWorktreeMock, sshFilesystemDispatchMock, - sshGitDispatchMock, textGenerationModuleMock, pullRequestContextMock, pullRequestTemplateMock, @@ -296,12 +271,14 @@ export function resetFilesystemIpcMocks(): void { trashItemMock.mockResolvedValue(undefined) // Default: not a WSL UNC path, so deletePath falls through to shell.trashItem. tryDeleteWslUncPathMock.mockResolvedValue(false) - promoteLocalDownloadedFolderMock.mockResolvedValue(undefined) showSaveDialogMock.mockResolvedValue({ canceled: true }) showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) fromWebContentsMock.mockReturnValue(null) - getSshGitProviderMock.mockReturnValue(null) - statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) + statMock.mockResolvedValue({ + size: 10, + isDirectory: () => false, + mtimeMs: 123 + }) renameMock.mockResolvedValue(undefined) rmMock.mockResolvedValue(undefined) openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10)))) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 70fe879323b..dfd1f4cea82 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -31,10 +31,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -48,10 +44,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index d8f5140e43d..ffd95e42e47 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import { open, writeFile } from 'node:fs/promises' import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { sanitizeLocalDownloadFilename } from '../../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' import { abortWhenRendererGone } from '../renderer-lifetime-abort' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { @@ -36,7 +35,14 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon } ) - registerFilesystemDownloadFolderHandlers() + ipcMain.handle( + 'fs:downloadFolder', + async (_event, args: { dirPath?: string; connectionId?: string }): Promise => { + validateRequiredString(args?.dirPath, 'dirPath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + throw new SshHostNotServedError(connectionId) + } + ) ipcMain.handle( 'fs:saveDownloadedFile', diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts index f0fad64322f..5e1c9666cd8 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts @@ -12,7 +12,6 @@ vi.mock('../registered-worktree-roots-cache', () => ({ resolveRegisteredWorktreePath: async (path: string) => path })) vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() })) -vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets' const repo: Repo = { 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 62c3644f52c..5d3de958c5b 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 @@ -3,8 +3,6 @@ import type { Store } from '../persistence' const mocks = vi.hoisted(() => ({ gitExec: vi.fn(), - getProvider: vi.fn(), - getProviderGeneration: vi.fn(), resolveRegisteredPath: vi.fn(), getLocalRepo: vi.fn(), getLocalOptions: vi.fn(), @@ -12,11 +10,6 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExec })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getProvider, - getSshGitProviderGeneration: mocks.getProviderGeneration, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable' -})) vi.mock('./registered-worktree-roots-cache', () => ({ resolveRegisteredWorktreePath: mocks.resolveRegisteredPath })) @@ -34,7 +27,6 @@ import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' describe('applyGitStatusUpstreamRefWatchRequest', () => { beforeEach(() => { vi.clearAllMocks() - mocks.getProviderGeneration.mockReturnValue(0) mocks.resolveRegisteredPath.mockResolvedValue('/resolved/repo') mocks.getLocalOptions.mockReturnValue({}) mocks.setWatch.mockImplementation( @@ -68,11 +60,7 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => { ) }) - 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) - + it('binds the SSH watch but refuses to resolve on an SSH host', async () => { await expect( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH refusal throws before any store read. applyGitStatusUpstreamRefWatchRequest({} as Store, { @@ -86,10 +74,9 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => { ).rejects.toThrow(SshHostNotServedError) expect(mocks.setWatch).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }), + expect.objectContaining({ connectionId: 'ssh-1' }), expect.any(Function) ) - 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 d3811c827d4..5901128f42e 100644 --- a/src/main/ipc/git-status-upstream-ref-watch-request.ts +++ b/src/main/ipc/git-status-upstream-ref-watch-request.ts @@ -1,7 +1,6 @@ import type { Store } from '../persistence' import { resolveGitStatusUpstreamRef } from '../git/status-upstream-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' import { getLocalGitOptionsForRepo, @@ -12,10 +11,7 @@ import type { GitStatusRefBindingRequest } from './worktree-git-status-ref-watch import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../shared/execution-host' -export type GitStatusUpstreamRefWatchRequest = Omit< - GitStatusRefBindingRequest, - 'providerGeneration' -> +export type GitStatusUpstreamRefWatchRequest = GitStatusRefBindingRequest const UPSTREAM_REF_RESOLUTION_TIMEOUT_MS = 15_000 @@ -27,34 +23,28 @@ export function applyGitStatusUpstreamRefWatchRequest( store: Store, args: GitStatusUpstreamRefWatchRequest ): Promise { - const providerGeneration = args.connectionId - ? getSshGitProviderGeneration(args.connectionId) - : undefined - return setWorktreeGitStatusRefWatch( - { ...args, ...(providerGeneration !== undefined ? { providerGeneration } : {}) }, - async (bindingSignal) => { - if (!args.branch || !args.upstreamName) { - return undefined - } - const signal = boundedSignal(bindingSignal) - requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - return resolveGitStatusUpstreamRef( - (gitArgs, cwd, requestSignal) => - gitExecFileAsync(gitArgs, { - cwd, - signal: requestSignal, - timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) - }), - worktreePath, - args.branch, - args.upstreamName, - signal - ) + return setWorktreeGitStatusRefWatch(args, async (bindingSignal) => { + if (!args.branch || !args.upstreamName) { + return undefined } - ) + const signal = boundedSignal(bindingSignal) + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + return resolveGitStatusUpstreamRef( + (gitArgs, cwd, requestSignal) => + gitExecFileAsync(gitArgs, { + cwd, + signal: requestSignal, + timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }), + worktreePath, + args.branch, + args.upstreamName, + signal + ) + }) } diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index 443f3b108db..2ef043fda4b 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -2,10 +2,7 @@ import { getStoredRepoSshConnectionId } from '../../repo-execution-host' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' -import { isENOENT } from '../filesystem-path-containment' +import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' @@ -30,27 +27,13 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } } - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } + if (getStoredRepoSshConnectionId(repo)) { + // Why: this process serves no SSH filesystem. + return { + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false } } diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index ca1bfbcfb78..e5d840f73a0 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -4,14 +4,7 @@ import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { isENOENT } from '../filesystem-path-containment' -import { parseOrcaYaml } from '../../hooks' -import { - isIssueCommandIgnoredByGit, - readIssueCommand, - writeIssueCommand -} from '../../issue-command-file' +import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' @@ -35,52 +28,14 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v } const connectionId = getStoredRepoSshConnectionId(repo) if (connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { - status: 'error', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - - let status: 'ok' | 'error' = 'ok' - let localContent: string | null = null - let sharedContent: string | null = null - try { - const result = await fsProvider.readFile(issueCommandPath) - localContent = result.isBinary ? null : result.content.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - sharedContent = result.isBinary - ? null - : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - const effectiveContent = localContent ?? sharedContent + // Why: this process serves no SSH filesystem. return { - status: localContent ? 'ok' : status, - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: joinWorktreeRelativePath(repo.path, '.orca/issue-command'), + source: 'none' as const } } return readIssueCommand(repo.path) @@ -94,44 +49,8 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v if (!repo || isFolderRepo(repo)) { return } - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - throw new Error( - 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' - ) - } - const trimmed = args.content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - if (await isIssueCommandIgnoredByGit(repo.path, connectionId)) { - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return - } - const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') - try { - const result = await fsProvider.readFile(gitignorePath) - if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { - const separator = result.content.endsWith('\n') ? '' : '\n' - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - await fsProvider.writeFile(gitignorePath, '.orca\n') - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return + if (getStoredRepoSshConnectionId(repo)) { + throw new Error('Remote filesystem unavailable. Reconnect the SSH target before retrying.') } await writeIssueCommand(repo.path, args.content, () => getLocalProjectWorktreeGitOptions(store, repo) diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts index 6df6e79dbb8..9f5cb4b36c6 100644 --- a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -4,7 +4,6 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports' import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { readFile, stat } from 'node:fs/promises' import { isENOENT } from '../filesystem-path-containment' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' @@ -17,27 +16,14 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex 'hooks:inspectSetupScriptImports', async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { + // Why: this process serves no SSH filesystem. + if (!repo || isFolderRepo(repo) || getStoredRepoSshConnectionId(repo)) { return [] } return inspectSetupScriptImportCandidates( async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - try { return await readFile(filePath, 'utf-8') } catch (error) { @@ -50,20 +36,6 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex { fileExists: async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return false - } - try { - const fileStat = await fsProvider.stat(filePath) - return fileStat.type !== 'directory' - } catch { - return false - } - } - try { const fileStat = await stat(filePath) return !fileStat.isDirectory() diff --git a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts index d38ac9b3b4a..c4a705f6b3b 100644 --- a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts +++ b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts @@ -10,13 +10,7 @@ const mocks = vi.hoisted(() => ({ localRead: vi.fn(), localWrite: vi.fn(), localFileRead: vi.fn(), - localStat: vi.fn(), - remoteRead: vi.fn(), - remoteWrite: vi.fn(), - remoteStat: vi.fn(), - remoteMkdir: vi.fn(), - provider: vi.fn(), - ignored: vi.fn() + localStat: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { @@ -33,16 +27,15 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../../issue-command-file', () => ({ readIssueCommand: mocks.localRead, - writeIssueCommand: mocks.localWrite, - isIssueCommandIgnoredByGit: mocks.ignored + writeIssueCommand: mocks.localWrite })) vi.mock('../../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) -vi.mock('../../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: mocks.provider +vi.mock('node:fs/promises', () => ({ + readFile: mocks.localFileRead, + stat: mocks.localStat })) -vi.mock('node:fs/promises', () => ({ readFile: mocks.localFileRead, stat: mocks.localStat })) vi.mock('../../../shared/setup-script-imports', () => ({ inspectSetupScriptImportCandidates: async ( read: (path: string) => Promise, @@ -67,22 +60,11 @@ import { registerWorktreeHookInspectionHandler } from './register-worktree-hook- beforeEach(() => { vi.clearAllMocks() mocks.handlers.clear() - mocks.remoteRead.mockResolvedValue({ content: 'remote command', isBinary: false }) - mocks.remoteWrite.mockResolvedValue(undefined) - mocks.remoteMkdir.mockResolvedValue(undefined) - mocks.remoteStat.mockResolvedValue({ type: 'file' }) - mocks.ignored.mockResolvedValue(true) mocks.localRead.mockResolvedValue({ localContent: 'wrong host' }) mocks.localWrite.mockResolvedValue(undefined) mocks.localFileRead.mockResolvedValue('wrong host') mocks.localStat.mockResolvedValue({ isDirectory: () => false }) mocks.hasHooks.mockReturnValue(false) - mocks.provider.mockReturnValue({ - readFile: mocks.remoteRead, - writeFile: mocks.remoteWrite, - createDir: mocks.remoteMkdir, - stat: mocks.remoteStat - }) }) const owners = [ @@ -151,21 +133,14 @@ const localCalls = [ mocks.effectiveHooks ] +// Why: this process serves no SSH filesystem, so an SSH owner is neither read remotely nor locally. function expectRoute(target: string | null): void { if (target) { - expect(mocks.provider).toHaveBeenCalledWith(target) - for (const [connectionId] of mocks.provider.mock.calls) { - expect(connectionId).toBe(target) - } for (const method of localCalls) { expect(method).not.toHaveBeenCalled() } } else { - expect(mocks.provider).not.toHaveBeenCalled() expect(localCalls.some((method) => method.mock.calls.length > 0)).toBe(true) - expect(mocks.remoteRead).not.toHaveBeenCalled() - expect(mocks.remoteWrite).not.toHaveBeenCalled() - expect(mocks.remoteStat).not.toHaveBeenCalled() } } @@ -173,19 +148,17 @@ describe.each(owners)('desktop hooks: $label', ({ fields, target }) => { it.each(channels)('routes %s to the stored owner', async (channel) => { const repo = makeRepo(fields) register(repo) - await mocks.handlers.get(channel)!(null, { - repoId: repo.id, - hostId: getRepoExecutionHostId(repo), - content: 'save' - }) + const result = Promise.resolve( + mocks.handlers.get(channel)!(null, { + repoId: repo.id, + hostId: getRepoExecutionHostId(repo), + content: 'save' + }) + ) + await (channel === 'hooks:writeIssueCommand' && target + ? expect(result).rejects.toThrow('Remote filesystem unavailable') + : result) expectRoute(target) - if (channel === 'hooks:writeIssueCommand' && target) { - expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target) - expect(mocks.remoteWrite).toHaveBeenCalledWith( - '/remote/fixture/.orca/issue-command', - 'save\n' - ) - } }) }) @@ -210,16 +183,7 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { } else { await issue.write(repo.id, 'save') } - if (target) { - // The runtime serves no SSH filesystem: an SSH owner is neither read remotely nor locally. - expect(mocks.provider).not.toHaveBeenCalled() - expect(mocks.remoteWrite).not.toHaveBeenCalled() - for (const method of localCalls) { - expect(method).not.toHaveBeenCalled() - } - } else { - expectRoute(target) - } + expectRoute(target) } ) }) @@ -227,7 +191,6 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { it('refuses an unreachable canonical SSH write without touching local files', async () => { const repo = makeRepo({ executionHostId: 'ssh:host-a' }) register(repo) - mocks.provider.mockReturnValue(null) await expect( mocks.handlers.get('hooks:writeIssueCommand')!(null, { repoId: repo.id, @@ -246,7 +209,6 @@ it.each(channels)('keeps folder repositories out of %s', async (channel) => { hostId: 'ssh:host-a', content: 'save' }) - expect(mocks.provider).not.toHaveBeenCalled() expect(mocks.hasHooks).not.toHaveBeenCalled() expect(mocks.localRead).not.toHaveBeenCalled() expect(mocks.localWrite).not.toHaveBeenCalled() diff --git a/src/main/ipc/orcad-delta-move-handlers.test.ts b/src/main/ipc/orcad-delta-move-handlers.test.ts index 26f0dc386e7..47bc074b325 100644 --- a/src/main/ipc/orcad-delta-move-handlers.test.ts +++ b/src/main/ipc/orcad-delta-move-handlers.test.ts @@ -32,9 +32,12 @@ vi.mock('../ssh/orcad-migration-delta-move', () => ({ vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({ orcadMigrationDestinationFor: () => ({}) })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() })) -vi.mock('../ssh/ssh-target-registry', () => ({ hasRegisteredDirectSshAuthority: () => false })) -vi.mock('./ssh-session-teardown', () => ({ disconnectRegisteredSshTarget: vi.fn() })) +vi.mock('../ssh/orcad-managed-tunnel', () => ({ + ensureOrcadManagedTunnel: vi.fn() +})) +vi.mock('./ssh-session-teardown', () => ({ + disconnectRegisteredSshTarget: vi.fn() +})) vi.mock('./runtime-environment-managed-tunnel', () => ({ publishResolvedChangedHostStatus: mocks.publish })) diff --git a/src/main/ipc/orcad-delta-move-handlers.ts b/src/main/ipc/orcad-delta-move-handlers.ts index b2a3353e22a..fa9abfb85db 100644 --- a/src/main/ipc/orcad-delta-move-handlers.ts +++ b/src/main/ipc/orcad-delta-move-handlers.ts @@ -11,9 +11,7 @@ import { keepOrcadServerVersion, runOrcadDeltaMove } from '../ssh/orcad-migratio import { planOrcadDeltaMove } from '../ssh/orcad-migration-delta-plan' import { censusHostRelayTerminalsFor } from '../ssh/ssh-host-relay-census-for-target' import { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring' -import { hasRegisteredDirectSshAuthority } from '../ssh/ssh-target-registry' import { requiredString } from './orcad-runtime-lifecycle-handlers' -import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { publishResolvedChangedHostStatus } from './runtime-environment-managed-tunnel' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' @@ -46,11 +44,6 @@ export function registerOrcadDeltaMoveHandlers(getUserDataPath: () => string): v environment, destination: orcadMigrationDestinationFor(environment), censusHost: censusHostRelayTerminalsFor(target), - releaseDirectSession: async (targetId) => { - if (hasRegisteredDirectSshAuthority(targetId)) { - await disconnectRegisteredSshTarget(targetId) - } - }, ensureTunnel: async () => { await ensureOrcadManagedTunnel(userDataPath, environment.id) }, diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 03bac6611be..441631c9146 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -10,7 +10,6 @@ import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus } from '../../../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' export function assertFolderWorkspacePtyPathUsable( store: Store | undefined, @@ -20,11 +19,10 @@ export function assertFolderWorkspacePtyPathUsable( if (!store || workspaceScope?.type !== 'folder') { return } - return getFolderWorkspacePathStatus( - store, - { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, - { getSshFilesystemProvider } - ).then(assertFolderWorkspacePathUsable) + return getFolderWorkspacePathStatus(store, { + scope: 'folder-workspace', + folderWorkspaceId: workspaceScope.folderWorkspaceId + }).then(assertFolderWorkspacePathUsable) } export function resolvePtySpawnStartupCwd( diff --git a/src/main/ipc/qoder-desktop-execution-host.test.ts b/src/main/ipc/qoder-desktop-execution-host.test.ts index 16a289cfb8f..25f4e643a5e 100644 --- a/src/main/ipc/qoder-desktop-execution-host.test.ts +++ b/src/main/ipc/qoder-desktop-execution-host.test.ts @@ -14,9 +14,9 @@ vi.mock('./preflight-command-exec', () => ({ isCommandOnPath: vi.fn() })) vi.mock('./local-agent-install-dir-detection', () => ({ detectCommandsInInstallDirs: () => new Set() })) -vi.mock('./preflight-wsl-agent-detection', () => ({ detectWslCommandsOnPath: vi.fn() })) -const { mux } = vi.hoisted(() => ({ mux: vi.fn() })) -vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux })) +vi.mock('./preflight-wsl-agent-detection', () => ({ + detectWslCommandsOnPath: vi.fn() +})) vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), stat: vi.fn(async () => ({ isDirectory: () => true })) @@ -76,7 +76,6 @@ describe('desktop Qoder execution-host selection', () => { beforeEach(() => { nativeProbe.mockReset().mockImplementation(async (cmd) => cmd === 'qodercli') wslProbe.mockReset().mockResolvedValue(new Set(['qoder'])) - mux.mockReset() }) async function spawn(args: Partial = {}, store?: Store) { const providerSpawn = setupDaemonAdapter() @@ -171,7 +170,6 @@ describe('desktop Qoder execution-host selection', () => { expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: args.command })) expect(nativeProbe).not.toHaveBeenCalled() expect(wslProbe).not.toHaveBeenCalled() - expect(mux).not.toHaveBeenCalled() }) it('uses the selected project WSL distro rather than installed native legacy', async () => { await withWin32Platform(async () => { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 1433a1e816c..80cbb991fca 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -70,9 +70,6 @@ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) -vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) - import { registerRepoHandlers } from './repos' const MAIN_CHECKOUT = '/Users/dev/projects/orca' diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 74797fa3d6a..6f465188b5d 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -87,14 +87,6 @@ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' } diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index e3b584977d2..4ffced7d679 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -21,10 +21,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index b3cf63793bb..eca40c3c161 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -21,10 +21,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index e79be629d90..d3d94f1b82d 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -25,10 +25,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index de92762f264..f6181df6a3d 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -24,10 +24,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -35,7 +31,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' -const { handleMock, mockStore, mockFilesystemProvider, listWorktreeGraphMock } = reposMocks +const { handleMock, mockStore, listWorktreeGraphMock } = reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -142,7 +138,6 @@ describe('projectGroups IPC validation', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() expect(isGitRepo).not.toHaveBeenCalled() - expect(mockFilesystemProvider.readDir).not.toHaveBeenCalled() }) it('imports a small selection from a large nested scan', async () => { diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index b1088832bcd..a1a7d402c8f 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -24,10 +24,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -35,7 +31,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' -const { handleMock, mockStore, mockFilesystemProvider } = reposMocks +const { handleMock, mockStore } = reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -60,7 +56,6 @@ describe('projectGroups IPC validation', () => { connectionId: 'conn-1' }) ).rejects.toThrow('ssh_connection_unavailable') - expect(mockFilesystemProvider.readDir).not.toHaveBeenCalled() expect(isGitRepo).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index ce658981574..be3029d24ff 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -32,14 +32,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' describe('repos folder pickers', () => { diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 08c5e1c042b..bb9822fa0d7 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -21,10 +21,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -32,13 +28,8 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createRepoHandlerHarness } from './repos-remote-test-harness' import { getBaseRefDefault } from '../git/repo' -const { - handleMock, - mockStore, - mockGitProvider, - gitExecFileAsyncMock, - prepareLocalWorktreeRootForRepoMock -} = reposMocks +const { handleMock, mockStore, gitExecFileAsyncMock, prepareLocalWorktreeRootForRepoMock } = + reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -54,7 +45,6 @@ describe('repos:getBaseRefDefault envelope', () => { mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) // Reset exec so a newly added test doesn't inherit the previous test's exec mock. - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) gitExecFileAsyncMock.mockReset() vi.mocked(getBaseRefDefault).mockClear() registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) @@ -108,7 +98,6 @@ describe('repos:getBaseRefDefault envelope', () => { const result = await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' }) expect(result).toEqual({ defaultBaseRef: null, remoteCount: 0 }) - expect(mockGitProvider.exec).not.toHaveBeenCalled() expect(getBaseRefDefault).not.toHaveBeenCalled() }) }) @@ -121,7 +110,6 @@ describe('repos:searchBaseRefs on SSH repos', () => { mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) gitExecFileAsyncMock.mockReset() vi.mocked(getBaseRefDefault).mockClear() registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) @@ -141,7 +129,6 @@ describe('repos:searchBaseRefs on SSH repos', () => { }) expect(result).toEqual([]) - expect(mockGitProvider.exec).not.toHaveBeenCalled() }) it('returns [] for an SSH repo without running git', async () => { @@ -158,7 +145,6 @@ describe('repos:searchBaseRefs on SSH repos', () => { }) expect(result).toEqual([]) - expect(mockGitProvider.exec).not.toHaveBeenCalled() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index fbd3197a805..0427c612c2e 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -38,8 +38,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn(), invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) -vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) type HandlerMap = Map unknown> diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index aa07aa5527d..6a4c0b48174 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -21,23 +21,14 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createRepoHandlerHarness } from './repos-remote-test-harness' -const { - handleMock, - mockStore, - mockGitProvider, - gitExecFileAsyncMock, - prepareLocalWorktreeRootForRepoMock -} = reposMocks +const { handleMock, mockStore, gitExecFileAsyncMock, prepareLocalWorktreeRootForRepoMock } = + reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -50,7 +41,6 @@ describe('repos:getGitUsername', () => { beforeEach(() => { captureHandlers(handleMock) mockStore.getRepo.mockReset() - mockGitProvider.exec.mockReset() gitExecFileAsyncMock.mockReset() mockWindow.webContents.send.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) @@ -72,7 +62,6 @@ describe('repos:getGitUsername', () => { const username = await handlers.get('repos:getGitUsername')!(null, { repoId: 'repo-ssh' }) expect(username).toBe('') - expect(mockGitProvider.exec).not.toHaveBeenCalled() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index df6dcfa16d4..0f5c8c127ca 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -28,9 +28,9 @@ export type ReposIpcMocks = { | 'getSshTarget' | 'getSshTargets', ReposIpcSpy - > & { updateRepo: Mock<(repoId: string, updates: Record) => unknown> } - mockGitProvider: { exec: ReposGitArgvSpy } - mockFilesystemProvider: Record<'readDir', ReposIpcSpy> + > & { + updateRepo: Mock<(repoId: string, updates: Record) => unknown> + } gitSpawnMock: ReposIpcSpy gitSpawnAfterWindowsEnvironmentReadyMock: ReposIpcSpy gitExecFileAsyncMock: ReposGitArgvSpy @@ -59,13 +59,6 @@ export function createReposIpcMocks(): ReposIpcMocks { getSshTarget: vi.fn(), getSshTargets: vi.fn().mockReturnValue([]) }, - // Why: SSH targets are not served; suites assert these stand-ins are never reached. - mockGitProvider: { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - }, - mockFilesystemProvider: { - readDir: vi.fn().mockResolvedValue([]) - }, gitSpawnMock: vi.fn(), gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -137,29 +130,6 @@ export function worktreeRootPreparationModuleMock(mocks: ReposIpcMocks): Record< return { prepareLocalWorktreeRootForRepo: mocks.prepareLocalWorktreeRootForRepoMock } } -export function sshGitDispatchModuleMock(mocks: ReposIpcMocks): Record { - return { - getSshGitProviderGeneration: () => 0, - getSshGitProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockGitProvider - } - return undefined - }) - } -} - -export function sshFilesystemDispatchModuleMock(mocks: ReposIpcMocks): Record { - return { - getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockFilesystemProvider - } - return undefined - }) - } -} - export type RepoHandlerHarness = { handlers: Map unknown> mockWindow: { isDestroyed: () => boolean; webContents: { send: ReposIpcSpy } } @@ -201,8 +171,6 @@ export function resetProjectGroupMocks( mocks.mockStore.updateProjectHostSetup.mockReset() mocks.mockStore.getRepos.mockReset() mocks.mockStore.getRepos.mockReturnValue([]) - mocks.mockFilesystemProvider.readDir.mockReset() - mocks.mockFilesystemProvider.readDir.mockResolvedValue([]) mocks.listWorktreeGraphMock.mockReset() mocks.listWorktreeGraphMock.mockResolvedValue([]) vi.mocked(gitRepo.isGitRepo).mockReset() diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index df660555c0d..651b31069ee 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -46,14 +46,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' type HandlerMap = Map unknown> diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 744d919d282..638591ce227 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -8,7 +8,6 @@ import { getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath } from '../../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { notifyReposChanged } from './repos-changed-notification' import { @@ -33,7 +32,7 @@ export function registerFolderWorkspaceHandlers( rawArgs, 'invalid_folder_workspace_path_status_args' ) as FolderWorkspacePathStatusRequest - return getFolderWorkspacePathStatus(store, args, { getSshFilesystemProvider }) + return getFolderWorkspacePathStatus(store, args) }) ipcMain.handle( @@ -53,16 +52,13 @@ export function registerFolderWorkspaceHandlers( if (!group || !folderPath) { throw new Error('folder_workspace_project_group_not_found') } - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath, - projectGroupId: group.id, - connectionId: args.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath, + projectGroupId: group.id, + connectionId: args.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) const workspace = store.createFolderWorkspace({ ...args, @@ -90,19 +86,16 @@ export function registerFolderWorkspaceHandlers( return null } const projectGroups = store.getProjectGroups() - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath: args.updates.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: - workspace.connectionId ?? - projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath: args.updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) } const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates) diff --git a/src/main/ipc/ssh-app-shutdown.test.ts b/src/main/ipc/ssh-app-shutdown.test.ts index d26cd9fdd99..1435a0615d4 100644 --- a/src/main/ipc/ssh-app-shutdown.test.ts +++ b/src/main/ipc/ssh-app-shutdown.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-connect-decision-cancellation.test.ts b/src/main/ipc/ssh-connect-decision-cancellation.test.ts index bb99dd2f3fc..13b802f13a2 100644 --- a/src/main/ipc/ssh-connect-decision-cancellation.test.ts +++ b/src/main/ipc/ssh-connect-decision-cancellation.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-disconnect-cancellation.test.ts b/src/main/ipc/ssh-disconnect-cancellation.test.ts index 33119e75972..ba666c250af 100644 --- a/src/main/ipc/ssh-disconnect-cancellation.test.ts +++ b/src/main/ipc/ssh-disconnect-cancellation.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-handler-reregistration.test.ts b/src/main/ipc/ssh-handler-reregistration.test.ts index 57615fc2f67..9c3203233ff 100644 --- a/src/main/ipc/ssh-handler-reregistration.test.ts +++ b/src/main/ipc/ssh-handler-reregistration.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts index 2a7f6acd209..35713ff60f9 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts @@ -23,8 +23,7 @@ vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getVersion: () => '1.5.0', getPath: () => '/tmp/unused' }) })) vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => mocks.registry.current, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.registry.current })) vi.mock('../ssh/orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('../ssh/orcad-runtime-conversion', () => ({ diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index bedc61f0751..7d1603a9632 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -26,14 +26,10 @@ import { setSshHostServerStatus } from '../ssh/ssh-host-server-status' import { trackSshHostServerEvent } from '../ssh/ssh-host-server-telemetry' import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { knownOrcadTunnelTransport } from '../ssh/orcad-tunnel-transport-memo' -import { - getSshTargetRegistryStore, - hasRegisteredDirectSshAuthority -} from '../ssh/ssh-target-registry' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { releaseUnreachableOrcadSetup } from '../ssh/orcad-unreachable-setup-release' import { getCurrentMainWindow } from './ssh-ipc-context' import { broadcastSshState } from './ssh-renderer-broadcast' -import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { connectInFlight } from './ssh-connect-attempt-registry' export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConnectDeps { @@ -100,14 +96,8 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne // The connect's own census already answered; a conversion before any session reuses it. censusHost: (host) => hostProof ? Promise.resolve(hostProof) : censusHostRelayTerminalsFor(host)(), - destinationFor: orcadMigrationDestinationFor, - // Why guarded: this runs before the connect registers a session, and an unconditional - // disconnect would cancel the very connect attempt that asked for the conversion. - releaseDirectSession: async (targetId) => { - if (hasRegisteredDirectSshAuthority(targetId)) { - await disconnectRegisteredSshTarget(targetId) - } - } + // Why no releaseDirectSession: a disconnect here would cancel the connect that asked for it. + destinationFor: orcadMigrationDestinationFor }), abandonDeploy: async (target) => { const environmentId = diff --git a/src/main/ipc/ssh-ipc-mock-shapes.ts b/src/main/ipc/ssh-ipc-mock-shapes.ts index 3ec62e57b98..8ce34f58b0b 100644 --- a/src/main/ipc/ssh-ipc-mock-shapes.ts +++ b/src/main/ipc/ssh-ipc-mock-shapes.ts @@ -61,9 +61,6 @@ export type SshIpcMockState = { mockSshStore: SshStoreMock mockConnectionManager: SshConnectionManagerMock mockMux: SshChannelMultiplexerMock - mockFsProvider: Record - mockGitProvider: Record - mockRegisterSshGitProvider: Mock mockPortForwardManager: SshPortForwardManagerMock mockPortScannerCallbacks: Map mockListConfigHosts: Mock @@ -82,11 +79,7 @@ export type SshIpcMockModules = { sshConnectionStore: SshIpcMockModule sshConnectionManager: SshIpcMockModule sshChannelMultiplexer: SshIpcMockModule - sshFilesystemProvider: SshIpcMockModule pty: SshIpcMockModule - sshFilesystemDispatch: SshIpcMockModule - sshGitProvider: SshIpcMockModule - sshGitDispatch: SshIpcMockModule sshPortForward: SshIpcMockModule sshPortScanner: SshIpcMockModule } diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index 018a99ea89b..1255cd6e5e5 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -46,9 +46,6 @@ export function createSshIpcMocks(): SshIpcMocks { notify: vi.fn(), probeLiveness: vi.fn().mockResolvedValue(false) }, - mockFsProvider: {}, - mockGitProvider: {}, - mockRegisterSshGitProvider: vi.fn(), mockPortForwardManager: { addForward: vi.fn(), updateForward: vi.fn(), @@ -79,9 +76,6 @@ export function createSshIpcMocks(): SshIpcMocks { mockSshStore, mockConnectionManager, mockMux, - mockFsProvider, - mockGitProvider, - mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockListConfigHosts, @@ -156,13 +150,6 @@ export function createSshIpcMocks(): SshIpcMocks { } } }, - sshFilesystemProvider: { - SshFilesystemProvider: class MockSshFilesystemProvider { - constructor() { - return mockFsProvider - } - } - }, pty: { clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), @@ -172,22 +159,6 @@ export function createSshIpcMocks(): SshIpcMocks { isCurrentPtyExit: vi.fn().mockReturnValue(true), isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) }, - sshFilesystemDispatch: { - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() - }, - sshGitProvider: { - SshGitProvider: class MockSshGitProvider { - constructor() { - return mockGitProvider - } - } - }, - sshGitDispatch: { - registerSshGitProvider: mockRegisterSshGitProvider, - unregisterSshGitProvider: vi.fn() - }, sshPortForward: { SshPortForwardManager: class MockPortForwardManager { constructor() { diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 13ed1ca3787..ba7f4283b4d 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -36,7 +36,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockSshStore, mockConnectionManager, mockMux, - mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, @@ -118,7 +117,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockMux.onDispose.mockReset().mockReturnValue(() => {}) mockMux.request.mockReset().mockResolvedValue({}) mockMux.probeLiveness.mockReset().mockResolvedValue(false) - mockRegisterSshGitProvider.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 5acca148370..80a9e61fc7b 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index 835b454550c..726da4520df 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 8bd739f6c97..33b28c79f3c 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index a3fa33d6f1b..2dcbcc046e8 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -12,17 +12,6 @@ import { setSshTargetRegistryStore } from '../ssh/ssh-target-registry' -// Why re-exported: the registry moved to ../ssh/ssh-target-registry so the runtime can -// read it without pulling ipcMain in, but many existing importers reference these from -// here. Re-exporting keeps them working without a repo-wide rename. -export { - connectRegisteredSshTarget, - getActiveMultiplexer, - getRegisteredSshState, - getSshConnectionManager, - listRegisteredRemovedSshTargetLabels, - listRegisteredSshTargets -} from '../ssh/ssh-target-registry' import { registerSshBrowseHandler } from './ssh-browse' import { registerCredentialHandler } from './ssh-passphrase' import type { OrcaRuntimeService } from '../runtime/orca-runtime' diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index 39b1dbaec20..f790c2fef00 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -293,7 +293,7 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) const worktree = await resolveWorkspaceCleanupActivityWorktree( - { kind: 'ssh', hostId: 'ssh:ssh-1', connectionId: 'ssh-1', provider: null }, + { kind: 'ssh', hostId: 'ssh:ssh-1', connectionId: 'ssh-1' }, makeWorktree({ createdAt: 10_000 }), statPath ) diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 66555d216ac..c0dbd18607f 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -14,16 +14,14 @@ const { listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, - getLocalProjectWorktreeGitOptionsMock, - getSshGitProviderMock + getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getLocalProjectWorktreeGitOptionsMock: vi.fn() })) vi.mock('node:fs/promises', () => ({ @@ -47,10 +45,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -150,7 +144,6 @@ describe('workspace cleanup broad scan opt-in', () => { } satisfies GitStatusResult) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset().mockReturnValue(undefined) }) afterEach(() => { @@ -191,53 +184,6 @@ describe('workspace cleanup broad scan opt-in', () => { ).toBe('runtime:env-1') }) - it('does not borrow same-id local metadata for a connected SSH cleanup row', async () => { - const sharedPath = '/shared/workspace' - const worktreeId = `${REPO.id}::${sharedPath}` - const localRepo = { ...REPO, path: '/local/repo' } - const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' } - const sharedWorktree: GitWorktreeInfo = { - path: sharedPath, - head: 'shared123', - branch: 'refs/heads/shared', - isBare: false, - isMainWorktree: false - } - const localMeta = makeWorktreeMeta({ - displayName: 'Local metadata only', - hostId: 'local', - isPinned: true, - lastActivityAt: NOW - 40 * DAY_MS - }) - listRepoWorktreesMock.mockResolvedValue([sharedWorktree]) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([sharedWorktree]) - }) - - const result = await scanWorkspaceCleanup( - makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }), - { includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] } - ) - - expect(result.candidates).toHaveLength(2) - expect(result.candidates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - worktreeId, - executionHostId: 'local', - displayName: 'Local metadata only', - blockers: ['pinned'] - }), - expect.objectContaining({ - worktreeId, - executionHostId: 'ssh:ssh-1', - displayName: 'shared', - blockers: [] - }) - ]) - ) - }) - it('reads git for a pinned target during focused preflight scans', async () => { const worktreeId = 'repo-1::/repo-old' const pinnedMeta = makeWorktreeMeta({ @@ -354,47 +300,6 @@ describe('workspace cleanup broad scan opt-in', () => { ]) }) - it('does not publish a same-id local folder instance on an SSH host', async () => { - const localFolderRepo = { ...FOLDER_REPO } - const sshFolderRepo = { ...FOLDER_REPO, connectionId: 'ssh-1' } - const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` - getSshGitProviderMock.mockReturnValue({}) - - const result = await scanWorkspaceCleanup( - makeStore([localFolderRepo, sshFolderRepo], { - [instanceId]: makeWorktreeMeta({ - displayName: 'Local folder session', - hostId: 'local' - }) - }), - { includeAllWorkspaces: true } - ) - - expect(result.candidates).toHaveLength(3) - expect(result.candidates.filter((candidate) => candidate.worktreeId === instanceId)).toEqual([ - expect.objectContaining({ - executionHostId: 'local', - displayName: 'Local folder session' - }) - ]) - }) - - it('does not mark connected SSH folder workspaces as disconnected', async () => { - const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } - getSshGitProviderMock.mockReturnValue({}) - - const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), { - includeAllWorkspaces: true - }) - - expect(result.candidates).toHaveLength(1) - expect(result.candidates[0]).toMatchObject({ - worktreeId: 'repo-folder::/folder-workspace', - connectionId: 'ssh-1', - blockers: ['main-worktree', 'folder-repo'] - }) - }) - it('defers git evidence for rows that no cleanup decision can use', async () => { const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true diff --git a/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts b/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts index 1b193afa2f2..71869b413a3 100644 --- a/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts +++ b/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts @@ -11,7 +11,6 @@ const { listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, - getSshGitProviderMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -20,7 +19,6 @@ const { listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn() })) @@ -38,11 +36,6 @@ vi.mock('../repo-worktrees', () => ({ vi.mock('../git/status', () => ({ getStatus: getStatusMock })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -61,32 +54,6 @@ const CLEAN_STATUS: GitStatusResult = { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } } -/** - * Two SSH targets are registered at once for every case: routing that answers with "some - * connected host" instead of *this row's* host only shows up when a second one exists. - */ -const sshProviders = new Map>() - -function makeSshGitProvider(): { - listWorktrees: ReturnType - getStatus: ReturnType - exec: ReturnType -} { - return { - listWorktrees: vi.fn(async () => [ - { - path: WORKTREE_PATH, - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn(async () => CLEAN_STATUS), - exec: vi.fn(async () => ({ stdout: '0\n', stderr: '' })) - } -} - function makeRepo(overrides: Partial = {}): Repo { return { id: REPO_ID, @@ -127,9 +94,6 @@ describe('workspace cleanup execution-host routing', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) - sshProviders.clear() - sshProviders.set('alpha', makeSshGitProvider()) - sshProviders.set('beta', makeSshGitProvider()) lstatMock.mockReset().mockResolvedValue({ mtimeMs: 0 }) readFileMock.mockReset().mockRejectedValue(new Error('not a gitdir pointer')) openMock.mockReset().mockRejectedValue(new Error('no reflog')) @@ -145,49 +109,20 @@ describe('workspace cleanup execution-host routing', () => { getStatusMock.mockReset().mockResolvedValue(CLEAN_STATUS) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset().mockImplementation((id: string) => sshProviders.get(id)) }) afterEach(() => { vi.useRealTimers() }) - it('lists an executionHostId-only SSH repo through its own host, never locally', async () => { + it('never lists or reads an executionHostId-only SSH repo on this machine', async () => { const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { [WORKTREE_ID]: makeMeta() }) - const result = await scanWorkspaceCleanup(store) - - expect(sshProviders.get('alpha')?.listWorktrees).toHaveBeenCalledWith('/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(sshProviders.get('beta')?.listWorktrees).not.toHaveBeenCalled() - expect(listRepoWorktreesMock).not.toHaveBeenCalled() - expect(result.candidates).toHaveLength(1) - expect(result.candidates[0]?.executionHostId).toBe('ssh:alpha') - }) - - it('reads git status and unpushed commits on the row-named host, not this machine', async () => { - const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { - [WORKTREE_ID]: makeMeta() - }) - getStatusMock.mockResolvedValue({ - ...CLEAN_STATUS, - upstreamStatus: { hasUpstream: false } - } as GitStatusResult) - sshProviders.get('alpha')?.getStatus.mockResolvedValue({ - ...CLEAN_STATUS, - upstreamStatus: { hasUpstream: false } - }) - await scanWorkspaceCleanup(store) - expect(sshProviders.get('alpha')?.getStatus).toHaveBeenCalledWith(WORKTREE_PATH, { - includeLineStats: false, - signal: expect.any(AbortSignal) - }) - expect(sshProviders.get('alpha')?.exec).toHaveBeenCalled() + expect(listRepoWorktreesMock).not.toHaveBeenCalled() expect(getStatusMock).not.toHaveBeenCalled() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -202,27 +137,6 @@ describe('workspace cleanup execution-host routing', () => { expect(lstatMock).not.toHaveBeenCalled() }) - it('keeps two simultaneously registered SSH hosts apart', async () => { - const store = makeStore( - [ - makeRepo({ id: 'repo-alpha', executionHostId: 'ssh:alpha' }), - makeRepo({ id: 'repo-beta', connectionId: 'beta' }) - ], - { - [`repo-alpha::${WORKTREE_PATH}`]: makeMeta(), - [`repo-beta::${WORKTREE_PATH}`]: makeMeta() - } - ) - - await scanWorkspaceCleanup(store) - - expect(sshProviders.get('alpha')?.listWorktrees).toHaveBeenCalledTimes(1) - expect(sshProviders.get('beta')?.listWorktrees).toHaveBeenCalledTimes(1) - expect(sshProviders.get('alpha')?.getStatus).toHaveBeenCalledTimes(1) - expect(sshProviders.get('beta')?.getStatus).toHaveBeenCalledTimes(1) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() - }) - it('refuses a runtime host that carries no nested SSH target instead of scanning locally', async () => { const store = makeStore([makeRepo({ executionHostId: 'runtime:env-a' })], { [WORKTREE_ID]: makeMeta() @@ -237,7 +151,7 @@ describe('workspace cleanup execution-host routing', () => { expect(result.errors).toEqual([]) }) - it('refuses a runtime host whose nested SSH target name is also registered on this client', async () => { + it('refuses a runtime host that names a nested SSH target', async () => { const store = makeStore( [makeRepo({ executionHostId: 'runtime:env-a', connectionId: 'alpha' })], { [WORKTREE_ID]: makeMeta() } @@ -245,11 +159,7 @@ describe('workspace cleanup execution-host routing', () => { const result = await scanWorkspaceCleanup(store) - // 'alpha' names this client's own host; the runtime row's nested target lives in - // env-a's namespace, so dialing it here would inspect an entirely different machine. - expect(sshProviders.get('alpha')?.listWorktrees).not.toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() - expect(sshProviders.get('beta')?.listWorktrees).not.toHaveBeenCalled() + // The runtime row's nested target lives in env-a's namespace, never this client's. expect(listRepoWorktreesMock).not.toHaveBeenCalled() expect(result.candidates).toEqual([]) }) @@ -262,13 +172,11 @@ describe('workspace cleanup execution-host routing', () => { const result = await scanWorkspaceCleanup(store, { worktreeIds: [WORKTREE_ID] }) - expect(sshProviders.get('alpha')?.listWorktrees).not.toHaveBeenCalled() expect(result.errors).toHaveLength(1) expect(result.errors[0]?.executionHostId).toBe('runtime:env-a') }) it('synthesizes disconnected rows for an executionHostId-only SSH host that is not connected', async () => { - sshProviders.delete('alpha') const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { [WORKTREE_ID]: makeMeta({ hostId: 'ssh:alpha' }) }) @@ -292,7 +200,6 @@ describe('workspace cleanup execution-host routing', () => { expect(listRepoWorktreesMock).toHaveBeenCalled() expect(getStatusMock).not.toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() expect(result.candidates).toHaveLength(1) expect(result.candidates[0]?.blockers).toContain('git-status-error') expect(result.candidates[0]?.executionHostId).toBe('ssh:alpha') @@ -307,7 +214,6 @@ describe('workspace cleanup execution-host routing', () => { expect(listRepoWorktreesMock).toHaveBeenCalled() expect(getStatusMock).toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() expect(result.candidates[0]?.executionHostId).toBe('local') }) }) diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index b57b6e99975..128332424d5 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -10,12 +10,9 @@ import { withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' import { resolveWorkspaceCleanupWorktreeGitRoute, - type WorkspaceCleanupGitRoute, - type WorkspaceCleanupWorktreeGitRoute + type WorkspaceCleanupGitRoute } from './workspace-cleanup-git-route' export type WorkspaceCleanupGitEvidence = { @@ -53,22 +50,23 @@ export async function readWorkspaceCleanupGitEvidence( ) return { ...createEmptyWorkspaceCleanupGitEvidence(), blockers: ['git-status-error'] } } - // Shared links are this machine's symlink layout; no remote checkout inherits it. - const sharedLinkPaths = route.kind === 'ssh' ? [] : getWorktreeSharedLinkPaths(repo) + // An unreachable remote host is an error, never a licence to read this machine's checkout. + if (route.kind === 'ssh') { + return { + ...createEmptyWorkspaceCleanupGitEvidence(), + blockers: ['git-status-error'] + } + } + const sharedLinkPaths = getWorktreeSharedLinkPaths(repo) try { status = await withWorkspaceCleanupTimeout( (signal) => - route.kind === 'ssh' - ? requireWorkspaceCleanupGitProvider(route).getStatus(worktree.path, { - includeLineStats: false, - signal - }) - : getStatus(worktree.path, { - includeLineStats: false, - signal, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }), + getStatus(worktree.path, { + includeLineStats: false, + signal, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out reading git status.', signal @@ -101,7 +99,7 @@ export async function readWorkspaceCleanupGitEvidence( blockers.push('unpushed-commits') } if (clean && upstreamAhead === null) { - const unpushedCommitCount = await readUnpushedCommitCount(worktree, route, signal) + const unpushedCommitCount = await readUnpushedCommitCount(worktree, signal) if (unpushedCommitCount === null) { blockers.push('unknown-base') } else if (unpushedCommitCount > 0) { @@ -120,22 +118,15 @@ export async function readWorkspaceCleanupGitEvidence( async function readUnpushedCommitCount( worktree: Worktree, - route: Exclude, signal?: AbortSignal ): Promise { try { const result = await withWorkspaceCleanupTimeout( (signal) => - route.kind === 'ssh' - ? requireWorkspaceCleanupGitProvider(route).exec( - ['rev-list', '--count', 'HEAD', '--not', '--remotes'], - worktree.path, - { signal } - ) - : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { - cwd: worktree.path, - signal - }), + gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path, + signal + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out checking unpushed commits.', signal @@ -150,16 +141,6 @@ async function readUnpushedCommitCount( } } -/** An unreachable remote host is an error, never a licence to read this machine's checkout. */ -function requireWorkspaceCleanupGitProvider( - route: Extract -): SshGitProvider { - if (!route.provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return route.provider -} - function uniqueWorkspaceCleanupGitBlockers( blockers: WorkspaceCleanupBlocker[] ): WorkspaceCleanupBlocker[] { diff --git a/src/main/ipc/workspace-cleanup-git-route.ts b/src/main/ipc/workspace-cleanup-git-route.ts index 5dd1344335b..b90bcf24e2d 100644 --- a/src/main/ipc/workspace-cleanup-git-route.ts +++ b/src/main/ipc/workspace-cleanup-git-route.ts @@ -27,13 +27,11 @@ 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 = | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } - /** `provider: null` is "remote and currently unreachable" — never "read it here". */ - | { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string; provider: SshGitProvider | null } + /** This process serves no SSH git, so an `ssh` route is always unreachable — never "read it here". */ + | { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string } /** * The workspace's own host is the authority, and it can disagree with the row that produced the @@ -57,8 +55,7 @@ export function resolveWorkspaceCleanupRepoGitRoute( return { kind: 'ssh', hostId: route.hostId, - connectionId: route.connectionId, - provider: getSshGitProvider(route.connectionId) ?? null + connectionId: route.connectionId } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 2ef49bb7966..6c07d75ba71 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -162,7 +162,7 @@ async function scanRepoWorkspaces( }) } - if (route.kind === 'ssh' && !route.provider) { + if (route.kind === 'ssh') { // Why: a disconnected host still owns real workspaces; the full list shows // them (blocked), while legacy scans keep omitting what they cannot inspect. const candidates = diff --git a/src/main/ipc/workspace-cleanup-worktree-listing.ts b/src/main/ipc/workspace-cleanup-worktree-listing.ts index 27ad173d3b5..0ab98f7d814 100644 --- a/src/main/ipc/workspace-cleanup-worktree-listing.ts +++ b/src/main/ipc/workspace-cleanup-worktree-listing.ts @@ -31,20 +31,8 @@ export async function listCleanupGitWorktrees( return { route, gitWorktrees: [createFolderWorktree(repo)] } } if (route.kind === 'ssh') { - if (!route.provider) { - // Why: cleanup should reflect only workspaces Orca can currently inspect. - return { route, gitWorktrees: [] } - } - const provider = route.provider - return { - route, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => provider.listWorktrees(repo.path, { signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing SSH worktrees.', - signal - ) - } + // Why: cleanup should reflect only workspaces Orca can currently inspect. + return { route, gitWorktrees: [] } } const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) return { diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 92c61e11067..3a35c574653 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -16,7 +16,6 @@ const { getStatusMock, gitExecFileAsyncMock, getLocalProjectWorktreeGitOptionsMock, - getSshGitProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -25,7 +24,6 @@ const { getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshGitProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -54,11 +52,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -163,7 +156,6 @@ describe('workspace cleanup scan', () => { getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) lstatMock.mockResolvedValue({ mtimeMs: 0 }) @@ -185,7 +177,6 @@ describe('workspace cleanup scan', () => { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } } satisfies GitStatusResult) gitExecFileAsyncMock.mockResolvedValue({ stdout: '0\n', stderr: '' }) - getSshGitProviderMock.mockReturnValue(undefined) }) afterEach(() => { @@ -427,67 +418,6 @@ describe('workspace cleanup scan', () => { expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) }) - it('scans connected remote workspaces through the SSH git provider', async () => { - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature', - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn().mockResolvedValue({ - entries: [], - conflictOperation: 'unknown', - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } - } satisfies GitStatusResult) - } - getSshGitProviderMock.mockReturnValue(provider) - - const result = await scanWorkspaceCleanup( - makeStore({ - repos: [{ ...REPO, connectionId: 'ssh-1' }] - }) - ) - - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { - signal: expect.any(AbortSignal) - }) - expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', { - includeLineStats: false, - signal: expect.any(AbortSignal) - }) - expect(result.errors).toEqual([]) - expect(result.candidates[0]).toMatchObject({ - connectionId: 'ssh-1', - tier: 'ready', - selectedByDefault: true, - reasons: ['idle-clean'] - }) - }) - - it('skips connected remote workspaces that fail during broad scans', async () => { - const provider = { - listWorktrees: vi.fn().mockRejectedValue(new Error('ssh timeout')), - getStatus: vi.fn() - } - getSshGitProviderMock.mockReturnValue(provider) - - const result = await scanWorkspaceCleanup( - makeStore({ - repos: [{ ...REPO, connectionId: 'ssh-1' }] - }) - ) - - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { - signal: expect.any(AbortSignal) - }) - expect(result.errors).toEqual([]) - expect(result.candidates).toEqual([]) - }) - it('filters out recent workspaces before running git status', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/ipc/worktree-git-status-ref-watch.ts b/src/main/ipc/worktree-git-status-ref-watch.ts index 84e35748ae7..0c79b4a39a7 100644 --- a/src/main/ipc/worktree-git-status-ref-watch.ts +++ b/src/main/ipc/worktree-git-status-ref-watch.ts @@ -35,7 +35,6 @@ export type GitStatusRefBindingRequest = { worktreePath: string executionHostId: string connectionId?: string - providerGeneration?: number branch?: string upstreamName?: string } @@ -115,7 +114,6 @@ function resolutionKey(args: GitStatusRefBindingRequest): string { return [ args.executionHostId, args.connectionId ?? '', - args.providerGeneration ?? 0, args.worktreeId, args.worktreePath, args.branch, diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index d23efd2fb2b..7a30fa7365b 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -44,19 +44,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index ead826e7d62..f5ff1550238 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-create-event-timing.test.ts b/src/main/ipc/worktrees-create-event-timing.test.ts index 44d345e9724..4fe00b7c062 100644 --- a/src/main/ipc/worktrees-create-event-timing.test.ts +++ b/src/main/ipc/worktrees-create-event-timing.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 0109057430a..b77c0b521ee 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -69,19 +69,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index aee10f0f637..7b9d2c90c51 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -36,19 +36,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index b8dec3859d8..239805ddfd3 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -31,19 +31,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index 2f8bedc0970..4a4683533db 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -26,19 +26,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index cfc9e735b21..af38cfc910c 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index f1cfa13eb80..b2563b433c5 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -45,19 +45,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index efed2838bc3..2668d263ed2 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -7,14 +7,12 @@ import { import { listWorktreesMock, removeWorktreeMock, - getSshGitProviderMock, getSshFilesystemProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, - parseOrcaYamlMock, hasHooksFileMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -44,19 +42,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -126,8 +117,6 @@ describe('registerWorktreeHandlers', () => { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) getLocalPtyProviderMock.mockReturnValue(ptyProvider) - // Why: a removed/disconnected SSH target has no live provider; forgetLocal must not reach for one. - getSshGitProviderMock.mockReturnValue(undefined) const result = await handlers['worktrees:forgetLocal'](null, { worktreeId }) @@ -152,7 +141,6 @@ describe('registerWorktreeHandlers', () => { repoId: 'repo-1' }) // The whole point: local-only cleanup never dispatches to the SSH provider. - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() @@ -210,7 +198,6 @@ describe('registerWorktreeHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-gone' }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) @@ -378,14 +365,8 @@ describe('worktree execution-host disambiguation', () => { connectionId: null, executionHostId: 'ssh:conn-1' as const } - const provider = { - listWorktrees: vi.fn(), - removeWorktree: vi.fn(), - worktreeIsClean: vi.fn() - } store.getRepo.mockReturnValue(brokenRepo) store.getRepos.mockReturnValue([brokenRepo]) - getSshGitProviderMock.mockReturnValue(provider) await expect( handlers['worktrees:remove'](null, { @@ -397,7 +378,6 @@ describe('worktree execution-host disambiguation', () => { ) expect(listWorktreesMock).not.toHaveBeenCalled() - expect(provider.listWorktrees).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) @@ -493,7 +473,6 @@ describe('worktree execution-host disambiguation', () => { ).rejects.toThrow('Repo not found: repo-shared') expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('inspects hooks on the requested host when repo ids collide', async () => { @@ -504,29 +483,25 @@ describe('worktree execution-host disambiguation', () => { badgeColor: '#000', addedAt: 0 } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n archive: remote-cleanup', - isBinary: false - }) + const sshRepo = { + ...localRepo, + path: '/remote/repo', + connectionId: 'conn-1' } store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + // Why: this process serves no SSH filesystem, so the SSH host answers error, never local hooks. await expect( handlers['hooks:check'](null, { repoId: 'repo-shared', hostId: 'ssh:conn-1' }) ).resolves.toEqual({ - status: 'ok', - hasHooks: true, - hooks: { scripts: { archive: 'remote-cleanup' } }, + status: 'error', + hasHooks: false, + hooks: null, mayNeedUpdate: false }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') expect(hasHooksFileMock).not.toHaveBeenCalled() }) @@ -559,32 +534,21 @@ describe('worktree execution-host disambiguation', () => { badgeColor: '#000', addedAt: 0 } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath === '/remote/repo/.superset/config.json') { - return { content: '{"setup":"remote setup"}', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + const sshRepo = { + ...localRepo, + path: '/remote/repo', + connectionId: 'conn-1' } store.getRepos.mockReturnValue([localRepo, sshRepo]) store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) + // Why: this process serves no SSH filesystem, so the SSH host offers no imports. await expect( handlers['hooks:inspectSetupScriptImports'](null, { repoId: 'repo-shared', hostId: 'ssh:conn-1' }) - ).resolves.toContainEqual( - expect.objectContaining({ - provider: 'superset', - setup: 'remote setup' - }) - ) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') + ).resolves.toEqual([]) }) it('does not coalesce forget requests for the same id on different hosts', async () => { diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 36821cf7b6c..26b7271a113 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -6,10 +6,7 @@ import * as projectGitOptions from '../project-runtime-git-options' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, - getSshFilesystemProviderMock, - getSshGitProviderMock, addWorktreeMock, - getActiveMultiplexerMock, listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -38,19 +35,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -115,10 +105,6 @@ function useRepo(repo: ReturnType): void { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) } describe('registerWorktreeHandlers', () => { @@ -209,142 +195,7 @@ describe('registerWorktreeHandlers', () => { }) }) - it('keeps SSH issue-command local overrides usable when shared read fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { content: 'local command\n', isBinary: false } - } - throw new Error('shared read failed') - }) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-ssh' - }) - ).resolves.toMatchObject({ - status: 'ok', - localContent: 'local command', - sharedContent: null, - effectiveContent: 'local command', - source: 'local' - }) - }) - - it('writes SSH issue-command overrides without clobbering .gitignore on read failure', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockRejectedValue(new Error('ssh read failed')), - writeFile: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - ).rejects.toThrow('ssh read failed') - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) - - it('reads an issue-command override from the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { - ...localRepo, - path: '/remote/repo', - displayName: 'ssh', - connectionId: 'conn-1' - } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { content: 'remote command\n', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toMatchObject({ - localContent: 'remote command', - effectiveContent: 'remote command', - source: 'local' - }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command') - }) - - it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }) - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockRejectedValue(enoent), - writeFile: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - - expect(fsProvider.writeFile).toHaveBeenNthCalledWith(1, '/remote/repo/.gitignore', '.orca\n') - expect(fsProvider.writeFile).toHaveBeenNthCalledWith( - 2, - '/remote/repo/.orca/issue-command', - 'orca issue command\n' - ) - }) - - it('rejects SSH issue-command writes when the remote filesystem provider is unavailable', async () => { + it('rejects SSH issue-command writes, which this process does not serve', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -355,7 +206,6 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: null } store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(null) await expect( handlers['hooks:writeIssueCommand'](null, { @@ -364,38 +214,6 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Remote filesystem unavailable') }) - - it('preserves .gitignore when the SSH host already ignores .orca', async () => { - store.getRepo.mockReturnValue({ - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - }) - const checkIgnoredPaths = vi.fn().mockResolvedValue(['.orca/issue-command']) - getSshGitProviderMock.mockReturnValue({ checkIgnoredPaths }) - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn(), - writeFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'local command' - }) - - expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') - expect(checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', ['.orca/issue-command']) - expect(fsProvider.readFile).not.toHaveBeenCalled() - expect(fsProvider.writeFile).toHaveBeenCalledExactlyOnceWith( - '/remote/repo/.orca/issue-command', - 'local command\n' - ) - }) }) describe('worktrees:create execution host routing', () => { diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 08163de9d87..43385ee5b81 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' -import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' +import { listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' @@ -28,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -264,20 +257,12 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/live-child', - head: 'def456', - branch: 'refs/heads/live-child', - isBare: false, - isMainWorktree: false - } - ]) - } store.getRepos.mockReturnValue([repo]) + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/live-child': {} + }) store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) store.getAllWorktreeLineage.mockReturnValue({ 'repo-ssh::/remote/live-child': { parentWorktreeId: 'repo-ssh::/remote/missing-parent', diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index dacec728b53..c2d4488fa55 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -28,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index 099fe5f64d2..c5db99b97e7 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 96e1b804194..51052ed1e5c 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -46,19 +46,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts b/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts index 0b21a42b42f..0b5c8f31629 100644 --- a/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts +++ b/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index ff033d9fe11..40327ab8a78 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index e5cd9c26c45..ddfd5886dbf 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -61,19 +61,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts index 7eb97cd035d..b5e82214f55 100644 --- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts +++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts @@ -43,19 +43,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index ed41b90f3cb..6c420f3beea 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -39,19 +39,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index cbf4c904096..1ce4365f339 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index e988f242c6b..8363e12c4d1 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' -import { - getPullRequestPushTargetMock, - gitExecFileAsyncMock, - getSshGitProviderMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { getPullRequestPushTargetMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers } from './worktrees-test-harness' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -32,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -98,142 +87,6 @@ describe('registerWorktreeHandlers', () => { runtimeStub = setupWorktreeHandlers() }) - it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'def456\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - getSshGitProviderMock.mockReturnValue({ exec, fetchRemoteTrackingRef }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'feature/add-feature', - isCrossRepository: false - }) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/workspace/repo', - 'origin', - 'feature/add-feature', - 'refs/remotes/origin/feature/add-feature' - ) - expect(exec).not.toHaveBeenCalledWith(expect.arrayContaining(['fetch']), expect.anything()) - expect(result).toMatchObject({ - baseBranch: 'def456', - headSha: 'def456', - branchNameOverride: 'feature/add-feature', - pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } - }) - }) - - it('fetches a fork PR head via the SSH pull-head RPC, not git.exec', async () => { - const durableLocalRef = `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => durableLocalRef) - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) { - return { stdout: 'fork-head-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) - getSshGitProviderMock.mockReturnValue({ - exec, - fetchGitHubPullRequestHead, - fetchRemoteTrackingRef: vi.fn() - }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'contributor/fix', - isCrossRepository: true - }) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/workspace/repo', 'origin', 42) - expect(exec).not.toHaveBeenCalledWith(expect.arrayContaining(['fetch']), expect.anything()) - expect(result).toMatchObject({ - baseBranch: 'fork-head-sha', - headSha: 'fork-head-sha', - branchNameOverride: 'contributor/fix' - }) - }) - - it('fetches a fork PR head from origin, not the first remote, over SSH', async () => { - const durableLocalRef = `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => durableLocalRef) - // Why: `fork` is listed first, but fork PR heads live on the hosting remote (origin). - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'get-url') { - return { - stdout: `git@github.com:org/${args[2] === 'origin' ? 'repo' : 'fork'}.git\n`, - stderr: '' - } - } - if (args[0] === 'remote') { - return { stdout: 'fork\norigin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) { - return { stdout: 'fork-head-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) - getSshGitProviderMock.mockReturnValue({ - exec, - fetchGitHubPullRequestHead, - fetchRemoteTrackingRef: vi.fn() - }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'contributor/fix', - isCrossRepository: true - }) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/workspace/repo', 'origin', 42) - expect(result).toMatchObject({ - baseBranch: 'fork-head-sha', - headSha: 'fork-head-sha', - branchNameOverride: 'contributor/fix' - }) - }) - it('resolves a fork PR base even when push-target discovery fails', async () => { getPullRequestPushTargetMock.mockRejectedValueOnce(new Error('lookup failed')) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 952494ffdf4..53deeb0f02e 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -9,7 +9,6 @@ import { import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, - getSshGitProviderMock, pruneCleanupScanSnapshotsMock, pruneSpaceAnalysisSnapshotsMock } from './worktrees-test-module-mocks' @@ -39,19 +38,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -153,7 +145,6 @@ describe('registerWorktreeHandlers', () => { it('rejects malformed and contradictory repo host provenance', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } - getSshGitProviderMock.mockReturnValue(provider) const request = { providerRequestId: 'request-1' as ProviderRequestId, repoId: 'repo-1', @@ -227,7 +218,6 @@ describe('registerWorktreeHandlers', () => { connectionId: 'target-a' } store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(undefined) const expectedAuthority = getSshProviderAuthority('target-a') const result = await handlers['worktrees:listDetected'](ipcEvent, { @@ -304,7 +294,6 @@ describe('registerWorktreeHandlers', () => { ] } }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) @@ -346,7 +335,6 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) ]) - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('stops listing SSH worktrees once an authoritative scan retires their metadata', async () => { @@ -585,7 +573,6 @@ describe('registerWorktreeHandlers', () => { executionHostId: sshHostId }) expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { @@ -621,7 +608,6 @@ describe('registerWorktreeHandlers', () => { source: 'metadata-fallback', worktrees: [] }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) @@ -689,17 +675,6 @@ describe('registerWorktreeHandlers', () => { ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) : undefined ) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: worktreePath, - head: 'head-a', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) const result = await handlers['worktrees:listDetected'](ipcEvent, { providerRequestId: 'request-1' as ProviderRequestId, @@ -765,7 +740,6 @@ describe('registerWorktreeHandlers', () => { expect(missingAuthorityResult).toMatchObject({ status: 'rejected' }) expect(zeroOwnerResult).toMatchObject({ status: 'ambiguous-owner' }) expect(wrongProviderOwnerResult).toMatchObject({ status: 'rejected' }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 52295e9fba3..44b8e9d4308 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -51,9 +51,7 @@ import { computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock, - getSshGitProviderMock, getSshFilesystemProviderMock, - getActiveMultiplexerMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, pruneCleanupScanSnapshotMock, @@ -129,9 +127,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock, - getSshGitProviderMock, getSshFilesystemProviderMock, - getActiveMultiplexerMock, mainWindow.webContents.send, store.getRepos, store.getRepo, diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index cfd8b20e7a2..d75185778c4 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -82,9 +82,7 @@ export const loadHooksMock: ModuleMock = vi.fn() export const computeWorktreePathMock: Mock = vi.fn() export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn() export const gitExecFileAsyncMock: GitArgvMock = vi.fn() -export const getSshGitProviderMock: StringArgMock = vi.fn() export const getSshFilesystemProviderMock: ModuleMock = vi.fn() -export const getActiveMultiplexerMock: ModuleMock = vi.fn() export const deleteWorktreeHistoryDirMock: ModuleMock = vi.fn() export const advertisedUrlWatcherForgetWorktreeMock: ModuleMock = vi.fn() export const pruneCleanupScanSnapshotMock: ModuleMock = vi.fn().mockResolvedValue(undefined) @@ -157,22 +155,6 @@ export const hostedReviewModuleMock = () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock }) -export const sshGitDispatchModuleMock = () => ({ - getSshGitProviderGeneration: () => 0, - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', - requireSshGitProvider: (connectionId: string) => { - const provider = getSshGitProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - } -}) - export const sshFilesystemDispatchModuleMock = () => ({ getSshFilesystemProvider: getSshFilesystemProviderMock }) @@ -189,17 +171,6 @@ export const worktreeSymlinksModuleMock = (): { removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock }) -export const sshModuleMock = () => ({ - getActiveMultiplexer: getActiveMultiplexerMock -}) - -// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the -// runtime could reach it without ipcMain. Production imports it from there now, so a -// vi.mock('./ssh') factory alone is inert. -export const sshTargetRegistryModuleMock = () => ({ - getActiveMultiplexer: getActiveMultiplexerMock -}) - export const hooksModuleMock = () => ({ getEffectiveHooks: getEffectiveHooksMock, loadHooks: loadHooksMock, diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 3877cc12030..915e254e0eb 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -48,19 +48,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts index ab2892e8954..bb2842e5756 100644 --- a/src/main/ipc/worktrees/create/register-review-base-handlers.ts +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -6,7 +6,10 @@ import { getLocalProjectGitExecOptions, getLocalProjectGhExecOptions } from '../../../project-runtime-git-options' -import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../../../providers/ssh-host-not-served-error' import { fetchPrHeadTrackingRef, fetchGitHubPullRequestHeadRef @@ -41,36 +44,21 @@ export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { if (!repo.connectionId) { return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error( - 'SSH Git provider is not available. Reconnect to this target and try again.' - ) - } - return provider.exec(args, repo.path) + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } const localGhOptions = repo.connectionId ? repo.ghAccount ? { ghAccount: repo.ghAccount } : undefined : getLocalProjectGhExecOptions(store, repo) - // Why: SSH review-head fetches require narrow write-capable RPCs. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - branch, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) + fetchPrHeadTrackingRef(repo, remote, branch, { + localGitExecOptions: getLocalProjectGitExecOptions(store, repo) + }) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - prNumber, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) + fetchGitHubPullRequestHeadRef(repo, remote, prNumber, { + localGitExecOptions: getLocalProjectGitExecOptions(store, repo) + }) return resolveGitHubPrStartPoint({ repoPath: repo.path, diff --git a/src/main/issue-command-file.ts b/src/main/issue-command-file.ts index daeb1f5f611..9bb69e82055 100644 --- a/src/main/issue-command-file.ts +++ b/src/main/issue-command-file.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { loadHooks } from './hooks' import type { GitRuntimeOptions } from './git/git-runtime-options' import { checkIgnoredPaths } from './git/check-ignored-paths' -import { requireSshGitProvider } from './providers/ssh-git-dispatch' type IssueCommandGitOptions = GitRuntimeOptions | (() => GitRuntimeOptions) @@ -77,7 +76,7 @@ export async function writeIssueCommand( if (!existsSync(orcaDir)) { mkdirSync(orcaDir, { recursive: true }) } - if (!(await isIssueCommandIgnoredByGit(repoPath, undefined, options))) { + if (!(await isIssueCommandIgnoredByGit(repoPath, options))) { ensureOrcaDirIgnored(repoPath) } writeFileSync(filePath, `${trimmed}\n`, 'utf-8') @@ -88,22 +87,19 @@ export async function writeIssueCommand( } } -/** Consult the execution host before changing shared ignore rules for a private override. */ -export async function isIssueCommandIgnoredByGit( +/** Consult Git before changing shared ignore rules for a private override. */ +async function isIssueCommandIgnoredByGit( repoPath: string, - connectionId?: string, options: IssueCommandGitOptions = {} ): Promise { try { const issueCommandPath = `${ORCA_DIR}/${ISSUE_COMMAND_FILENAME}` - const ignored = connectionId - ? await requireSshGitProvider(connectionId).checkIgnoredPaths(repoPath, [issueCommandPath]) - : await checkIgnoredPaths( - repoPath, - [issueCommandPath], - // Runtime repair must not block saving or clearing the local override. - typeof options === 'function' ? options() : options - ) + const ignored = await checkIgnoredPaths( + repoPath, + [issueCommandPath], + // Runtime repair must not block saving or clearing the local override. + typeof options === 'function' ? options() : options + ) return ignored.includes(issueCommandPath) } catch { // Preserve the existing ignore-file fallback if Git cannot inspect the rules. diff --git a/src/main/local-downloaded-folder-promotion.test.ts b/src/main/local-downloaded-folder-promotion.test.ts deleted file mode 100644 index 57d82edd6e2..00000000000 --- a/src/main/local-downloaded-folder-promotion.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { writeFileSync } from 'node:fs' -import { - lstat, - mkdtemp, - mkdir, - open, - readFile, - readdir, - rm, - symlink, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { - promoteLocalDownloadedFolder, - copyLocalDownloadedFileNoClobber, - publishLocalDownloadedFileNoClobber -} from './local-downloaded-folder-promotion' - -describe('promoteLocalDownloadedFolder', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function createPaths(): Promise<{ - root: string - tempPath: string - destinationPath: string - }> { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(join(tempPath, 'nested'), { recursive: true }) - await writeFile(join(tempPath, 'nested', 'file.txt'), 'remote') - return { root, tempPath, destinationPath } - } - - it('claims the destination and promotes the completed temporary tree', async () => { - const { root, tempPath, destinationPath } = await createPaths() - - await promoteLocalDownloadedFolder(tempPath, destinationPath) - - await expect(readFile(join(destinationPath, 'nested', 'file.txt'), 'utf8')).resolves.toBe( - 'remote' - ) - await expect(readdir(root)).resolves.toEqual(['downloaded']) - }) - - it('does not replace a destination created before publication', async () => { - const { tempPath, destinationPath } = await createPaths() - await mkdir(destinationPath) - await writeFile(join(destinationPath, 'local.txt'), 'local') - - await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toThrow( - 'Destination folder already exists' - ) - - await expect(readFile(join(destinationPath, 'local.txt'), 'utf8')).resolves.toBe('local') - await expect(readFile(join(tempPath, 'nested', 'file.txt'), 'utf8')).resolves.toBe('remote') - }) - - it('does not replace a file added inside a claimed destination', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'remote.txt') - const destinationPath = join(root, 'third-party.txt') - await writeFile(sourcePath, 'remote') - await writeFile(destinationPath, 'third-party') - - await expect( - publishLocalDownloadedFileNoClobber(sourcePath, destinationPath) - ).rejects.toMatchObject({ code: 'EEXIST' }) - - await expect(readFile(sourcePath, 'utf8')).resolves.toBe('remote') - await expect(readFile(destinationPath, 'utf8')).resolves.toBe('third-party') - }) - - it('preserves a mutation made after hard-link state is recorded', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'remote.txt') - const destinationPath = join(root, 'published.txt') - await writeFile(sourcePath, 'remote') - const signal = { - throwIfAborted: () => { - writeFileSync(destinationPath, 'third-party mutation') - throw new Error('window closed') - } - } as unknown as AbortSignal - - await expect( - publishLocalDownloadedFileNoClobber(sourcePath, destinationPath, signal) - ).rejects.toThrow('window closed') - - await expect(readFile(sourcePath, 'utf8')).resolves.toBe('third-party mutation') - await expect(readFile(destinationPath, 'utf8')).resolves.toBe('third-party mutation') - }) - - it('does not claim a destination after cancellation', async () => { - const { tempPath, destinationPath } = await createPaths() - const controller = new AbortController() - controller.abort(new Error('window closed')) - - await expect( - promoteLocalDownloadedFolder(tempPath, destinationPath, controller.signal) - ).rejects.toThrow('window closed') - - await expect(readdir(tempPath)).resolves.toEqual(['nested']) - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('rolls back unchanged entries after a mid-publication failure', async () => { - const { tempPath, destinationPath } = await createPaths() - await writeFile(join(tempPath, 'a-first.txt'), 'remote') - await symlink('a-first.txt', join(tempPath, 'z-unsupported-link')) - - await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toThrow( - "Unexpected local download entry 'z-unsupported-link'" - ) - - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('preserves a third-party file mutation when later publication fails', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(join(tempPath, 'b-work'), { recursive: true }) - await writeFile(join(tempPath, 'a-first.txt'), 'remote') - await Promise.all( - Array.from({ length: 200 }, (_, index) => - writeFile(join(tempPath, 'b-work', `${String(index).padStart(3, '0')}.txt`), 'remote') - ) - ) - await symlink('a-first.txt', join(tempPath, 'z-unsupported-link')) - - const promotion = promoteLocalDownloadedFolder(tempPath, destinationPath) - const failure = expect(promotion).rejects.toThrow( - "Unexpected local download entry 'z-unsupported-link'" - ) - const publishedFile = join(destinationPath, 'a-first.txt') - for (;;) { - try { - await readFile(publishedFile) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - await writeFile(publishedFile, 'third-party mutation') - - await failure - await expect(readFile(publishedFile, 'utf8')).resolves.toBe('third-party mutation') - }) - - it('cancels between entries and rolls back unchanged published files', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(tempPath) - await Promise.all( - Array.from({ length: 300 }, (_, index) => - writeFile(join(tempPath, `${String(index).padStart(3, '0')}.txt`), 'remote') - ) - ) - const controller = new AbortController() - - const promotion = promoteLocalDownloadedFolder(tempPath, destinationPath, controller.signal) - const failure = expect(promotion).rejects.toThrow('window closed') - for (;;) { - try { - await readFile(join(destinationPath, '000.txt')) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - controller.abort(new Error('window closed')) - - await failure - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('cancels a chunked exclusive-copy fallback and removes its partial file', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'large-source.bin') - const destinationPath = join(root, 'large-destination.bin') - const sourceHandle = await open(sourcePath, 'wx') - await sourceHandle.truncate(64 * 1024 * 1024) - await sourceHandle.close() - const controller = new AbortController() - - const copying = copyLocalDownloadedFileNoClobber(sourcePath, destinationPath, controller.signal) - const failure = expect(copying).rejects.toThrow('copy canceled') - for (;;) { - try { - await lstat(destinationPath) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - controller.abort(new Error('copy canceled')) - - await failure - await expect(lstat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(lstat(sourcePath)).resolves.toMatchObject({ size: 64 * 1024 * 1024 }) - }) -}) diff --git a/src/main/local-downloaded-folder-promotion.ts b/src/main/local-downloaded-folder-promotion.ts deleted file mode 100644 index f5ba45f7a5c..00000000000 --- a/src/main/local-downloaded-folder-promotion.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { fstatSync, lstatSync } from 'node:fs' -import { link, lstat, mkdir, open, readdir, rm, rmdir, unlink } from 'node:fs/promises' -import type { BigIntStats } from 'node:fs' -import type { FileHandle } from 'node:fs/promises' -import { join } from 'node:path' - -const LOCAL_COPY_CHUNK_BYTES = 1024 * 1024 - -type PublishedEntry = { - kind: 'directory' | 'file' - path: string - identity: Pick - fileState?: Pick -} - -function isEEXIST(error: unknown): boolean { - return ( - error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST' - ) -} - -function hasSameIdentity(current: BigIntStats, published: PublishedEntry): boolean { - const { identity } = published - const hasStableInode = identity.dev !== 0n || identity.ino !== 0n - if (hasStableInode) { - return current.dev === identity.dev && current.ino === identity.ino - } - return identity.birthtimeNs !== 0n && current.birthtimeNs === identity.birthtimeNs -} - -function hasSamePublishedFileState(current: BigIntStats, published: PublishedEntry): boolean { - const state = published.fileState - return ( - state === undefined || - (current.size === state.size && - current.mtimeNs === state.mtimeNs && - current.mode === state.mode) - ) -} - -function publishedEntryFromStats( - kind: PublishedEntry['kind'], - filePath: string, - stats: BigIntStats -): PublishedEntry { - return { - kind, - path: filePath, - identity: { dev: stats.dev, ino: stats.ino, birthtimeNs: stats.birthtimeNs }, - ...(kind === 'file' - ? { - fileState: { - size: stats.size, - mtimeNs: stats.mtimeNs, - mode: stats.mode - } - } - : {}) - } -} - -function updatePublishedFileState(entry: PublishedEntry, stats: BigIntStats): void { - Object.assign(entry, publishedEntryFromStats('file', entry.path, stats)) -} - -async function closeFileHandle(handle: FileHandle | undefined): Promise { - await handle?.close().catch(() => {}) -} - -async function copyTrackedLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - let sourceHandle: FileHandle | undefined - let destinationHandle: FileHandle | undefined - let record: PublishedEntry | undefined - try { - sourceHandle = await open(sourcePath, 'r') - destinationHandle = await open(destinationPath, 'wx') - record = publishedEntryFromStats( - 'file', - destinationPath, - fstatSync(destinationHandle.fd, { bigint: true }) - ) - publishedEntries.push(record) - const buffer = Buffer.allocUnsafe(LOCAL_COPY_CHUNK_BYTES) - let position = 0 - for (;;) { - signal?.throwIfAborted() - const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position) - if (bytesRead === 0) { - break - } - let written = 0 - while (written < bytesRead) { - signal?.throwIfAborted() - const result = await destinationHandle.write( - buffer, - written, - bytesRead - written, - position + written - ) - written += result.bytesWritten - } - position += bytesRead - } - updatePublishedFileState(record, await destinationHandle.stat({ bigint: true })) - } catch (error) { - if (record && destinationHandle) { - await destinationHandle - .stat({ bigint: true }) - .then((stats) => updatePublishedFileState(record!, stats)) - .catch(() => {}) - } - throw error - } finally { - await Promise.all([closeFileHandle(sourceHandle), closeFileHandle(destinationHandle)]) - } - await unlink(sourcePath) -} - -async function publishTrackedFileNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - const sourceStats = await lstat(sourcePath, { bigint: true }) - const hardLinkRecord = publishedEntryFromStats('file', destinationPath, sourceStats) - try { - await link(sourcePath, destinationPath) - } catch (error) { - if (isEEXIST(error)) { - throw error - } - await copyTrackedLocalDownloadedFileNoClobber( - sourcePath, - destinationPath, - publishedEntries, - signal - ) - return - } - // Why: hard links preserve dev+ino; register that known ownership first, - // then synchronously snapshot state without an async third-party window. - publishedEntries.push(hardLinkRecord) - updatePublishedFileState(hardLinkRecord, lstatSync(destinationPath, { bigint: true })) - signal?.throwIfAborted() - await unlink(sourcePath) -} - -export async function copyLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - const publishedEntries: PublishedEntry[] = [] - try { - await copyTrackedLocalDownloadedFileNoClobber( - sourcePath, - destinationPath, - publishedEntries, - signal - ) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - throw error - } -} - -export async function publishLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - const publishedEntries: PublishedEntry[] = [] - try { - await publishTrackedFileNoClobber(sourcePath, destinationPath, publishedEntries, signal) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - throw error - } -} - -async function rollbackPublishedEntries(entries: PublishedEntry[]): Promise { - for (const entry of entries.toReversed()) { - try { - const current = await lstat(entry.path, { bigint: true }) - if (!hasSameIdentity(current, entry)) { - continue - } - if (entry.kind === 'file') { - if (hasSamePublishedFileState(current, entry)) { - await unlink(entry.path) - } - } else { - // Why: rmdir removes only our still-empty directory; third-party children - // make it fail closed instead of being deleted recursively. - await rmdir(entry.path).catch(() => {}) - } - } catch { - // A missing or unreadable path is no longer safe for rollback to own. - } - } -} - -async function publishDirectoryNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - await mkdir(destinationPath, { recursive: false }) - // Why: capture the directory identity synchronously after the exclusive - // claim so no async gap exists before rollback ownership is registered. - const destinationStats = lstatSync(destinationPath, { bigint: true }) - publishedEntries.push(publishedEntryFromStats('directory', destinationPath, destinationStats)) - const entries = (await readdir(sourcePath, { withFileTypes: true })).toSorted((a, b) => - a.name.localeCompare(b.name) - ) - for (const entry of entries) { - signal?.throwIfAborted() - const sourceEntryPath = join(sourcePath, entry.name) - const destinationEntryPath = join(destinationPath, entry.name) - if (entry.isDirectory()) { - await publishDirectoryNoClobber( - sourceEntryPath, - destinationEntryPath, - publishedEntries, - signal - ) - } else if (entry.isFile()) { - await publishTrackedFileNoClobber( - sourceEntryPath, - destinationEntryPath, - publishedEntries, - signal - ) - } else { - throw new Error(`Unexpected local download entry '${entry.name}'`) - } - signal?.throwIfAborted() - } - signal?.throwIfAborted() -} - -export async function promoteLocalDownloadedFolder( - tempPath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - signal?.throwIfAborted() - const publishedEntries: PublishedEntry[] = [] - try { - // Why: Node has no portable atomic no-replace directory rename. Claiming - // the destination first preserves no-clobber while promotion stays local. - await publishDirectoryNoClobber(tempPath, destinationPath, publishedEntries, signal) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - if (isEEXIST(error)) { - throw new Error('Destination folder already exists') - } - throw error - } - await rm(tempPath, { recursive: true, force: true }).catch(() => {}) -} diff --git a/src/main/media/media-preview-protocol.test.ts b/src/main/media/media-preview-protocol.test.ts index 07683976b03..c005ba75eff 100644 --- a/src/main/media/media-preview-protocol.test.ts +++ b/src/main/media/media-preview-protocol.test.ts @@ -5,13 +5,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -const { authorize, provider } = vi.hoisted(() => ({ - authorize: vi.fn(async (filePath: string) => filePath), - provider: vi.fn() +const { authorize } = vi.hoisted(() => ({ + authorize: vi.fn(async (filePath: string) => filePath) +})) +vi.mock('electron', () => ({ + protocol: { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() } +})) +vi.mock('../ipc/local-file-access-resolution', () => ({ + resolveLocalFileRequestPath: authorize })) -vi.mock('electron', () => ({ protocol: { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() } })) -vi.mock('../ipc/local-file-access-resolution', () => ({ resolveLocalFileRequestPath: authorize })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: provider })) import { handleMediaPreviewRequest, readMediaPreview } from './media-preview-protocol' @@ -33,7 +35,6 @@ describe('media preview protocol', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-video-protocol-')) authorize.mockReset().mockImplementation(async (path) => path) - provider.mockReset() }) afterEach(async () => { emitter.emit('destroyed') @@ -88,30 +89,7 @@ describe('media preview protocol', () => { expect(readMediaPreview(event, { filePath: join(directory, 'secret.txt') }, store)).toBeNull() }) - it('serves SSH ranges on the execution host without reading a local path', async () => { - const readFileRange = vi.fn(async () => ({ bytes: Buffer.from([7, 8, 9]), bytesRead: 3 })) - provider.mockReturnValue({ stat: async () => ({ type: 'file', size: 10 }), readFileRange }) - const result = readMediaPreview( - event, - { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, - store - ) - if (!result) { - throw new Error('Missing preview') - } - const response = await handleMediaPreviewRequest( - new Request(result.mediaUrl, { headers: { range: 'bytes=7-' } }) - ) - expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([7, 8, 9]) - expect(readFileRange).toHaveBeenCalledWith('/remote/clip.mp4', 7, 3) - expect(authorize).not.toHaveBeenCalled() - expect(provider).toHaveBeenCalledWith('ssh-owner') - }) - - it('does not try a local read when an SSH host is disconnected', async () => { - provider.mockImplementation(() => { - throw new Error('SSH disconnected') - }) + it('does not try a local read for an SSH host', async () => { const result = readMediaPreview( event, { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, diff --git a/src/main/media/media-preview-protocol.ts b/src/main/media/media-preview-protocol.ts index f37f1a91eee..f441644a991 100644 --- a/src/main/media/media-preview-protocol.ts +++ b/src/main/media/media-preview-protocol.ts @@ -6,8 +6,6 @@ import type { LocalFileAccess } from '../../shared/local-file-access' import { MEDIA_FILE_MIME_TYPES, MEDIA_PREVIEW_SCHEME } from '../../shared/media-file-extensions' import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { readSshFileExplorerChunk } from '../runtime/ssh-file-explorer-chunk-read' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' import { createMediaRangeResponse } from './media-range-response' @@ -74,26 +72,9 @@ export async function handleMediaPreviewRequest(request: Request): Promise { - const chunk = await readSshFileExplorerChunk( - provider, - target.filePath, - stats.size, - offset, - length - ) - return Buffer.from(chunk.contentBase64, 'base64') - }, - close: async () => {} - }) + return new Response(null, { status: 404 }) } const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store) const { handle, stats } = await openLocalRegularFile(filePath) diff --git a/src/main/native-chat/ssh-transcript-fs-access.ts b/src/main/native-chat/ssh-transcript-fs-access.ts index 2b0c4424ae5..cfb786c3d44 100644 --- a/src/main/native-chat/ssh-transcript-fs-access.ts +++ b/src/main/native-chat/ssh-transcript-fs-access.ts @@ -1,31 +1,22 @@ -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { readRemoteTranscriptRange } from '../runtime/orchestration/worker-transcript-remote-range-read' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { SshTranscriptLocation } from './ssh-transcript-path' export type SshTranscriptHandle = { sshTranscript: SshTranscriptLocation } -// Why: the provider is looked up per call, so a reconnect is picked up and a disconnect throws -// instead of reading anything on this machine. -export async function statSshTranscript(location: SshTranscriptLocation) { - const stat = await requireSshFilesystemProvider(location.connectionId).stat(location.remotePath) - const mtimeMs = stat.mtimeMs ?? stat.mtime - return { size: stat.size, mtimeMs, ctimeMs: mtimeMs, dev: stat.dev ?? 0, ino: stat.ino ?? 0 } +// Why: this process serves no SSH filesystem, so an SSH transcript throws instead of reading +// anything on this machine. +export async function statSshTranscript(location: SshTranscriptLocation): Promise<{ + size: number + mtimeMs: number + ctimeMs: number + dev: number + ino: number +}> { + throw new SshHostNotServedError(location.connectionId) } export async function readSshTranscript( - handle: SshTranscriptHandle, - buffer: Buffer, - offset: number, - length: number, - position: number + handle: SshTranscriptHandle ): Promise<{ bytesRead: number; buffer: Buffer }> { - const { connectionId, remotePath } = handle.sshTranscript - const bytes = await readRemoteTranscriptRange( - requireSshFilesystemProvider(connectionId), - remotePath, - position, - length - ) - buffer.set(bytes, offset) - return { bytesRead: bytes.length, buffer } + throw new SshHostNotServedError(handle.sshTranscript.connectionId) } diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index 7fcac1d8950..e0dd53663a1 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -158,7 +158,7 @@ export function readTranscriptFile( signal?: AbortSignal ): Promise<{ bytesRead: number; buffer: Buffer }> { if (isSshTranscriptHandle(handle)) { - return readSshTranscript(handle, buffer, offset, length, position) + return readSshTranscript(handle) } // Handle kind decides before path spelling: a process-owned handle must never // hit the FileHandle branch even if a caller re-derives the path off-UNC. diff --git a/src/main/project-groups/folder-workspace-path-status.test.ts b/src/main/project-groups/folder-workspace-path-status.test.ts index 25314576bc3..dc42919ec88 100644 --- a/src/main/project-groups/folder-workspace-path-status.test.ts +++ b/src/main/project-groups/folder-workspace-path-status.test.ts @@ -2,13 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath, inferFolderWorkspacePathConnection } from './folder-workspace-path-status' -import type { IFilesystemProvider } from '../providers/types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -48,28 +47,26 @@ describe('folder workspace path status', () => { await writeFile(filePath, 'hello') await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: root, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: root })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) + getFolderWorkspacePathStatusForPath({ + folderPath: root, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: root })], + repos: [] + }) ).resolves.toEqual({ path: root, exists: true }) await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: filePath, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: filePath })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) - ).resolves.toEqual({ path: filePath, exists: false, reason: 'not-directory' }) + getFolderWorkspacePathStatusForPath({ + folderPath: filePath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: filePath })], + repos: [] + }) + ).resolves.toEqual({ + path: filePath, + exists: false, + reason: 'not-directory' + }) } finally { await rm(root, { recursive: true, force: true }) } @@ -79,69 +76,28 @@ describe('folder workspace path status', () => { const missingPath = join(tmpdir(), `orca-folder-status-missing-${randomUUID()}`) await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: missingPath, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: missingPath })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) + getFolderWorkspacePathStatusForPath({ + folderPath: missingPath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: missingPath })], + repos: [] + }) ).resolves.toEqual({ path: missingPath, exists: false, reason: 'missing' }) }) - it('routes inferred SSH folder scopes through the SSH filesystem provider', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - + it('reports unavailable for an inferred SSH scope, which this process does not serve', async () => { await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - projectGroups: [makeGroup()], - repos: [makeRepo({ connectionId: 'ssh-1' })] - }, - { getSshFilesystemProvider: () => provider } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') - }) - - it('routes explicit SSH folder scopes through SSH without child repos', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - connectionId: 'ssh-1', - projectGroups: [makeGroup({ connectionId: 'ssh-1' })], - repos: [] - }, - { getSshFilesystemProvider: () => provider } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') - }) - - it('reports unavailable when an inferred SSH provider is missing', async () => { - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - projectGroups: [makeGroup()], - repos: [makeRepo({ connectionId: 'ssh-1' })] - }, - { getSshFilesystemProvider: () => undefined } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'unavailable' }) + getFolderWorkspacePathStatusForPath({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [makeRepo({ connectionId: 'ssh-1' })] + }) + ).resolves.toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) }) it('reports ambiguous connection for mixed SSH scopes', () => { @@ -187,11 +143,7 @@ describe('folder workspace path status', () => { ).toEqual({ kind: 'ambiguous' }) }) - it('supports direct path scope without a persisted project group', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - + it('reports a direct SSH path scope unavailable without a persisted project group', async () => { await expect( getFolderWorkspacePathStatus( { @@ -199,44 +151,39 @@ describe('folder workspace path status', () => { getProjectGroups: () => [], getFolderWorkspaces: () => [] }, - { scope: 'path', path: '/workspace/platform', connectionId: 'ssh-1' }, - { getSshFilesystemProvider: () => provider } + { scope: 'path', path: '/workspace/platform', connectionId: 'ssh-1' } ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + ).resolves.toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) }) - it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - connectionId: 'ssh-1', - projectGroups: [ - makeGroup({ id: 'group-1', connectionId: 'ssh-1' }), - makeGroup({ id: 'group-2', connectionId: 'ssh-2' }) - ], - repos: [ - makeRepo({ id: 'repo-1', path: '/workspace/platform/api', connectionId: 'ssh-1' }), - makeRepo({ - id: 'repo-2', - path: '/workspace/platform/api', - projectGroupId: 'group-2', - connectionId: 'ssh-2' - }) - ] - }, - { - getSshFilesystemProvider: (connectionId) => - connectionId === 'ssh-1' ? provider : undefined - } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', () => { + expect( + inferFolderWorkspacePathConnection({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + connectionId: 'ssh-1', + projectGroups: [ + makeGroup({ id: 'group-1', connectionId: 'ssh-1' }), + makeGroup({ id: 'group-2', connectionId: 'ssh-2' }) + ], + repos: [ + makeRepo({ + id: 'repo-1', + path: '/workspace/platform/api', + connectionId: 'ssh-1' + }), + makeRepo({ + id: 'repo-2', + path: '/workspace/platform/api', + projectGroupId: 'group-2', + connectionId: 'ssh-2' + }) + ] + }) + ).toEqual({ kind: 'ssh', connectionId: 'ssh-1' }) }) }) diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index ef146482f32..2e508205bba 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -8,7 +8,6 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' -import type { IFilesystemProvider } from '../providers/types' type FolderWorkspacePathStatusStore = { getRepos: () => Repo[] @@ -21,10 +20,6 @@ export type FolderWorkspacePathConnectionResolution = | { kind: 'ssh'; connectionId: string } | { kind: 'ambiguous' } -type FolderWorkspacePathStatusDeps = { - getSshFilesystemProvider: (connectionId: string) => IFilesystemProvider | undefined -} - function getFolderScopeCandidateRepos(args: { folderPath: string projectGroupId?: string | null @@ -106,25 +101,14 @@ function pathStatErrorReason(error: unknown): 'missing' | 'unavailable' { async function statFolderPath( path: string, - connection: FolderWorkspacePathConnectionResolution, - deps: FolderWorkspacePathStatusDeps + connection: FolderWorkspacePathConnectionResolution ): Promise { if (connection.kind === 'ambiguous') { return { path, exists: false, reason: 'ambiguous-connection' } } if (connection.kind === 'ssh') { - const provider = deps.getSshFilesystemProvider(connection.connectionId) - if (!provider) { - return { path, exists: false, reason: 'unavailable' } - } - try { - const stats = await provider.stat(path) - return stats.type === 'directory' - ? { path, exists: true } - : { path, exists: false, reason: 'not-directory' } - } catch (error) { - return { path, exists: false, reason: pathStatErrorReason(error) } - } + // Why: this process serves no SSH filesystem. + return { path, exists: false, reason: 'unavailable' } } try { @@ -137,18 +121,15 @@ async function statFolderPath( } } -export async function getFolderWorkspacePathStatusForPath( - args: { - folderPath: string - projectGroupId?: string | null - connectionId?: string | null - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] - }, - deps: FolderWorkspacePathStatusDeps -): Promise { +export async function getFolderWorkspacePathStatusForPath(args: { + folderPath: string + projectGroupId?: string | null + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Promise { const connection = inferFolderWorkspacePathConnection(args) - return statFolderPath(args.folderPath, connection, deps) + return statFolderPath(args.folderPath, connection) } export function resolveFolderWorkspaceStatusPath(args: { @@ -196,20 +177,16 @@ export function resolveFolderWorkspaceStatusPath(args: { export async function getFolderWorkspacePathStatus( store: FolderWorkspacePathStatusStore, - request: FolderWorkspacePathStatusRequest, - deps: FolderWorkspacePathStatusDeps + request: FolderWorkspacePathStatusRequest ): Promise { const scope = resolveFolderWorkspaceStatusPath({ store, request }) - return getFolderWorkspacePathStatusForPath( - { - folderPath: scope.folderPath, - projectGroupId: scope.projectGroupId, - connectionId: scope.connectionId, - projectGroups: store.getProjectGroups?.() ?? [], - repos: store.getRepos() - }, - deps - ) + return getFolderWorkspacePathStatusForPath({ + folderPath: scope.folderPath, + projectGroupId: scope.projectGroupId, + connectionId: scope.connectionId, + projectGroups: store.getProjectGroups?.() ?? [], + repos: store.getRepos() + }) } /** The code a folder workspace refuses this path with, or null when the path is usable. */ diff --git a/src/main/providers/execution-host-provider-dispatch.test.ts b/src/main/providers/execution-host-provider-dispatch.test.ts index 1197e8cc97d..69b2b2ac235 100644 --- a/src/main/providers/execution-host-provider-dispatch.test.ts +++ b/src/main/providers/execution-host-provider-dispatch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { ExecutionHostNotDispatchableError, requireReachableFilesystemRoute, @@ -9,23 +9,11 @@ import { } from './execution-host-provider-dispatch' import { createLocalFilesystemProvider } from './local-filesystem-provider' import { createLocalGitProvider } from './local-git-provider' -import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './ssh-filesystem-dispatch' import { SSH_HOST_NOT_SERVED_MESSAGE, SshHostNotServedError } from './ssh-host-not-served-error' const connectionId = 'host-dispatch-target' -const gitProvider = { listWorktrees: async () => [] } as never -const filesystemProvider = { readDir: async () => [] } as never describe('execution host provider dispatch', () => { - afterEach(() => { - unregisterSshGitProvider(connectionId) - unregisterSshFilesystemProvider(connectionId) - }) - it('routes `local` to a per-call local provider factory, never a cached provider', () => { expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', @@ -39,11 +27,12 @@ describe('execution host provider dispatch', () => { }) }) - it('routes an ssh host to a provider-less entry, even with a registered provider', () => { - registerSshGitProvider(connectionId, gitProvider) - registerSshFilesystemProvider(connectionId, filesystemProvider) - - const expected = { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId } + it('routes an ssh host to a provider-less entry', () => { + const expected = { + kind: 'ssh', + hostId: `ssh:${connectionId}`, + connectionId + } expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) }) @@ -76,24 +65,17 @@ describe('execution host provider dispatch', () => { }) // 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( + it('throws the typed not-served error for an ssh host', () => { + 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.' + ) + }) it('never hands an ssh host a local provider factory', () => { expect(resolveGitRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty('createProvider') diff --git a/src/main/providers/ssh-filesystem-dispatch.test.ts b/src/main/providers/ssh-filesystem-dispatch.test.ts deleted file mode 100644 index 9b932be8c38..00000000000 --- a/src/main/providers/ssh-filesystem-dispatch.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './types' - -const provider = {} as IFilesystemProvider - -describe('onSshFilesystemProviderRegistered', () => { - it('notifies subscribers on every registration, including a reconnect replacing the provider', () => { - const listener = vi.fn() - const unsubscribe = onSshFilesystemProviderRegistered(listener) - - registerSshFilesystemProvider('conn-1', provider) - registerSshFilesystemProvider('conn-1', {} as IFilesystemProvider) - - expect(listener).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenNthCalledWith(1, 'conn-1') - expect(listener).toHaveBeenNthCalledWith(2, 'conn-1') - - unsubscribe() - unregisterSshFilesystemProvider('conn-1') - }) - - it('exposes the new provider to subscribers while they are being notified', () => { - let seen: IFilesystemProvider | undefined - const unsubscribe = onSshFilesystemProviderRegistered((connectionId) => { - seen = getSshFilesystemProvider(connectionId) - }) - - registerSshFilesystemProvider('conn-2', provider) - - expect(seen).toBe(provider) - unsubscribe() - unregisterSshFilesystemProvider('conn-2') - }) - - it('stops notifying after unsubscribe', () => { - const listener = vi.fn() - onSshFilesystemProviderRegistered(listener)() - - registerSshFilesystemProvider('conn-3', provider) - - expect(listener).not.toHaveBeenCalled() - unregisterSshFilesystemProvider('conn-3') - }) - - it('keeps registration working when a subscriber throws', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const healthy = vi.fn() - const unsubscribeThrower = onSshFilesystemProviderRegistered(() => { - throw new Error('subscriber blew up') - }) - const unsubscribeHealthy = onSshFilesystemProviderRegistered(healthy) - - expect(() => registerSshFilesystemProvider('conn-4', provider)).not.toThrow() - expect(getSshFilesystemProvider('conn-4')).toBe(provider) - expect(healthy).toHaveBeenCalledWith('conn-4') - - unsubscribeThrower() - unsubscribeHealthy() - unregisterSshFilesystemProvider('conn-4') - warnSpy.mockRestore() - }) -}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index 04ef209bef1..8df49d0ceb5 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -1,9 +1,9 @@ import type { IFilesystemProvider } from './types' -const sshProviders = new Map() +// Why still here: only the remote file-watcher subsystem reads it, and nothing in production +// registers a provider; that subsystem and this table go together in their own change. -export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' +const sshProviders = new Map() // Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old // transport (file watches) needs a signal to rebuild it — nothing else marks that boundary. @@ -40,11 +40,3 @@ export function unregisterSshFilesystemProvider(connectionId: string): void { export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } - -export function requireSshFilesystemProvider(connectionId: string): IFilesystemProvider { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider -} diff --git a/src/main/providers/ssh-git-dispatch.test.ts b/src/main/providers/ssh-git-dispatch.test.ts deleted file mode 100644 index e20ffe8d19b..00000000000 --- a/src/main/providers/ssh-git-dispatch.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - _getSshGitProviderGenerationCacheSize, - getSshGitProvider, - getSshGitProviderGeneration, - registerSshGitProvider, - requireSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, - unregisterSshGitProvider -} from './ssh-git-dispatch' -import { clearSshPlainSshMode, setSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' - -describe('SSH Git provider registry', () => { - const connectionId = 'ssh-generation-test' - - afterEach(() => { - unregisterSshGitProvider(connectionId) - }) - - it('names the plain SSH reason instead of a dropped connection when git is relay-only', () => { - expect(() => requireSshGitProvider(connectionId)).toThrow(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - setSshPlainSshMode(connectionId, { reason: 'no_runtime', message: 'm' }) - try { - expect(() => requireSshGitProvider(connectionId)).toThrow( - 'Git needs the Orca remote server, which is not running on this host (no_runtime).' - ) - } finally { - clearSshPlainSshMode(connectionId) - } - }) - - it('keeps provider generations monotonic across unregister and re-register', () => { - const before = getSshGitProviderGeneration(connectionId) - registerSshGitProvider(connectionId, {} as never) - const registered = getSshGitProviderGeneration(connectionId) - unregisterSshGitProvider(connectionId) - const unregistered = getSshGitProviderGeneration(connectionId) - registerSshGitProvider(connectionId, {} as never) - const reRegistered = getSshGitProviderGeneration(connectionId) - - expect(registered).toBe(before + 1) - expect(unregistered).toBe(registered + 1) - expect(reRegistered).toBe(unregistered + 1) - }) - - it('bounds retired connection generations', () => { - registerSshGitProvider(connectionId, {} as never) - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw new Error('test provider was not registered') - } - for (let index = 0; index < 600; index += 1) { - const id = `retired-${index}` - registerSshGitProvider(id, provider) - unregisterSshGitProvider(id) - } - - expect(_getSshGitProviderGenerationCacheSize()).toBeLessThanOrEqual(512) - }) - - it('does not reuse a generation after the target leaves both bounded maps', () => { - registerSshGitProvider(connectionId, {} as never) - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw new Error('test provider was not registered') - } - const beforeChurn = getSshGitProviderGeneration(connectionId) - for (let index = 0; index < 1_100; index += 1) { - const id = `churn-${index}` - registerSshGitProvider(id, provider) - unregisterSshGitProvider(id) - } - unregisterSshGitProvider(connectionId) - registerSshGitProvider(connectionId, {} as never) - - expect(getSshGitProviderGeneration(connectionId)).toBeGreaterThan(beforeChurn) - }) -}) diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts deleted file mode 100644 index 7506ba51658..00000000000 --- a/src/main/providers/ssh-git-dispatch.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SshGitProvider } from './ssh-git-provider' -import { getSshPlainSshMode, PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' - -const sshProviders = new Map() -const sshProviderGenerations = new Map() -const SSH_PROVIDER_GENERATION_MAX_ENTRIES = 512 -let providerGenerationSequence = 0 -let evictedProviderGeneration = 0 - -export const SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - -export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void { - sshProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, ++providerGenerationSequence) - pruneProviderGenerations() -} - -export function unregisterSshGitProvider(connectionId: string): void { - if (sshProviders.delete(connectionId)) { - sshProviderGenerations.set(connectionId, ++providerGenerationSequence) - pruneProviderGenerations() - } -} - -// Connection ids are externally supplied and can churn across repeated SSH -// sessions. Keep recent generations for cache invalidation without retaining -// every retired target for the lifetime of the main process. -function pruneProviderGenerations(): void { - while (sshProviderGenerations.size > SSH_PROVIDER_GENERATION_MAX_ENTRIES) { - const oldest = sshProviderGenerations.keys().next() - if (oldest.done) { - return - } - evictedProviderGeneration = Math.max( - evictedProviderGeneration, - sshProviderGenerations.get(oldest.value) ?? 0 - ) - sshProviderGenerations.delete(oldest.value) - } -} - -export function getSshGitProviderGeneration(connectionId: string): number { - return sshProviderGenerations.get(connectionId) ?? evictedProviderGeneration -} - -/** @internal - exposed for cache-bound regression tests. */ -export function _getSshGitProviderGenerationCacheSize(): number { - return sshProviderGenerations.size -} - -export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { - return sshProviders.get(connectionId) -} - -/** A plain-SSH host has no git by design, which is "unsupported", not "connection dropped". */ -export function sshGitProviderMissingError(connectionId: string): Error { - const plainMode = getSshPlainSshMode(connectionId) - return plainMode - ? new PlainSshUnsupportedError('Git', plainMode) - : new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) -} - -export function requireSshGitProvider(connectionId: string): SshGitProvider { - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw sshGitProviderMissingError(connectionId) - } - return provider -} diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts deleted file mode 100644 index c0d8df0573f..00000000000 --- a/src/main/providers/ssh-git-noninteractive-provider.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import { SshGitReadProvider } from './ssh-git-read-provider' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 - -export class SshGitNoninteractiveProvider extends SshGitReadProvider { - private readonly nonInteractiveExecQueues = new Map() - - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - ...(plan.env ? { env: plan.env } : {}), - timeoutMs, - operation, - shell: true - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - shell?: boolean - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } -} diff --git a/src/main/providers/ssh-git-provider-commit-message.test.ts b/src/main/providers/ssh-git-provider-commit-message.test.ts deleted file mode 100644 index 70d31d6a4f2..00000000000 --- a/src/main/providers/ssh-git-provider-commit-message.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => { - mux.request.mockImplementation(async (method, payload) => { - expect(method).toBe('git.exec') - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'M\tREADME.md\n' } - } - if (payload.args[2] === '--patch') { - return { stdout: 'diff --git a/README.md b/README.md\n+hello' } - } - throw new Error(`unexpected args: ${payload.args.join(' ')}`) - }) - - const result = await provider.getStagedCommitContext('/home/user/repo') - - expect(result).toEqual({ - branch: 'feature/ai-commit', - stagedSummary: 'M\tREADME.md', - stagedPatch: 'diff --git a/README.md b/README.md\n+hello' - }) - expect(mux.request).toHaveBeenCalledWith('git.exec', { - args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - cwd: '/home/user/repo', - __streamResponse: true - }) - }) - - it('getStagedCommitContext returns null when nothing is staged', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'main\n' } - } - return { stdout: '' } - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toBeNull() - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('getStagedCommitContext falls back when the remote staged patch overflows', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'A\thuge.jsonl\n' } - } - throw Object.assign(new Error('git stdout exceeded maxBuffer.'), { code: 'ENOBUFS' }) - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toEqual({ - branch: 'feature/ai-commit', - stagedSummary: 'A\thuge.jsonl', - stagedPatch: '' - }) - }) - - it('getStagedCommitContext rethrows remote patch failures that are not buffer overflows', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'M\tREADME.md\n' } - } - throw new Error('fatal: bad revision') - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).rejects.toThrow( - 'fatal: bad revision' - ) - }) - - it('keeps the transport alive for an agent response beyond the default request timeout', async () => { - vi.useFakeTimers() - try { - const execResult = { - stdout: 'Update docs', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockImplementation((_method, _payload, options) => { - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('transport request timed out')), - options?.timeoutMs ?? 30_000 - ) - setTimeout(() => { - clearTimeout(timeout) - resolve(execResult) - }, 45_000) - }) - }) - - let state: 'pending' | 'resolved' | 'rejected' = 'pending' - const pending = provider - .executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) - .then( - (result) => { - state = 'resolved' - return result - }, - (error) => { - state = 'rejected' - throw error - } - ) - void pending.catch(() => {}) - - await vi.advanceTimersByTimeAsync(30_000) - expect(state).toBe('pending') - await vi.advanceTimersByTimeAsync(15_000) - - await expect(pending).resolves.toEqual(execResult) - expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', expect.any(Object), { - timeoutMs: 65_000 - }) - } finally { - vi.useRealTimers() - } - }) - - it('executeCommitMessagePlan delegates the prepared plan to the relay', async () => { - const execResult = { - stdout: 'Update docs', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - const result = await provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex', - env: { FLAG: 'literal $HOME' } - }, - '/home/user/repo', - 60_000 - ) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - env: { FLAG: 'literal $HOME' }, - timeoutMs: 60_000, - operation: 'commit-message', - shell: true - }, - { timeoutMs: 65_000 } - ) - expect(result).toEqual(execResult) - }) - - it('keeps SSH commit-message and pull-request execution lanes separate', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation((method) => { - if (method === 'agent.cancelExec') { - return Promise.resolve({ canceled: true }) - } - return new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - }) - const plan = { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - } - - const commit = provider.executeCommitMessagePlan(plan, '/home/user/repo', 60_000) - const pullRequest = provider.executeCommitMessagePlan( - plan, - '/home/user/repo', - 60_000, - 'pull-request-fields' - ) - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'commit-message', - shell: true - }, - { timeoutMs: 65_000 } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'pull-request-fields', - shell: true - }, - { timeoutMs: 65_000 } - ) - - await provider.cancelGenerateCommitMessage('/home/user/repo') - await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields') - - expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'commit-message' - }) - expect(mux.request).toHaveBeenNthCalledWith(4, 'agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'pull-request-fields' - }) - - completeRequests.shift()?.() - completeRequests.shift()?.() - await Promise.all([commit, pullRequest]) - }) - - it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => { - await provider.cancelGenerateCommitMessage('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'commit-message' - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-diff.test.ts b/src/main/providers/ssh-git-provider-diff.test.ts deleted file mode 100644 index 0fe5f195e9f..00000000000 --- a/src/main/providers/ssh-git-provider-diff.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredValue(value: T): { promise: Promise; resolve: () => void } { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve: () => resolve(value) } -} - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getDiff sends git.diff request', async () => { - const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } - mux.request.mockResolvedValue(diffResult) - - const result = await provider.getDiff('/home/user/repo', 'src/index.ts', true) - expect(mux.request).toHaveBeenCalledWith('git.diff', { - worktreePath: '/home/user/repo', - filePath: 'src/index.ts', - staged: true, - // Why: opts into response streaming; a small result still comes back as a - // single frame (relay decides), and old relays ignore the flag. - __streamResponse: true - }) - expect(result).toEqual(diffResult) - }) - - it('getBranchDiff sends git.branchDiff request', async () => { - const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }] - mux.request.mockResolvedValue(diffs) - - const result = await provider.getBranchDiff('/home/user/repo', 'main') - expect(mux.request).toHaveBeenCalledWith('git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - __streamResponse: true - }) - expect(result).toEqual(diffs) - }) - - it('getBranchDiff forwards a pinned head OID without adding it when omitted', async () => { - const headOid = 'a'.repeat(40) - mux.request.mockResolvedValue([]) - - await provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid - }) - await provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/other-file.ts', - headOid: undefined - }) - - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - headOid, - __streamResponse: true - }) - expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/other-file.ts', - __streamResponse: true - }) - }) - - // Why: an unpinned compare snapshot reaches getBranchDiff as null despite the type. - it('omits a null head OID and shares the absent-head dedupe key', async () => { - const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] - const pendingDiff = deferredValue(diffs) - const headOid = 'a'.repeat(40) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = [ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: null as unknown as undefined - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid - }) - ] - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenCalledTimes(2) - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - __streamResponse: true - }) - expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - headOid, - __streamResponse: true - }) - - pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 3 }, () => diffs)) - }) - - it('coalesces matching branch diff heads while keeping distinct and absent heads separate', async () => { - const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] - const pendingDiff = deferredValue(diffs) - const firstHeadOid = 'a'.repeat(40) - const secondHeadOid = 'b'.repeat(40) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = [ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: firstHeadOid - }), - provider.getBranchDiff('/home/user/repo', 'main', { - filePath: 'src/file.ts', - headOid: firstHeadOid, - includePatch: true - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: secondHeadOid - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: undefined - }) - ] - - await waitForRequestCount(mux.request, 3) - expect(mux.request).toHaveBeenCalledTimes(3) - - pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 5 }, () => diffs)) - }) - - it('coalesces concurrent identical diff RPCs while in flight', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = Array.from({ length: 8 }, () => - provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingDiff.resolve() - - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) - - mux.request.mockReset() - const branchDiffs = [diff] - const pendingBranchDiff = deferredValue(branchDiffs) - mux.request.mockReturnValue(pendingBranchDiff.promise) - - const branchReads = Array.from({ length: 8 }, () => - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingBranchDiff.resolve() - await expect(Promise.all(branchReads)).resolves.toEqual( - Array.from({ length: 8 }, () => branchDiffs) - ) - - mux.request.mockReset() - const pendingCommitDiff = deferredValue(diff) - mux.request.mockReturnValue(pendingCommitDiff.promise) - - const commitReads = Array.from({ length: 8 }, () => - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingCommitDiff.resolve() - await expect(Promise.all(commitReads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) - }) - - it('retries diff RPCs after an in-flight rejection settles', async () => { - const failure = new Error('transient relay failure') - mux.request.mockRejectedValueOnce(failure) - - const firstBurst = Array.from({ length: 8 }, () => - provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - ) - - await expect(Promise.all(firstBurst)).rejects.toThrow('transient relay failure') - expect(mux.request).toHaveBeenCalledTimes(1) - - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValueOnce(diff) - - await expect(provider.getDiff('/home/user/repo', 'src/file.ts', false, true)).resolves.toBe( - diff - ) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('clears pending diff RPCs when status runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' }) - await provider.getStatus('/home/user/repo') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending diff RPCs when submodule status runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' }) - await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending diff RPCs when a mutation runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce(undefined) - await provider.stageFile('/home/user/repo', 'src/file.ts') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it.each([ - [ - 'clone', - (provider: SshGitProvider) => - provider.clone(['clone', '--', 'https://example.com/repo.git', 'repo'], '/projects') - ], - [ - 'mutating git.exec', - (provider: SshGitProvider) => - provider.exec(['commit', '--allow-empty', '-m', 'initialize'], '/home/user/repo') - ] - ])('clears pending diff RPCs when %s runs', async (_name, mutate) => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await mutate(provider) - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending branch diff RPCs when a ref-moving provider operation runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue([diff]) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getBranchDiff('/home/user/repo', 'origin/main', { - includePatch: true, - filePath: 'src/file.ts' - }) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce(undefined) - await provider.fetchRemoteTrackingRef( - '/home/user/repo', - 'origin', - 'main', - 'refs/remotes/origin/main' - ) - - mux.request.mockResolvedValueOnce([diff]) - const second = provider.getBranchDiff('/home/user/repo', 'origin/main', { - includePatch: true, - filePath: 'src/file.ts' - }) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([[diff], [diff]]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('coalesces logically identical branch and commit diff RPC args regardless of property order', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValue([diff]) - - await Promise.all([ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - includePatch: true - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(1) - - mux.request.mockReset() - mux.request.mockResolvedValue(diff) - - await Promise.all([ - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - parentOid: 'b'.repeat(40), - commitOid: 'c'.repeat(40) - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('keeps distinct diff RPC keys independent', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValue(diff) - - await Promise.all([ - provider.getDiff('/home/user/repo', 'src/file.ts', false, false), - provider.getDiff('/home/user/repo', 'src/file.ts', true, false), - provider.getDiff('/home/user/repo', 'src/file.ts', false, true), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: false, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'develop', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'a'.repeat(40), - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(10) - }) -}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts deleted file mode 100644 index ef0c3198a8d..00000000000 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('clone sends git.clone request and forwards matching progress notifications', async () => { - const unsubscribe = vi.fn() - const onProgress = vi.fn() - mux.onNotificationByMethod.mockReturnValue(unsubscribe) - mux.request.mockImplementationOnce(async (_method, params) => { - const progressHandler = mux.onNotificationByMethod.mock.calls[0][1] - progressHandler({ - progressId: params.progressId, - phase: 'Receiving objects', - percent: 42 - }) - progressHandler({ - progressId: 'other-clone', - phase: 'Receiving objects', - percent: 99 - }) - return { stdout: '', stderr: '' } - }) - - await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', { - timeoutMs: 1000, - onProgress - }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.clone', - expect.objectContaining({ - args: ['clone', '--progress', '--', 'url', 'repo'], - cwd: '/home/user', - progressId: expect.stringMatching(/^clone-/) - }), - { signal: undefined, timeoutMs: 1000 } - ) - expect(mux.onNotificationByMethod).toHaveBeenCalledWith( - 'git.cloneProgress', - expect.any(Function) - ) - expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 }) - expect(onProgress).toHaveBeenCalledTimes(1) - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('reports an actionable reconnect message when the relay does not support cloning', async () => { - const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number } - methodNotFound.code = -32601 - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user') - ).rejects.toThrow( - 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - }) - - it('execNonInteractive delegates fixed binary commands to the relay', async () => { - const execResult = { - stdout: '10.0.0\n', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - const result = await provider.execNonInteractive('pnpm', ['--version'], '/home/user/repo', 8000) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['--version'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - expect(result).toEqual(execResult) - }) - - it('execNonInteractive forwards environment variables to the relay', async () => { - const execResult = { - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - await provider.execNonInteractive( - '/bin/bash', - ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], - '/home/user/repo', - 120_000, - undefined, - { - ORCA_ROOT_PATH: '/home/user/repo', - ORCA_WORKTREE_PATH: '/home/user/repo-feature' - } - ) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: '/bin/bash', - args: ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 120_000, - env: { - ORCA_ROOT_PATH: '/home/user/repo', - ORCA_WORKTREE_PATH: '/home/user/repo-feature' - } - }, - { timeoutMs: 125_000 } - ) - }) - - it('cancelNonInteractiveExec sends best-effort relay cancellation', async () => { - await provider.cancelNonInteractiveExec('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) - }) - - it('exec forwards abort and timeout options to the relay request', async () => { - const controller = new AbortController() - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - - await provider.exec( - ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - '/home/user', - { - signal: controller.signal, - timeoutMs: 60_000 - } - ) - - expect(mux.request).toHaveBeenCalledWith( - 'git.exec', - { - args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - cwd: '/home/user', - // Why: exec opts into response streaming so a large stdout is chunked - // onto the bulk lane; old relays ignore the flag. - __streamResponse: true - }, - { - signal: controller.signal, - timeoutMs: 60_000 - } - ) - }) - - it('serializes non-interactive relay execs for the same cwd and operation', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - ) - - const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - - completeRequests.shift()?.() - await first - await waitForRequestCount(mux.request, 2) - - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - completeRequests.shift()?.() - await second - }) - - it('isolates non-interactive lanes between provider instances', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) - ) - }) - ) - const otherProvider = new SshGitProvider('conn-2', mux as never) - - const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenCalledTimes(2) - completeRequests.forEach((complete) => complete()) - await Promise.all([first, second]) - }) - - it('forwards leading global Git options without reordering argv', async () => { - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] - - await provider.exec(args, '/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.exec', { - args, - cwd: '/home/user/repo', - __streamResponse: true - }) - }) - - it('cancels a queued non-interactive exec without canceling the active relay child', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - ) - - const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - await provider.cancelNonInteractiveExec('/home/user/repo') - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).not.toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) - - completeRequests.shift()?.() - await first - const secondResult = await second - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(secondResult).toMatchObject({ canceled: true }) - }) - - it('uses an exec abort signal to cancel the matching active relay child with queued work present', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation((method) => { - if (method === 'agent.cancelExec') { - return Promise.resolve({ canceled: true }) - } - return new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - }) - - const controller = new AbortController() - const first = provider.execNonInteractive( - 'pnpm', - ['store', 'prune'], - '/home/user/repo', - 8000, - controller.signal - ) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - controller.abort() - await waitForRequestCount(mux.request, 2) - - expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.cancelExec', { cwd: '/home/user/repo' }) - - completeRequests.shift()?.() - await first - await waitForRequestCount(mux.request, 3) - expect(mux.request).toHaveBeenNthCalledWith( - 3, - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - completeRequests.shift()?.() - await second - }) -}) diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts deleted file mode 100644 index 7c7d0aa0717..00000000000 --- a/src/main/providers/ssh-git-provider-merge.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' - -describe('SshGitProvider merge operations', () => { - it('abortMerge sends git.abortMerge request', async () => { - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } - const provider = new SshGitProvider('conn-1', mux as never) - - await provider.abortMerge('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.abortMerge', { - worktreePath: '/home/user/repo' - }) - }) - - it('abortRebase sends git.abortRebase request', async () => { - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } - const provider = new SshGitProvider('conn-1', mux as never) - - await provider.abortRebase('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.abortRebase', { - worktreePath: '/home/user/repo' - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-remote-sync.test.ts b/src/main/providers/ssh-git-provider-remote-sync.test.ts deleted file mode 100644 index 4a25d54b8a6..00000000000 --- a/src/main/providers/ssh-git-provider-remote-sync.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getUpstreamStatus sends git.upstreamStatus request', async () => { - const upstreamResult = { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 } - mux.request.mockResolvedValue(upstreamResult) - - const result = await provider.getUpstreamStatus('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', { - worktreePath: '/home/user/repo' - }) - expect(result).toEqual(upstreamResult) - }) - - it('getUpstreamStatus forwards an explicit push target', async () => { - const upstreamResult = { hasUpstream: true, upstreamName: 'fork/feature', ahead: 0, behind: 1 } - mux.request.mockResolvedValue(upstreamResult) - - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - const result = await provider.getUpstreamStatus('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', { - worktreePath: '/home/user/repo', - pushTarget - }) - expect(result).toEqual(upstreamResult) - }) - - it('pushBranch sends git.push request and forwards publish mode and target', async () => { - await provider.pushBranch('/home/user/repo', true, { - remoteName: 'pr-fork-orca', - branchName: 'contributor/fix' - }) - expect(mux.request).toHaveBeenCalledWith('git.push', { - worktreePath: '/home/user/repo', - publish: true, - pushTarget: { - remoteName: 'pr-fork-orca', - branchName: 'contributor/fix' - } - }) - }) - - it('pushBranch forwards force-with-lease mode', async () => { - await provider.pushBranch('/home/user/repo', false, undefined, { forceWithLease: true }) - - expect(mux.request).toHaveBeenCalledWith('git.push', { - worktreePath: '/home/user/repo', - publish: false, - pushTarget: undefined, - forceWithLease: true - }) - }) - - it('pullBranch sends git.pull request', async () => { - await provider.pullBranch('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.pull', { - worktreePath: '/home/user/repo' - }) - }) - - it('pullBranch forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.pullBranch('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.pull', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('fastForwardBranch sends git.fastForward request', async () => { - await provider.fastForwardBranch('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.fastForward', { - worktreePath: '/home/user/repo' - }) - }) - - it('fastForwardBranch forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.fastForwardBranch('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.fastForward', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('rebaseFromBase sends git.rebaseFromBase request', async () => { - await provider.rebaseFromBase('/home/user/repo', 'upstream/main') - - expect(mux.request).toHaveBeenCalledWith( - 'git.rebaseFromBase', - { - worktreePath: '/home/user/repo', - baseRef: 'upstream/main' - }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - - it('fetchRemote sends git.fetch request', async () => { - await provider.fetchRemote('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.fetch', { - worktreePath: '/home/user/repo' - }) - }) - - it('fetchRemote forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.fetchRemote('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.fetch', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('syncForkDefaultBranch sends git.forkSync request', async () => { - const syncResult = { - status: 'synced', - originRemote: 'origin', - upstreamRemote: 'upstream', - branchName: 'main', - ahead: 0, - behind: 2 - } - mux.request.mockResolvedValue(syncResult) - - const expectedUpstream = { owner: 'stablyai', repo: 'orca' } - const result = await provider.syncForkDefaultBranch('/home/user/repo', expectedUpstream) - - expect(mux.request).toHaveBeenCalledWith('git.forkSync', { - worktreePath: '/home/user/repo', - expectedUpstream - }) - expect(result).toEqual(syncResult) - }) - - it('fetchRemoteTrackingRef sends git.fetchRemoteTrackingRef request', async () => { - await provider.fetchRemoteTrackingRef( - '/home/user/repo', - 'origin', - 'main', - 'refs/remotes/origin/main', - { skipAutoMaintenance: true } - ) - - expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { - worktreePath: '/home/user/repo', - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main', - skipAutoMaintenance: true - }) - }) - - it('fetchGitLabMergeRequestHead sends the durable-ref git.fetchGitLabMergeRequestHeadRef request', async () => { - mux.request.mockResolvedValueOnce({ - localRef: 'refs/orca/merge-requests/origin-abc/42' - }) - - const localRef = await provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - - expect(mux.request).toHaveBeenCalledWith('git.fetchGitLabMergeRequestHeadRef', { - worktreePath: '/home/user/repo', - remote: 'origin', - mrIid: 42 - }) - expect(localRef).toBe('refs/orca/merge-requests/origin-abc/42') - }) - - it('fetchGitLabMergeRequestHead maps old relays to the reconnect message', async () => { - const methodNotFound = Object.assign( - new Error('Method not found: git.fetchGitLabMergeRequestHeadRef'), - { code: -32601 } - ) - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('fetchGitLabMergeRequestHead rethrows non-method-not-found errors', async () => { - const error = new Error('fatal: could not read from remote repository') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toBe(error) - }) - - it('fetchGitHubPullRequestHead sends git.fetchGitHubPullRequestHead request', async () => { - mux.request.mockResolvedValueOnce({ localRef: 'refs/orca/pull/origin-abc/42' }) - - const localRef = await provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - - expect(mux.request).toHaveBeenCalledWith('git.fetchGitHubPullRequestHead', { - worktreePath: '/home/user/repo', - remote: 'origin', - prNumber: 42 - }) - expect(localRef).toBe('refs/orca/pull/origin-abc/42') - }) - - it('fetchGitHubPullRequestHead rejects relays that omit the durable localRef', async () => { - mux.request.mockResolvedValueOnce({}) - - await expect( - provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow('did not return the durable pull request head ref') - }) - - it('fetchGitHubPullRequestHead maps old relays to the reconnect message', async () => { - const methodNotFound = Object.assign( - new Error('Method not found: git.fetchGitHubPullRequestHead'), - { code: -32601 } - ) - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('fetchGitHubPullRequestHead rethrows non-method-not-found errors', async () => { - const error = new Error('fatal: could not read from remote repository') - mux.request.mockRejectedValueOnce(error) - - await expect(provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42)).rejects.toBe( - error - ) - }) -}) diff --git a/src/main/providers/ssh-git-provider-staging.test.ts b/src/main/providers/ssh-git-provider-staging.test.ts deleted file mode 100644 index 1ad3d973902..00000000000 --- a/src/main/providers/ssh-git-provider-staging.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE } from '../../shared/git-stage-worktree-scope' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('commit sends git.commit request', async () => { - const commitResult = { success: true } - mux.request.mockResolvedValue(commitResult) - - const result = await provider.commit('/home/user/repo', 'feat: add source control commit') - - expect(mux.request).toHaveBeenCalledWith('git.commit', { - worktreePath: '/home/user/repo', - message: 'feat: add source control commit' - }) - expect(result).toEqual(commitResult) - }) - - it('stageFile sends git.stage request', async () => { - await provider.stageFile('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.stage', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('unstageFile sends git.unstage request', async () => { - await provider.unstageFile('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.unstage', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('bulkStageFiles sends git.bulkStage request', async () => { - await provider.bulkStageFiles('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkStage', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) - - it('bulkStageFiles forwards a whole-worktree scope and accepts the relay receipt', async () => { - mux.request.mockResolvedValue({ stagedScope: 'all' }) - await provider.bulkStageFiles('/home/user/repo', [], 'all') - expect(mux.request).toHaveBeenCalledWith('git.bulkStage', { - worktreePath: '/home/user/repo', - filePaths: [], - scope: 'all' - }) - }) - - it('bulkStageFiles rejects a scoped stage an older relay answered without a receipt', async () => { - // An older relay strips `scope`, stages the empty path list, and replies with nothing. - mux.request.mockResolvedValue(undefined) - await expect(provider.bulkStageFiles('/home/user/repo', [], 'all')).rejects.toThrow( - GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE - ) - mux.request.mockResolvedValue({ stagedScope: 'tracked' }) - await expect(provider.bulkStageFiles('/home/user/repo', [], 'all')).rejects.toThrow( - GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE - ) - }) - - it('bulkUnstageFiles sends git.bulkUnstage request', async () => { - await provider.bulkUnstageFiles('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkUnstage', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) - - it('discardChanges sends git.discard request', async () => { - await provider.discardChanges('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.discard', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('bulkDiscardChanges sends git.bulkDiscard request', async () => { - await provider.bulkDiscardChanges('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkDiscard', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-status-lease.test.ts b/src/main/providers/ssh-git-provider-status-lease.test.ts deleted file mode 100644 index 9f436ba1a1a..00000000000 --- a/src/main/providers/ssh-git-provider-status-lease.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredPromise(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('SshGitProvider status read leases', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('isolates status reads by worktree and output-affecting options', async () => { - const pendingRequests = Array.from({ length: 8 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - - const reads = [ - provider.getStatus('/home/user/repo'), - provider.getStatus('/home/user/other'), - provider.getStatus('/home/user/repo', { includeIgnored: true }), - provider.getStatus('/home/user/repo', { includeLineStats: false }), - provider.getStatus('/home/user/repo', { - bypassEffectiveUpstreamNegativeCache: true - }), - provider.getStatus('/home/user/repo', { reuseLineStats: true }), - provider.getStatus('/home/user/repo', { admissionTier: 'background' }), - provider.getStatus('/home/user/repo', { admissionTier: 'interactive' }) - ] - await waitForRequestCount(mux.request, 8) - - expect(mux.request.mock.calls.map(([, payload]) => payload)).toEqual([ - { worktreePath: '/home/user/repo' }, - { worktreePath: '/home/user/other' }, - { worktreePath: '/home/user/repo', includeIgnored: true }, - { worktreePath: '/home/user/repo', includeLineStats: false }, - { - worktreePath: '/home/user/repo', - bypassEffectiveUpstreamNegativeCache: true - }, - { worktreePath: '/home/user/repo', reuseLineStats: true }, - { worktreePath: '/home/user/repo', admissionTier: 'background' }, - { worktreePath: '/home/user/repo', admissionTier: 'interactive' } - ]) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('isolates status reads by branch-line-total fork point', async () => { - const pendingRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - - // Why: the response shape differs per fork point, so a poll that omitted the base - // must never serve a refresh that asked for it (the chip would blank or go stale). - const reads = [ - provider.getStatus('/home/user/repo'), - provider.getStatus('/home/user/repo', { branchLineTotalMergeBase: 'abc123' }), - provider.getStatus('/home/user/repo', { branchLineTotalMergeBase: 'def456' }) - ] - await waitForRequestCount(mux.request, 3) - - expect(mux.request.mock.calls.map(([, payload]) => payload)).toEqual([ - { worktreePath: '/home/user/repo' }, - { worktreePath: '/home/user/repo', branchLineTotalMergeBase: 'abc123' }, - { worktreePath: '/home/user/repo', branchLineTotalMergeBase: 'def456' } - ]) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('keeps status leases isolated per provider and relay incarnation', async () => { - const pendingRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - const replacement = new SshGitProvider('conn-1', mux as never) - const otherConnection = new SshGitProvider('conn-2', mux as never) - - const reads = [ - provider.getStatus('/home/user/repo'), - replacement.getStatus('/home/user/repo'), - otherConnection.getStatus('/home/user/repo') - ] - await waitForRequestCount(mux.request, 3) - - expect(mux.request).toHaveBeenCalledTimes(3) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('fences status reads before, during, and after an SSH mutation', async () => { - const statusRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - const mutation = deferredPromise() - let statusRequestIndex = 0 - mux.request.mockImplementation((method) => { - if (method === 'git.status') { - return statusRequests[statusRequestIndex++]?.promise - } - if (method === 'git.stage') { - return mutation.promise - } - return Promise.resolve(undefined) - }) - - const beforeMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 1) - const mutating = provider.stageFile('/home/user/repo', 'src/file.ts') - await waitForRequestCount(mux.request, 2) - const duringMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 3) - mutation.resolve(undefined) - await mutating - const afterMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 4) - - statusRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all([beforeMutation, duringMutation, afterMutation]) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(3) - }) -}) diff --git a/src/main/providers/ssh-git-provider-status.test.ts b/src/main/providers/ssh-git-provider-status.test.ts deleted file mode 100644 index bb5d8adde2e..00000000000 --- a/src/main/providers/ssh-git-provider-status.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getStatus sends git.status request', async () => { - const statusResult = { - entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown', - didHitLimit: true, - statusLength: 1_001 - } - mux.request.mockResolvedValue(statusResult) - - const result = await provider.getStatus('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - expect(result).toEqual(statusResult) - }) - - it('getStatus forwards includeIgnored only when requested', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] } - mux.request.mockResolvedValue(statusResult) - - await provider.getStatus('/home/user/repo', { includeIgnored: true }) - await provider.getStatus('/home/user/repo', { includeIgnored: false }) - - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'git.status', - { - worktreePath: '/home/user/repo', - includeIgnored: true - }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards a false line-stats request', async () => { - mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - - await provider.getStatus('/home/user/repo', { includeLineStats: false }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo', includeLineStats: false }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }) - await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false }) - - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'git.status', - { - worktreePath: '/home/user/repo', - bypassEffectiveUpstreamNegativeCache: true - }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards line-stat reuse and cancellation to the relay', async () => { - const controller = new AbortController() - mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - - await provider.getStatus('/home/user/repo', { - reuseLineStats: true, - signal: controller.signal - }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo', reuseLineStats: true }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request.mock.calls[0][2].signal).not.toBe(controller.signal) - }) - - it('getSubmoduleStatus sends git.submoduleStatus request', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - const result = await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib') - - expect(mux.request).toHaveBeenCalledWith('git.submoduleStatus', { - worktreePath: '/home/user/repo', - submodulePath: 'vendor/lib', - area: 'unstaged' - }) - expect(result).toEqual(statusResult) - }) - - it('getSubmoduleStatus forwards the requested source-control area', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib', 'staged') - - expect(mux.request).toHaveBeenCalledWith('git.submoduleStatus', { - worktreePath: '/home/user/repo', - submodulePath: 'vendor/lib', - area: 'staged' - }) - }) - - it('reports an actionable reconnect message when the relay lacks submodule status', async () => { - const methodNotFound = new Error('Method not found: git.submoduleStatus') as Error & { - code?: number - } - methodNotFound.code = -32601 - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect(provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib')).rejects.toThrow( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - }) - - it('rethrows non-method-not-found submodule status errors unchanged', async () => { - mux.request.mockRejectedValueOnce(new Error('fatal: not a submodule')) - - await expect(provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib')).rejects.toThrow( - 'fatal: not a submodule' - ) - }) - - it('checkIgnoredPaths sends git.checkIgnored request', async () => { - mux.request.mockResolvedValue(['dist/bundle.js']) - - const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js']) - - expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', { - worktreePath: '/home/user/repo', - paths: ['dist/bundle.js'] - }) - expect(result).toEqual(['dist/bundle.js']) - }) - - it('getHistory sends git.history request', async () => { - const historyResult = { - items: [], - hasIncomingChanges: false, - hasOutgoingChanges: false, - hasMore: false, - limit: 50 - } - mux.request.mockResolvedValue(historyResult) - - const result = await provider.getHistory('/home/user/repo', { - limit: 25, - baseRef: 'origin/main' - }) - - expect(mux.request).toHaveBeenCalledWith('git.history', { - worktreePath: '/home/user/repo', - limit: 25, - baseRef: 'origin/main' - }) - expect(result).toEqual(historyResult) - }) - - it('detectConflictOperation sends git.conflictOperation request', async () => { - mux.request.mockResolvedValue('rebase') - const result = await provider.detectConflictOperation('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.conflictOperation', { - worktreePath: '/home/user/repo' - }) - expect(result).toBe('rebase') - }) - - it('getBranchCompare sends git.branchCompare request', async () => { - const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] } - mux.request.mockResolvedValue(compareResult) - - const result = await provider.getBranchCompare('/home/user/repo', 'main', { - admissionTier: 'background' - }) - expect(mux.request).toHaveBeenCalledWith('git.branchCompare', { - worktreePath: '/home/user/repo', - baseRef: 'main', - admissionTier: 'background' - }) - expect(result).toEqual(compareResult) - }) - - it('isGitRepo always returns true for remote paths', () => { - expect(provider.isGitRepo('/any/path')).toBe(true) - }) -}) diff --git a/src/main/providers/ssh-git-provider-test-harness.ts b/src/main/providers/ssh-git-provider-test-harness.ts deleted file mode 100644 index 86ef0a0e9b0..00000000000 --- a/src/main/providers/ssh-git-provider-test-harness.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { vi } from 'vitest' - -export type MockMultiplexer = { - request: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType -} - -export function createMockMux(): MockMultiplexer { - return { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), - // Why: requestGitStreamable subscribes to onDispose before awaiting the - // response so it can reject in-flight reassembly if the link drops. - onDispose: vi.fn().mockReturnValue(vi.fn()), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } -} - -export async function waitForRequestCount( - mock: ReturnType, - count: number -): Promise { - for (let i = 0; i < 20; i++) { - if (mock.mock.calls.length >= count) { - return - } - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} diff --git a/src/main/providers/ssh-git-provider-upstream-lease.test.ts b/src/main/providers/ssh-git-provider-upstream-lease.test.ts deleted file mode 100644 index 5717be064c4..00000000000 --- a/src/main/providers/ssh-git-provider-upstream-lease.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredPromise(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('SshGitProvider upstream status read leases', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('isolates upstream-status RPCs by worktree and every target field', async () => { - mux.request.mockResolvedValue({ - hasUpstream: true, - upstreamName: 'fork/feature', - ahead: 0, - behind: 0 - }) - const baseTarget = { remoteName: 'fork', branchName: 'feature' } - - await Promise.all([ - provider.getUpstreamStatus('/repo-a'), - provider.getUpstreamStatus('/repo-b'), - provider.getUpstreamStatus('/repo-a', baseTarget), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }), - provider.getUpstreamStatus('/repo-a', { - ...baseTarget, - remoteUrl: 'https://github.com/example/fork.git' - }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(8) - }) - - it('keeps upstream-status reads isolated per provider instance', async () => { - const otherProvider = new SshGitProvider('conn-1', mux as never) - mux.request.mockResolvedValue({ - hasUpstream: true, - upstreamName: 'origin/main', - ahead: 0, - behind: 0 - }) - - await Promise.all([ - provider.getUpstreamStatus('/home/user/repo'), - otherProvider.getUpstreamStatus('/home/user/repo') - ]) - - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('fences upstream-status reads before, during, and after an SSH mutation', async () => { - const upstreamRequests = Array.from({ length: 3 }, () => - deferredPromise<{ hasUpstream: false; ahead: 0; behind: 0 }>() - ) - const mutation = deferredPromise() - let upstreamRequestIndex = 0 - mux.request.mockImplementation((method) => { - if (method === 'git.upstreamStatus') { - return upstreamRequests[upstreamRequestIndex++]?.promise - } - if (method === 'git.stage') { - return mutation.promise - } - return Promise.resolve(undefined) - }) - - const beforeMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 1) - const mutating = provider.stageFile('/home/user/repo', 'src/file.ts') - await waitForRequestCount(mux.request, 2) - const duringMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 3) - mutation.resolve(undefined) - await mutating - const afterMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 4) - - upstreamRequests.forEach((pending) => - pending.resolve({ hasUpstream: false, ahead: 0, behind: 0 }) - ) - await Promise.all([beforeMutation, duringMutation, afterMutation]) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.upstreamStatus') - ).toHaveLength(3) - }) -}) diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts deleted file mode 100644 index cc9a8024104..00000000000 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function methodNotFound(method: string): Error & { code: number } { - return Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }) -} - -const CLEAN_STATUS = { entries: [], conflictOperation: 'unknown' } - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('listWorktrees sends git.listWorktrees request', async () => { - const worktrees = [ - { - path: '/home/user/repo', - head: 'abc123', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ] - mux.request.mockResolvedValue(worktrees) - - const controller = new AbortController() - const result = await provider.listWorktrees('/home/user/repo', { signal: controller.signal }) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: '/home/user/repo' }, - { signal: controller.signal } - ) - expect(result).toEqual(worktrees) - }) - - it('addWorktree sends git.addWorktree request', async () => { - await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { - base: 'main', - noCheckout: true - }) - expect(mux.request).toHaveBeenCalledWith('git.addWorktree', { - repoPath: '/home/user/repo', - branchName: 'feature', - targetDir: '/home/user/feat', - base: 'main', - noCheckout: true - }) - }) - - it('removeWorktree sends git.removeWorktree request', async () => { - await provider.removeWorktree('/home/user/feat', true) - expect(mux.request).toHaveBeenCalledWith('git.removeWorktree', { - worktreePath: '/home/user/feat', - force: true - }) - }) - - it('worktreeIsClean sends git.worktreeIsClean request', async () => { - const cleanResult = { clean: false, stdout: '?? scratch.txt\n' } - mux.request.mockResolvedValue(cleanResult) - - const result = await provider.worktreeIsClean('/home/user/feat') - - expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { - worktreePath: '/home/user/feat' - }) - expect(result).toEqual(cleanResult) - }) - - it('keeps supported clean checks on the preferred relay RPC', async () => { - let resolveProbe!: (result: { clean: boolean }) => void - let resolveRemaining!: (result: { clean: boolean }) => void - const probe = new Promise<{ clean: boolean }>((resolve) => { - resolveProbe = resolve - }) - const remaining = new Promise<{ clean: boolean }>((resolve) => { - resolveRemaining = resolve - }) - let preferredRequestCount = 0 - mux.request.mockImplementation((method) => { - expect(method).toBe('git.worktreeIsClean') - preferredRequestCount += 1 - return preferredRequestCount === 1 ? probe : remaining - }) - const checks = Array.from({ length: 10 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledOnce() - resolveProbe({ clean: true }) - await waitForRequestCount(mux.request, 10) - expect(mux.request).toHaveBeenCalledTimes(10) - resolveRemaining({ clean: true }) - await expect(Promise.all(checks)).resolves.toEqual( - Array.from({ length: 10 }, () => ({ clean: true })) - ) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(10) - expect(mux.request.mock.calls.some(([method]) => method === 'git.status')).toBe(false) - }) - - it('worktreeIsClean can ignore untracked files', async () => { - const cleanResult = { clean: true } - mux.request.mockResolvedValue(cleanResult) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { - worktreePath: '/home/user/feat', - includeUntracked: false - }) - expect(result).toEqual(cleanResult) - }) - - it('worktreeIsClean filters untracked stdout when old relays ignore the option', async () => { - mux.request.mockResolvedValue({ clean: false, stdout: '?? scratch.txt\n' }) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(result).toEqual({ clean: true }) - }) - - it('worktreeIsClean keeps dirty results without stdout dirty for tracked-only checks', async () => { - mux.request.mockResolvedValue({ clean: false }) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(result).toEqual({ clean: false }) - }) - - it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request and returns its outcome', async () => { - const refs = { - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - } - mux.request.mockResolvedValueOnce({ - status: 'skipped_dirty_worktree', - ownerWorktreePath: '/home/user/repo' - }) - - await expect(provider.refreshLocalBaseRefForWorktreeCreate(refs)).resolves.toEqual({ - status: 'skipped_dirty_worktree', - ownerWorktreePath: '/home/user/repo' - }) - expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', refs) - }) - - it('refreshLocalBaseRefForWorktreeCreate reads a malformed relay reply as an error', async () => { - mux.request.mockResolvedValueOnce(undefined) - - await expect( - provider.refreshLocalBaseRefForWorktreeCreate({ - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'skipped_error' }) - }) - - it('getLocalBaseRefFastForwardableBehind asks the relay to inspect without moving anything', async () => { - const refs = { - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - } - mux.request - .mockResolvedValueOnce({ status: 'behind', behind: 4, localOid: 'a', remoteOid: 'b' }) - .mockResolvedValueOnce({ status: 'skipped_dirty_worktree', ownerWorktreePath: '/x' }) - - await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBe(4) - await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBeUndefined() - expect(mux.request).toHaveBeenCalledWith('git.inspectLocalBaseRefForWorktreeCreate', refs) - expect(mux.request).not.toHaveBeenCalledWith( - 'git.refreshLocalBaseRefForWorktreeCreate', - expect.anything() - ) - }) - - it('worktreeIsClean falls back to git.status for old relays', async () => { - mux.request.mockRejectedValueOnce(methodNotFound('git.worktreeIsClean')).mockResolvedValueOnce({ - entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown' - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - const result = await provider.worktreeIsClean('/home/user/feat') - - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.worktreeIsClean', { - worktreePath: '/home/user/feat' - }) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/feat' }, - { signal: expect.any(AbortSignal) } - ) - expect(result).toEqual({ clean: false, stdout: 'untracked untracked: scratch.txt' }) - } finally { - warnSpy.mockRestore() - } - }) - - it('probes an old relay once across sequential clean checks', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return CLEAN_STATUS - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worktreePaths = Array.from({ length: 10 }, (_, index) => `/repo/worktree-${index}`) - - try { - for (const worktreePath of worktreePaths) { - await expect(provider.worktreeIsClean(worktreePath)).resolves.toEqual({ clean: true }) - } - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) - expect(mux.request).toHaveBeenCalledTimes(11) - expect(warnSpy).toHaveBeenCalledOnce() - } finally { - warnSpy.mockRestore() - } - }) - - it('shares one old-relay probe across concurrent clean checks', async () => { - let rejectProbe!: (error: Error) => void - const probe = new Promise((_resolve, reject) => { - rejectProbe = reject - }) - mux.request.mockImplementation((method) => { - if (method === 'git.worktreeIsClean') { - return probe - } - return Promise.resolve(CLEAN_STATUS) - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const checks = Array.from({ length: 10 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - - try { - await waitForRequestCount(mux.request, 1) - rejectProbe(methodNotFound('git.worktreeIsClean')) - await expect(Promise.all(checks)).resolves.toEqual( - Array.from({ length: 10 }, () => ({ clean: true })) - ) - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) - expect(mux.request).toHaveBeenCalledTimes(11) - expect(warnSpy).toHaveBeenCalledOnce() - } finally { - warnSpy.mockRestore() - } - }) - - it('retries the preferred clean RPC after a transport failure', async () => { - const transportError = Object.assign(new Error('Method not found: git.worktreeIsClean'), { - code: -32602 - }) - mux.request.mockRejectedValueOnce(transportError).mockResolvedValueOnce({ clean: true }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/repo/first')).rejects.toBe(transportError) - await expect(provider.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) - expect(mux.request.mock.calls.map(([method]) => method)).toEqual([ - 'git.worktreeIsClean', - 'git.worktreeIsClean' - ]) - expect(warnSpy).not.toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('lets concurrent waiters retry after a transient probe failure', async () => { - const transportError = new Error('connection closed') - let rejectProbe!: (error: Error) => void - let resolveRetries!: (result: { clean: boolean }) => void - const probe = new Promise((_resolve, reject) => { - rejectProbe = reject - }) - const retries = new Promise<{ clean: boolean }>((resolve) => { - resolveRetries = resolve - }) - let preferredRequestCount = 0 - mux.request.mockImplementation((method) => { - expect(method).toBe('git.worktreeIsClean') - preferredRequestCount += 1 - return preferredRequestCount === 1 ? probe : retries - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const checks = Array.from({ length: 3 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - const settledChecks = Promise.allSettled(checks) - - try { - await waitForRequestCount(mux.request, 1) - rejectProbe(transportError) - await waitForRequestCount(mux.request, 3) - resolveRetries({ clean: true }) - - const results = await settledChecks - expect(results[0]).toEqual({ status: 'rejected', reason: transportError }) - expect(results.slice(1)).toEqual([ - { status: 'fulfilled', value: { clean: true } }, - { status: 'fulfilled', value: { clean: true } } - ]) - await expect(provider.worktreeIsClean('/repo/later')).resolves.toEqual({ clean: true }) - expect(mux.request).toHaveBeenCalledTimes(4) - expect(warnSpy).not.toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('re-probes clean support after the provider is replaced', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return CLEAN_STATUS - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/repo/first')).resolves.toEqual({ clean: true }) - const replacement = new SshGitProvider('conn-1', mux as never) - await expect(replacement.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(2) - expect(warnSpy).toHaveBeenCalledTimes(2) - } finally { - warnSpy.mockRestore() - } - }) - - it('worktreeIsClean filters untracked entries in old-relay fallback', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return { - entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown' - } - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/home/user/feat')).resolves.toEqual({ - clean: false, - stdout: 'untracked untracked: scratch.txt' - }) - await expect( - provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - ).resolves.toEqual({ clean: true }) - expect(mux.request).toHaveBeenNthCalledWith( - 3, - 'git.status', - { worktreePath: '/home/user/feat' }, - { signal: expect.any(AbortSignal) } - ) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - } finally { - warnSpy.mockRestore() - } - }) - - it('renameCurrentBranch sends the narrow branch-rename request', async () => { - await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth') - expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', { - worktreePath: '/home/user/feat', - newBranch: 'you/fix-auth' - }) - }) - - it('forceDeletePreservedBranch sends the preserved-branch delete request', async () => { - await provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - expect(mux.request).toHaveBeenCalledWith('git.forceDeletePreservedBranch', { - repoPath: '/home/user/repo', - branchName: 'you/fix-auth', - expectedHead: 'abc123' - }) - }) - - it('forceDeletePreservedBranch maps old relays to the reconnect message', async () => { - mux.request.mockRejectedValueOnce(methodNotFound('git.forceDeletePreservedBranch')) - - await expect( - provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('forceDeletePreservedBranch rethrows non-method-not-found errors', async () => { - const error = new Error('remote update-ref failed') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - ).rejects.toBe(error) - }) - - it('markRemoteOrcaCreated sends the narrow provenance-marker request', async () => { - await provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - expect(mux.request).toHaveBeenCalledWith('git.markRemoteOrcaCreated', { - repoPath: '/home/user/repo', - remoteName: 'pr-contributor-orca' - }) - }) - - it('markRemoteOrcaCreated degrades to a one-time warning for an older relay', async () => { - mux.request.mockRejectedValue(methodNotFound('git.markRemoteOrcaCreated')) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).resolves.toBeUndefined() - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).resolves.toBeUndefined() - expect(warnSpy).toHaveBeenCalledTimes(1) - } finally { - warnSpy.mockRestore() - } - }) - - it('markRemoteOrcaCreated rethrows non-method-not-found errors', async () => { - const error = new Error('remote config write failed') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).rejects.toBe(error) - }) -}) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts deleted file mode 100644 index b2f8f88d76c..00000000000 --- a/src/main/providers/ssh-git-provider.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { - describeMaxBufferOverflowError, - isMaxBufferOverflowError -} from '../git/max-buffer-overflow' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import type { IGitProvider } from './types' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' -import { ReviewDraftContextError } from '../../shared/review-draft-context-error' - -export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { - async readReviewDiff( - worktreePath: string, - mergeBase: string, - format: 'name-status' | 'patch', - options?: { timeoutMs?: number } - ): Promise<{ stdout: string; stderr: string }> { - let result: unknown - try { - result = await requestGitStreamable( - this.mux, - 'git.reviewDiff', - { worktreePath, mergeBase, format }, - options - ) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new ReviewDraftContextError( - 'SSH review draft support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw new ReviewDraftContextError( - error instanceof Error ? error.message : 'SSH review diff failed.', - { cause: error } - ) - } - if ( - !result || - typeof result !== 'object' || - !('stdout' in result) || - !('stderr' in result) || - typeof result.stdout !== 'string' || - typeof result.stderr !== 'string' - ) { - throw new ReviewDraftContextError('Invalid SSH review diff response.') - } - return { stdout: result.stdout, stderr: result.stderr } - } - - async getStagedCommitContext(worktreePath: string): Promise { - const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ - stdout: '' - })) - const [branchResult, summaryResult] = await Promise.all([ - branchPromise, - this.exec(['diff', '--cached', '--name-status'], worktreePath) - ]) - const stagedSummary = summaryResult.stdout.trim() - if (!stagedSummary) { - return null - } - let stagedPatch = '' - try { - const patchResult = await this.exec( - ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - worktreePath - ) - stagedPatch = patchResult.stdout - } catch (error) { - if (!isMaxBufferOverflowError(error)) { - throw error - } - console.warn( - '[ssh-git] Staged patch too large to read; using file summary only:', - describeMaxBufferOverflowError(error) - ) - } - return { - branch: branchResult.stdout.trim() || null, - stagedSummary, - stagedPatch - } - } - - async exec( - args: string[], - cwd: string, - options?: { signal?: AbortSignal; timeoutMs?: number } - ): Promise<{ stdout: string; stderr: string }> { - const run = () => - options - ? requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) - : requestGitStreamable(this.mux, 'git.exec', { args, cwd }) - const result = gitExecMutatesRepository(args) - ? await this.runWithGitReadInvalidation(run) - : await run() - return result as { stdout: string; stderr: string } - } - - async clone( - args: string[], - cwd: string, - options?: { - signal?: AbortSignal - timeoutMs?: number - onProgress?: (progress: { phase: string; percent: number }) => void - } - ): Promise<{ stdout: string; stderr: string }> { - return this.runWithGitReadInvalidation(async () => { - const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}` - const unsubscribe = options?.onProgress - ? this.mux.onNotificationByMethod('git.cloneProgress', (params) => { - if (params.progressId !== progressId) { - return - } - const phase = params.phase - const percent = params.percent - if (typeof phase === 'string' && typeof percent === 'number') { - options.onProgress?.({ phase, percent }) - } - }) - : undefined - try { - const result = await this.mux.request( - 'git.clone', - { args, cwd, progressId }, - { signal: options?.signal, timeoutMs: options?.timeoutMs } - ) - return result as { stdout: string; stderr: string } - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } finally { - unsubscribe?.() - } - }) - } - - async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { - return (await this.mux.request('git.isGitRepo', { dirPath })) as { - isRepo: boolean - rootPath: string | null - } - } - - // Remote paths are validated asynchronously by the relay before registration. - isGitRepo(_path: string): boolean { - return true - } - - private async readOriginRemoteUrl(worktreePath: string): Promise { - try { - const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) - return result.stdout.trim() || null - } catch { - return null - } - } - - async getRemoteFileUrl( - worktreePath: string, - relativePath: string, - line: number - ): Promise { - const remoteUrl = await this.readOriginRemoteUrl(worktreePath) - if (!remoteUrl) { - return null - } - - let defaultBranch = 'main' - try { - const refResult = await this.exec( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - worktreePath - ) - const ref = refResult.stdout.trim() - if (ref) { - defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') - } - } catch { - // Fall back to 'main'. - } - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) - } - - async getRemoteCommitUrl(worktreePath: string, sha: string): Promise { - const remoteUrl = await this.readOriginRemoteUrl(worktreePath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) - } -} diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts deleted file mode 100644 index cbf20271003..00000000000 --- a/src/main/providers/ssh-git-read-provider.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import type { GitDiffResult } from '../../shared/git-diff-compare-types' -import type { - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GitPushTarget } from '../../shared/worktree/types' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import type { GitProviderStatusOptions } from './types' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' - -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -export class SshGitReadProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - constructor( - private readonly connectionId: string, - protected readonly mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) {} - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - protected async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - /** Overridden by subclasses that own additional read caches (worktree listings). */ - protected invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const request = { - worktreePath, - ...(options?.admissionTier ? { admissionTier: options.admissionTier } : {}), - ...(options?.includeIgnored ? { includeIgnored: true } : {}), - ...(options?.includeLineStats === false ? { includeLineStats: false } : {}), - ...(options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), - ...(options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) - } - const key = stableInFlightKey([ - worktreePath, - options?.admissionTier ?? 'status', - options?.includeIgnored === true, - options?.includeLineStats !== false, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } -} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts deleted file mode 100644 index d8bd8fe9016..00000000000 --- a/src/main/providers/ssh-git-relay-errors.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { JsonRpcErrorCode } from '../ssh/relay-protocol' - -export function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts deleted file mode 100644 index bead9414f3b..00000000000 --- a/src/main/providers/ssh-git-remote-sync-provider.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { GitPushTarget } from '../../shared/worktree/types' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' - -export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } -} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts deleted file mode 100644 index 5ff5cc69048..00000000000 --- a/src/main/providers/ssh-git-review-head-provider.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' - -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } -} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts deleted file mode 100644 index b26da56be4d..00000000000 --- a/src/main/providers/ssh-git-working-tree-provider.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { - GitBranchCompareResult, - GitCommitCompareResult -} from '../../shared/git-diff-compare-types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitConflictOperation } from '../../shared/git-status-types' -import { - requireGitStageWorktreeScopeReceipt, - type GitStageWorktreeScope -} from '../../shared/git-stage-worktree-scope' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' - -export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles( - worktreePath: string, - filePaths: string[], - scope?: GitStageWorktreeScope - ): Promise { - await this.runWithGitReadInvalidation(async () => { - const reply = await this.mux.request('git.bulkStage', { - worktreePath, - filePaths, - ...(scope ? { scope } : {}) - }) - if (scope) { - requireGitStageWorktreeScopeReceipt(reply, scope) - } - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare( - worktreePath: string, - baseRef: string, - options: { admissionTier?: GitAdmissionTier } = {} - ): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef, - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } -} diff --git a/src/main/providers/ssh-git-worktree-list-dedupe.test.ts b/src/main/providers/ssh-git-worktree-list-dedupe.test.ts deleted file mode 100644 index 4030dbe87e4..00000000000 --- a/src/main/providers/ssh-git-worktree-list-dedupe.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Local repos coalesce concurrent `git worktree list` scans (`shareWorktreeScan`); the SSH path - * branched away from that and paid one relay round trip per independent caller (`worktrees:list`, - * `worktrees:listAll`, the space repo scan, provisioned-root adoption). These are call counters. - */ -import { describe, expect, it } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' - -const REPO_PATH = '/home/user/repo' - -const WORKTREES = [ - { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true } -] - -type Deferred = { resolve: (value: unknown) => void; reject: (error: unknown) => void } - -/** Holds `git.listWorktrees` open so overlap is deterministic; answers everything else at once. */ -function createPendingListMux(): { mux: MockMultiplexer; listDeferreds: Deferred[] } { - const mux = createMockMux() - const listDeferreds: Deferred[] = [] - mux.request.mockImplementation((method: string) => { - if (method !== 'git.listWorktrees') { - return Promise.resolve(undefined) - } - return new Promise((resolve, reject) => { - listDeferreds.push({ resolve, reject }) - }) - }) - return { mux, listDeferreds } -} - -const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) - -function countListRequests(mux: MockMultiplexer): number { - return mux.request.mock.calls.filter((call) => call[0] === 'git.listWorktrees').length -} - -describe('SSH git.listWorktrees in-flight dedupe', () => { - it('collapses concurrent listings of one repo into a single relay request', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = Array.from({ length: 6 }, () => provider.listWorktrees(REPO_PATH)) - await flush() - - expect(countListRequests(mux)).toBe(1) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: REPO_PATH }, - { signal: undefined } - ) - - listDeferreds[0].resolve(WORKTREES) - expect(await Promise.all(listings)).toEqual(Array.from({ length: 6 }, () => WORKTREES)) - }) - - it('does not share across repos or connections', async () => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - void provider.listWorktrees('/home/user/other') - await flush() - expect(countListRequests(mux)).toBe(2) - - const second = createPendingListMux() - void new SshGitProvider('conn-2', second.mux as never).listWorktrees(REPO_PATH) - await flush() - - expect(countListRequests(second.mux)).toBe(1) - expect(countListRequests(mux)).toBe(2) - }) - - it('keeps a signalled listing on its own request', async () => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - await flush() - const controller = new AbortController() - void provider.listWorktrees(REPO_PATH, { signal: controller.signal }) - await flush() - - expect(countListRequests(mux)).toBe(2) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: REPO_PATH }, - { signal: controller.signal } - ) - }) - - it('re-requests after the shared listing settles instead of caching it', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const first = provider.listWorktrees(REPO_PATH) - await flush() - listDeferreds[0].resolve(WORKTREES) - await first - - void provider.listWorktrees(REPO_PATH) - await flush() - - expect(countListRequests(mux)).toBe(2) - }) - - it.each([ - ['addWorktree', (p: SshGitProvider) => p.addWorktree(REPO_PATH, 'feature', '/home/user/feat')], - ['removeWorktree', (p: SshGitProvider) => p.removeWorktree('/home/user/feat')] - ])('invalidates the shared listing after %s', async (_name, mutate) => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(1) - - await mutate(provider) - - // The catalog moved, so a joiner must not inherit the pre-mutation scan. - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(2) - }) - - it('shares a failed listing with its joiners and re-requests afterwards', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] - await flush() - expect(countListRequests(mux)).toBe(1) - - const failure = new Error('relay request failed') - listDeferreds[0].reject(failure) - await expect(listings[0]).rejects.toBe(failure) - await expect(listings[1]).rejects.toBe(failure) - - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(2) - }) - - it('refuses an unauthoritative relay answer for every joiner (#14004)', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] - await flush() - listDeferreds[0].resolve([]) - - await expect(listings[0]).rejects.toThrow() - await expect(listings[1]).rejects.toThrow() - }) -}) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts deleted file mode 100644 index 4e1e0f3a057..00000000000 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { GitStatusResult } from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { CapabilityProbeCache } from '../../shared/capability-probe-cache' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { - parseLocalBaseBranchFastForwardOutcome, - readFastForwardableBehindCount, - type LocalBaseBranchFastForwardOutcome, - type LocalBaseBranchRefs -} from '../../shared/worktree/local-base-branch-fast-forward' -import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' - -const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { - private loggedWorktreeIsCleanFallback = false - private loggedMarkRemoteOrcaCreatedFallback = false - // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. - private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< - typeof WORKTREE_IS_CLEAN_CAPABILITY - >(Number.POSITIVE_INFINITY) - // Scoped to this provider instance, so two SSH hosts never share an entry. - private readonly worktreeListDedupe = new InFlightPromiseDedupe() - - protected override invalidateGitReads(): void { - super.invalidateGitReads() - this.worktreeListDedupe.clear() - } - - /** Un-signalled reads of one repo coalesce onto the request already in flight; nothing is cached. */ - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - // Why: same rule as shareWorktreeScan — one caller's abort must not cancel the scan its - // joiners are still waiting on, so a signalled read keeps its own request. - if (options?.signal) { - return this.requestWorktreeList(repoPath, options.signal) - } - return this.worktreeListDedupe.run(stableInFlightKey(['listWorktrees', repoPath]), () => - this.requestWorktreeList(repoPath) - ) - } - - /** The one real relay round trip a coalesced read's joiners all wait on. */ - private async requestWorktreeList( - repoPath: string, - signal?: AbortSignal - ): Promise { - const response = await this.mux.request('git.listWorktrees', { repoPath }, { signal }) - // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are - // normal, so refuse the shape here too — a Git repo always lists its own checkout. - return assertAuthoritativeWorktreeCatalog(response, repoPath) - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - return this.worktreeIsCleanCapabilityCache.runWithFallback( - WORKTREE_IS_CLEAN_CAPABILITY, - async () => { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { clean: boolean; stdout?: string } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - }, - async () => { - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - }, - isJsonRpcMethodNotFoundError - ) - } - - /** The relay inspects and moves the branch host-side, one run per branch at a time. */ - async refreshLocalBaseRefForWorktreeCreate( - args: LocalBaseBranchRefs - ): Promise { - return this.runWithGitReadInvalidation(async () => - parseLocalBaseBranchFastForwardOutcome( - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - ) - ) - } - - /** Commits local is behind when the relay could fast-forward it; read-only. */ - async getLocalBaseRefFastForwardableBehind( - args: LocalBaseBranchRefs - ): Promise { - return readFastForwardableBehindCount( - await this.mux.request('git.inspectLocalBaseRefForWorktreeCreate', args) - ) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - // Why: git.exec blocks config writes outright, so the deferred fork-remote provenance - // marker (#17828) needs its own RPC. Non-essential to push/pull, so an older relay - // that hasn't shipped it yet degrades to no marker rather than failing materialization. - async markRemoteOrcaCreated(repoPath: string, remoteName: string): Promise { - try { - await this.mux.request('git.markRemoteOrcaCreated', { repoPath, remoteName }) - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedMarkRemoteOrcaCreatedFallback) { - this.loggedMarkRemoteOrcaCreatedFallback = true - console.warn( - "[ssh-git] Relay does not implement git.markRemoteOrcaCreated; this remote will lack a git-config provenance marker permanently (reconnecting does not retroactively add it -- only a newer relay deployment does, for remotes added after that). The store's remoteCreated flag remains the fallback ownership signal for cleanup." - ) - } - } - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } -} diff --git a/src/main/providers/ssh-host-not-served-error.ts b/src/main/providers/ssh-host-not-served-error.ts index b83e7ae3daf..e8b9bde6a79 100644 --- a/src/main/providers/ssh-host-not-served-error.ts +++ b/src/main/providers/ssh-host-not-served-error.ts @@ -5,6 +5,10 @@ export const SSH_HOST_NOT_SERVED_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' +/** The wording SSH review-head fetches have always used. */ +export const SSH_GIT_RECONNECT_MESSAGE = + 'SSH Git provider is not available. Reconnect to this target and try again.' + /** The wording project registration on an SSH target has always used. */ export function sshTargetNotConnectedMessage(connectionId: string): string { return `SSH connection "${connectionId}" not found or not connected` diff --git a/src/main/providers/ssh-worktree-catalog-authority.test.ts b/src/main/providers/ssh-worktree-catalog-authority.test.ts deleted file mode 100644 index 4ae6530e664..00000000000 --- a/src/main/providers/ssh-worktree-catalog-authority.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative - * empty catalog. Covers the whole client-side chain — provider response guard, the repo-level - * listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal - * teardown (`teardownMissingWorktreeTerminalsBestEffort`). - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability' -import { listRepoWorktrees } from '../repo-worktrees' -import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing' -import type { Repo } from '../../shared/repo-types' -import type { Store } from '../persistence/loading-store/store' - -const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() })) - -vi.mock('./ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - requireSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 1 -})) - -const CONNECTION_ID = 'conn-1' -const REPO_PATH = '/home/user/repo' -const WORKTREE_PATH = '/home/user/feature' - -const repo: Repo = { - id: 'repo-1', - path: REPO_PATH, - displayName: 'repo', - connectionId: CONNECTION_ID -} as Repo - -const worktreeId = `${repo.id}::${WORKTREE_PATH}` - -function createStore(): Store { - const meta: Record = { - [worktreeId]: { instanceId: 'instance-1' } - } - return { - getRepos: () => [repo], - getRepo: () => repo, - getAllWorktreeMeta: () => meta, - getWorktreeMeta: (id: string) => meta[id], - setWorktreeMeta: vi.fn(), - getAllWorktreeLineage: () => ({}), - getProjectHostSetups: () => [], - getSettings: () => ({}) - } as unknown as Store -} - -describe('SSH worktree catalog authority (#14004)', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider(CONNECTION_ID, mux as never) - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockReturnValue(provider) - }) - - it('refuses an empty relay response instead of publishing an empty catalog', async () => { - // An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state. - mux.request.mockResolvedValue([]) - - await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( - isWorktreeCatalogUnavailableError - ) - }) - - it('refuses a malformed relay response', async () => { - mux.request.mockResolvedValue(undefined) - - await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( - isWorktreeCatalogUnavailableError - ) - }) - - it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - - await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError) - }) - - it('does not authorize missing-worktree teardown for an SSH host this process does not serve', async () => { - const result = await listDetectedWorktreesForCapturedRepo(createStore(), repo, () => true) - - expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) - // The persisted workspace survives, so the renderer has nothing to reconcile away. - expect( - (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) - ).toContain(worktreeId) - }) -}) diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts index 67e84a70e3f..74a22730295 100644 --- a/src/main/repo-git-remote-identity.test.ts +++ b/src/main/repo-git-remote-identity.test.ts @@ -1,6 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' import { probeGitRemoteIdentity } from './repo-git-remote-identity' vi.mock('./git/runner', () => ({ gitExecFileAsync: vi.fn() })) @@ -12,25 +11,10 @@ const gitlabIdentity = { remoteUrl: 'git@gitlab.example.com:team/orca.git' } -const registered: string[] = [] - -function registerHost(connectionId: string, stdout = gitlabRemote) { - const exec = vi.fn().mockResolvedValue({ stdout, stderr: '' }) - registerSshGitProvider(connectionId, { exec } as never) - registered.push(connectionId) - return exec -} - beforeEach(() => { vi.clearAllMocks() }) -afterEach(() => { - for (const connectionId of registered.splice(0)) { - unregisterSshGitProvider(connectionId) - } -}) - describe('probeGitRemoteIdentity', () => { it('resolves the canonical identity for a non-GitHub remote', async () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) @@ -49,26 +33,20 @@ describe('probeGitRemoteIdentity', () => { }) }) - it('reports unavailable for an SSH host, even with a registered git provider', async () => { - const exec = registerHost('builder') - + it('reports unavailable for an SSH host', async () => { await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'unavailable' }) - expect(exec).not.toHaveBeenCalled() expect(gitExecFileAsync).not.toHaveBeenCalled() }) // A runtime host's Git is executed by that environment's own server, and the SSH target on its // repo row lives in that server's namespace. Dialing a same-named target here answers for // another machine's repository. - it('refuses a runtime host even when its nested SSH target is registered on this client', async () => { - const nested = registerHost('nested-1') - + it('refuses a runtime host', async () => { await expect(probeGitRemoteIdentity('/repos/orca', 'runtime:env-a')).resolves.toEqual({ status: 'unavailable' }) - expect(nested).not.toHaveBeenCalled() expect(gitExecFileAsync).not.toHaveBeenCalled() }) diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 008d6684615..6b8eaf30e7b 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -1,13 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './providers/ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './providers/types' import { detectRepoIcon, detectRepoIconAndUpstream } from './repo-icon-autodetect' const PNG_1X1_BASE64 = @@ -21,30 +16,7 @@ async function makeTempRepoDir(): Promise { return dir } -const registeredHosts: string[] = [] - -/** A remote host whose only readable file is a package.json naming a host-specific homepage. */ -function registerHomepageHost(connectionId: string, homepage: string) { - const stat = vi.fn(async (filePath: string) => { - if (!filePath.endsWith('/package.json')) { - throw new Error('ENOENT') - } - return { type: 'file', size: 64, mtime: 0 } - }) - const readFile = vi.fn(async () => ({ - content: JSON.stringify({ homepage }), - isBinary: false, - mimeType: 'application/json' - })) - registerSshFilesystemProvider(connectionId, { stat, readFile } as unknown as IFilesystemProvider) - registeredHosts.push(connectionId) - return { stat, readFile } -} - afterEach(async () => { - for (const connectionId of registeredHosts.splice(0)) { - unregisterSshFilesystemProvider(connectionId) - } await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -212,30 +184,16 @@ describe('detectRepoIcon', () => { ).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) }) - 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 it. - await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) - const host = registerHomepageHost('m4air', 'https://m4air.example.com') - - await expect( - detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:m4air' }) - ).resolves.toBeUndefined() - expect(host.stat).not.toHaveBeenCalled() - }) - - it('reads nothing for a runtime host even when its nested SSH target is registered here', async () => { + it('reads nothing for a runtime host', async () => { // Why: a `runtime:` repo row's `connectionId` names a target in that server's namespace. Both // spellings of the incumbent shape are wrong here — a null one reads this machine's copy of // the path, and the nested id dials a same-named box of ours. const repoPath = await makeTempRepoDir() await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) - const nested = registerHomepageHost('nested-1', 'https://nested.example.com') await expect( detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'runtime:env-a' }) ).resolves.toBeUndefined() - expect(nested.stat).not.toHaveBeenCalled() }) it('falls back to the GitHub owner avatar for GitHub repos', async () => { diff --git a/src/main/runtime/orca-runtime-files-mock-registry.ts b/src/main/runtime/orca-runtime-files-mock-registry.ts index d66f7caa800..276c16cf90b 100644 --- a/src/main/runtime/orca-runtime-files-mock-registry.ts +++ b/src/main/runtime/orca-runtime-files-mock-registry.ts @@ -41,7 +41,6 @@ export const wslAwareSpawnMock: Mock< export const watchMock: Mock< (rootPath: string, options: unknown, callback: WatchCallback) => unknown > = vi.fn() -export const getSshFilesystemProviderMock: Mock<(...args: unknown[]) => unknown> = vi.fn() export function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -103,22 +102,6 @@ export const localWorktreeRuntimeOptionsMock = { getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock } -const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - -export const sshFilesystemDispatchMock = { - getSshFilesystemProvider: getSshFilesystemProviderMock, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider - }, - onSshFilesystemProviderRegistered: () => () => undefined, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} - export function resetRuntimeFileMocks(): void { lstatMock.mockReset() openMock.mockReset() @@ -130,7 +113,6 @@ export function resetRuntimeFileMocks(): void { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() bundledRipgrepCommandMock.mockReset() - getSshFilesystemProviderMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts index f2266ae8526..015dee81d05 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts @@ -30,10 +30,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) /** * Replays the first stat seen for a path on every later open of it. That is what Linux hands the diff --git a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts index a6b729b465e..39e00dc46a3 100644 --- a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts +++ b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts @@ -19,7 +19,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { getRepoExecutionHostId, getRepoSshConnectionId, @@ -166,7 +165,7 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends ? projectRuntime.runtime.cacheKey : projectRuntime.repair.cacheKey : sshConnectionId - ? `ssh:${sshConnectionId}:${getSshGitProviderGeneration(sshConnectionId)}` + ? `ssh:${sshConnectionId}` : 'local:default' const now = Date.now() const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index afe0c752a10..fdc6bb95b89 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -14,7 +14,6 @@ import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerpr import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan' import { dropRepeatedWorktreeRows } from './repeated-worktree-rows' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -161,14 +160,10 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) ) } - const provider = getSshGitProvider(sshConnectionId) - if (!provider) { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } - } - try { - return { ok: true, worktrees: await provider.listWorktrees(repo.path) } - } catch { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } + // Why: this process serves no SSH git, so an SSH repo answers from its stored rows. + return { + ok: false, + worktrees: this.listStoredWorktreesForResolution(repo) } } diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index 1822cdf7e75..4f955b26e87 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -25,7 +25,7 @@ const { electronMocks, ensurePathWithinWorkspaceMock } = mocks const { findExistingWorktreeSymlinkPathsMock, forceDeleteLocalBranchMock } = mocks const { forgetLocalWatcherRemovalSnapshotMock, forgetRemoteWatcherRemovalSnapshotMock } = mocks const { describeCreatedWorktree } = mocks -const { getActiveMultiplexerMock, getDefaultTabsLaunch, getEffectiveHooks } = mocks +const { getDefaultTabsLaunch, getEffectiveHooks } = mocks const { getEffectiveHooksFromConfig, getGitHubPRCheckDetailsMock, getGitHubPRChecksMock } = mocks const { getGitHubPRCommentsMock, getGitHubPRFileContentsMock, getGitHubWorkItemByOwnerRepoMock } = mocks @@ -34,7 +34,7 @@ const { getGitLabProjectRefForRemoteMock, getGitLabWorkItemByProjectRefMock } = const { getGitLabWorkItemDetailsMock, getGlabKnownHostsMock } = mocks const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getIssueMock } = mocks const { getPRForBranchMock, getPRForBranchOutcomeMock, getPullRequestPushTargetMock } = mocks -const { getRepoSlugMock, getRepoUpstreamMock, getSshGitProviderMock, hasHooksFile } = mocks +const { getRepoSlugMock, getRepoUpstreamMock, hasHooksFile } = mocks const { installFakeAppEnvironment, invalidateAuthorizedRootsCacheMock } = mocks const { listGitHubAssignableUsersMock, listGitHubIssuesMock, listGitHubLabelsMock } = mocks const { listGitHubWorkItemsMock, listGitLabIssuesMock, listGitLabLabelsMock } = mocks @@ -47,9 +47,8 @@ const { } = mocks const { listWorktreesStrict, loadHooks, markCodexProjectTrustedMock } = mocks const { markCopilotFolderTrustedMock, markCursorWorkspaceTrustedMock, mergeGitHubPRMock } = mocks -const { mergeGitLabMRMock, muxRequestMock, parseOrcaYaml, prepareLocalWorktreeRootForRepoMock } = - mocks -const { registerSshGitProviderMock, removeGitHubPRReviewersMock, removeWorktree } = mocks +const { mergeGitLabMRMock, parseOrcaYaml, prepareLocalWorktreeRootForRepoMock } = mocks +const { removeGitHubPRReviewersMock, removeWorktree } = mocks const { removeWorktreeLinkedPathsMock, reopenGitLabMRMock, requestGitHubPRReviewersMock } = mocks const { rerunGitHubPRChecksMock, resetPlatform, resolveGitHubReviewThreadMock } = mocks const { resolveGitLabMRDiscussionMock, resolveLocalGitUsernameMock, resolveSetupRunnerShell } = @@ -60,8 +59,8 @@ const { retryGitLabJobMock, runHook, scanLocalRepoWorktreesForResolutionMock } = const { setGitHubPRAutoMergeMock, setGitHubPRFileViewedMock, setRuntimeBrowserCommandsFactory } = mocks const { setRuntimeBrowserUnavailableCause, setRuntimeDesktopSurface } = mocks -const { setRuntimeTerminalUnavailableCause, shouldRunSetupForCreate, sshGitProviders } = mocks -const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueMock } = mocks +const { setRuntimeTerminalUnavailableCause, shouldRunSetupForCreate } = mocks +const { updateGitHubIssueMock } = mocks const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks @@ -147,28 +146,7 @@ function resetRuntimeTestMocks(): void { resolveLocalGitUsernameMock.mockReset().mockResolvedValue('') vi.mocked(forceDeleteLocalBranchMock).mockReset() vi.mocked(forceDeleteLocalBranchMock).mockResolvedValue(undefined) - sshGitProviders.clear() - sshProviderGenerations.clear() - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockImplementation((connectionId: string) => - sshGitProviders.get(connectionId) - ) - registerSshGitProviderMock.mockReset() - registerSshGitProviderMock.mockImplementation((connectionId: string, provider: unknown) => { - sshGitProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - }) - unregisterSshGitProviderMock.mockReset() - unregisterSshGitProviderMock.mockImplementation((connectionId: string) => { - if (sshGitProviders.delete(connectionId)) { - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - } - }) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue(undefined) applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([]) - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(getEffectiveHooksFromConfig).mockReset() diff --git a/src/main/runtime/orca-runtime-test-mocks.spec.ts b/src/main/runtime/orca-runtime-test-mocks.spec.ts index a13942dd873..5238736627c 100644 --- a/src/main/runtime/orca-runtime-test-mocks.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks.spec.ts @@ -118,8 +118,6 @@ export const recentTerminalOutputIncludesPath = export const recentTerminalPathCandidatesIncludePath = importedValues.exportedRecentTerminalPathCandidatesIncludePath export const registerLocalPtyMemoryRow = importedValues.exportedRegisterLocalPtyMemoryRow -export const registerSshFilesystemProvider = importedValues.exportedRegisterSshFilesystemProvider -export const registerSshGitProvider = importedValues.exportedRegisterSshGitProvider export const removeWorktree = importedValues.exportedRemoveWorktree export const resolveSetupRunnerShell = importedValues.exportedResolveSetupRunnerShell export const resolveWorktreeScanCacheTtlMs = importedValues.exportedResolveWorktreeScanCacheTtlMs @@ -138,9 +136,6 @@ export const setWorktreeWatcherRemoval = importedValues.exportedSetWorktreeWatch export const shouldRunSetupForCreate = importedValues.exportedShouldRunSetupForCreate export const tmpdir = importedValues.exportedTmpdir export const unregisterLocalPtyMemoryRow = importedValues.exportedUnregisterLocalPtyMemoryRow -export const unregisterSshFilesystemProvider = - importedValues.exportedUnregisterSshFilesystemProvider -export const unregisterSshGitProvider = importedValues.exportedUnregisterSshGitProvider export const vi = importedValues.exportedVi export const win32 = importedValues.exportedWin32 export const worktreePathComparison = importedValues.exportedWorktreePathComparison @@ -179,15 +174,7 @@ export { computeWorktreePathMock, ensurePathWithinWorkspaceMock } from './orca-runtime-test-mocks/setup.spec' -export const sshGitProviders = setup.sshGitProviders -export const sshProviderGenerations = setup.sshProviderGenerations export { - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, diff --git a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts index 058fbbe4a6d..3acab50ffd4 100644 --- a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts @@ -93,11 +93,6 @@ import { } from '../../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../../providers/ssh-filesystem-dispatch' -import { registerSshGitProvider, unregisterSshGitProvider } from '../../providers/ssh-git-dispatch' import { registerPty as registerLocalPtyMemoryRow, unregisterPty as unregisterLocalPtyMemoryRow @@ -232,8 +227,6 @@ export const exportedRecentTerminalOutputIncludesPath = recentTerminalOutputIncl export const exportedRecentTerminalPathCandidatesIncludePath = recentTerminalPathCandidatesIncludePath export const exportedRegisterLocalPtyMemoryRow = registerLocalPtyMemoryRow -export const exportedRegisterSshFilesystemProvider = registerSshFilesystemProvider -export const exportedRegisterSshGitProvider = registerSshGitProvider export const exportedRemoveWorktree = removeWorktree export const exportedResolveSetupRunnerShell = resolveSetupRunnerShell export const exportedResolveWorktreeScanCacheTtlMs = resolveWorktreeScanCacheTtlMs @@ -249,8 +242,6 @@ export const exportedSetWorktreeWatcherRemoval = setWorktreeWatcherRemoval export const exportedShouldRunSetupForCreate = shouldRunSetupForCreate export const exportedTmpdir = tmpdir export const exportedUnregisterLocalPtyMemoryRow = unregisterLocalPtyMemoryRow -export const exportedUnregisterSshFilesystemProvider = unregisterSshFilesystemProvider -export const exportedUnregisterSshGitProvider = unregisterSshGitProvider export const exportedVi = vi export const exportedWin32 = win32 export const exportedWorktreePathComparison = worktreePathComparison 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 547006c56c0..08d28c87152 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -115,14 +115,6 @@ const { forceDeleteLocalBranchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, @@ -191,10 +183,6 @@ const { getIssueMock, deleteWorktreeHistoryDirMock } = vi.hoisted(() => { - // Why: SSH runtime tests register providers via the public dispatcher API, so the mock needs the same registry semantics as the real module. - const sshGitProviders = new Map() - const sshProviderGenerations = new Map() - return { MOCK_GIT_WORKTREES: [ { @@ -211,26 +199,6 @@ const { forceDeleteLocalBranchMock: vi.fn() as TestMock, computeWorktreePathMock: vi.fn() as TestMock, ensurePathWithinWorkspaceMock: vi.fn() as TestMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock: vi.fn((connectionId: string) => sshGitProviders.get(connectionId)), - getSshGitProviderGenerationMock: vi.fn( - (connectionId: string) => sshProviderGenerations.get(connectionId) ?? 0 - ), - registerSshGitProviderMock: vi.fn((connectionId: string, provider: unknown) => { - sshGitProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - }), - unregisterSshGitProviderMock: vi.fn((connectionId: string) => { - if (sshGitProviders.delete(connectionId)) { - sshProviderGenerations.set( - connectionId, - (sshProviderGenerations.get(connectionId) ?? 0) + 1 - ) - } - }), - getActiveMultiplexerMock: vi.fn() as TestMock, - muxRequestMock: vi.fn() as TestMock, invalidateAuthorizedRootsCacheMock: vi.fn() as TestMock, prepareLocalWorktreeRootForRepoMock: vi.fn() as TestMock, createHostedReviewMock: vi.fn() as TestMock, @@ -323,26 +291,7 @@ vi.mock('../../terminal-history-deletion', () => ({ deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock })) -vi.mock('../../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', - requireSshGitProvider: (connectionId: string) => { - const provider = getSshGitProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - }, - registerSshGitProvider: registerSshGitProviderMock, - unregisterSshGitProvider: unregisterSshGitProviderMock -})) - vi.mock('../../ssh/ssh-target-registry', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) @@ -593,14 +542,6 @@ export { forceDeleteLocalBranchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts index bd86392b0e9..73682b3e45d 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts @@ -19,7 +19,6 @@ import { getPRForBranchOutcomeMock, listWorktrees, mergeGitHubPRMock, - registerSshGitProvider, removeGitHubPRReviewersMock, requestGitHubPRReviewersMock, rerunGitHubPRChecksMock, @@ -27,12 +26,16 @@ import { setGitHubPRAutoMergeMock, setGitHubPRFileViewedMock, setPlatform, - unregisterSshGitProvider, updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeMeta, + store +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { @@ -583,29 +586,16 @@ describe('OrcaRuntimeService', () => { connectionId: 'ssh-1' } ], + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}::/remote/repo`]: makeWorktreeMeta() + }), getWorktreeMeta: () => null } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: true - } - ]) - } - registerSshGitProvider('ssh-1', gitProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() - } finally { - unregisterSshGitProvider('ssh-1') - } + await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() - expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') expect(getBaseRefDefault).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts index 3fe67dab719..64706d755e8 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts @@ -1,12 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import * as repoWorktreeAdminFingerprint from '../repo-worktree-admin-fingerprint' -import { - MOCK_GIT_WORKTREES, - OrcaRuntimeService, - listWorktrees, - registerSshGitProvider, - unregisterSshGitProvider -} from '../orca-runtime-test-mocks.spec' +import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' import type { Worktree, WorktreeLineage, WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, @@ -102,43 +96,6 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).toHaveBeenCalledTimes(1) }) - it('worktree scan cache: keeps colliding local and SSH owners warm independently', async () => { - vi.mocked(listWorktrees).mockClear() - const localRepo = store.getRepo(TEST_REPO_ID)! - const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/worktree')]) - } - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => [localRepo, remoteRepo] - } as never) - vi.mocked(listWorktrees).mockResolvedValue([makeWorktreeInfo(TEST_WORKTREE_PATH)]) - - try { - await runtime.listManagedWorktrees() - runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) - await runtime.listManagedWorktrees() - - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(provider.listWorktrees).toHaveBeenCalledTimes(1) - - unregisterSshGitProvider('ssh-1') - const replacementProvider = { - listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/replacement')]) - } - registerSshGitProvider('ssh-1', replacementProvider as never) - runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) - await runtime.listManagedWorktrees() - - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(replacementProvider.listWorktrees).toHaveBeenCalledTimes(1) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - it('keeps detected metadata scoped to the selected colliding host', async () => { const localRepo = store.getRepo(TEST_REPO_ID)! const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } @@ -157,25 +114,12 @@ describe('OrcaRuntimeService', () => { getAllWorktreeMeta: () => ({ [worktreeId]: localMeta }), getWorktreeMeta: (id: string) => (id === worktreeId ? localMeta : undefined) } - const provider = { listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo(worktreePath)]) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - try { - const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1') + // Why: the SSH host lists only its own stored rows, so the local row never surfaces there. + const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1') - expect(result.worktrees).toEqual([ - expect.objectContaining({ - id: worktreeId, - hostId: 'ssh:ssh-1', - comment: '', - isPinned: false - }) - ]) - expect(result.worktrees[0]?.displayName).not.toBe('local-only-name') - } finally { - unregisterSshGitProvider('ssh-1') - } + expect(result.worktrees).toEqual([]) }) it('does not project one colliding host lineage onto another host rows', async () => { @@ -209,26 +153,20 @@ describe('OrcaRuntimeService', () => { } const rows = [makeWorktreeInfo(childPath), makeWorktreeInfo(parentPath)] vi.mocked(listWorktrees).mockResolvedValue(rows) - const provider = { listWorktrees: vi.fn().mockResolvedValue(rows) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - try { - const resolved = await ( - runtime as unknown as { listResolvedWorktrees: () => Promise } - ).listResolvedWorktrees() - const localChild = resolved.find( - (worktree) => worktree.id === childId && worktree.hostId === 'local' - ) - const remoteChild = resolved.find( - (worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1' - ) + const resolved = await ( + runtime as unknown as { listResolvedWorktrees: () => Promise } + ).listResolvedWorktrees() + const localChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'local' + ) + const remoteChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1' + ) - expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null }) - expect(remoteChild).toMatchObject({ parentWorktreeId: parentId }) - } finally { - unregisterSshGitProvider('ssh-1') - } + expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null }) + expect(remoteChild).toMatchObject({ parentWorktreeId: parentId }) }) it('worktree scan cache: rescans immediately after worktree invalidation', async () => { @@ -370,58 +308,6 @@ describe('OrcaRuntimeService', () => { } }) - it('worktree scan cache: does not cache non-authoritative SSH scan failures', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn() } - provider.listWorktrees - .mockRejectedValueOnce(new Error('git unavailable')) - .mockResolvedValueOnce([makeWorktreeInfo(TEST_WORKTREE_PATH)]) - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: false }) - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: true }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - - it('worktree scan cache: invalidates when SSH availability changes', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES) } - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: true }) - unregisterSshGitProvider('ssh-1') - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: false }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(1) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - it('worktree scan cache: SSH state changes do not evict local repo scans', async () => { vi.mocked(listWorktrees).mockClear() const localRepo = { ...store.getRepo(TEST_REPO_ID)!, id: 'local-repo' } @@ -432,53 +318,20 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => repos.find((repo) => repo.id === id), getRepos: () => repos } - const provider = { listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await runtime.listDetectedManagedWorktrees('id:local-repo') - await runtime.listDetectedManagedWorktrees('id:remote-repo') - runtime.notifySshStateChanged('ssh-1', { - targetId: 'ssh-1', - status: 'disconnected', - error: null, - reconnectAttempt: 0 - }) - await runtime.listDetectedManagedWorktrees('id:local-repo') - await runtime.listDetectedManagedWorktrees('id:remote-repo') + await runtime.listDetectedManagedWorktrees('id:local-repo') + await runtime.listDetectedManagedWorktrees('id:remote-repo') + runtime.notifySshStateChanged('ssh-1', { + targetId: 'ssh-1', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + }) + await runtime.listDetectedManagedWorktrees('id:local-repo') + await runtime.listDetectedManagedWorktrees('id:remote-repo') - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - - it('worktree scan cache: SSH state changes invalidate empty resolved snapshots', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-empty' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } - registerSshGitProvider('ssh-empty', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect(runtime.listManagedWorktrees()).resolves.toMatchObject({ totalCount: 0 }) - runtime.notifySshStateChanged('ssh-empty', { - targetId: 'ssh-empty', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await expect(runtime.listManagedWorktrees()).resolves.toMatchObject({ totalCount: 0 }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-empty') - } + expect(listWorktrees).toHaveBeenCalledTimes(1) }) it('worktree scan cache: SSH state changes for unknown targets keep local snapshots in flight', async () => { diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts index 0eaf37a80d9..ff5c5518c44 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts @@ -1,16 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, - getSshGitProviderMock, join, listWorktrees, lstat, mkdir, mkdtemp, - registerSshFilesystemProvider, rm, - tmpdir, - unregisterSshFilesystemProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import type { WorkspaceLineage, @@ -63,71 +60,6 @@ describe('OrcaRuntimeService', () => { ).resolves.toEqual({ refs: [], refDetails: [], truncated: true }) }) - it('resolves SSH worktrees when manually updating lineage', async () => { - const remoteRepo = { - id: 'remote-repo', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` - const metaById: Record = { - [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), - [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) - } - const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo-child', - head: 'abc', - branch: 'feature/child', - isBare: false, - isMainWorktree: false - }, - { - path: '/home/user/repo-parent', - head: 'def', - branch: 'feature/parent', - isBare: false, - isMainWorktree: false - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) - const runtimeStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo], - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...metaById[worktreeId], ...meta } - return metaById[worktreeId] - }, - getWorktreeLineage: () => undefined, - setWorktreeLineage - } - const runtime = new OrcaRuntimeService(runtimeStore as never) - - await runtime.updateManagedWorktreeMeta(`id:${childId}`, { - lineage: { parentWorktree: `id:${parentId}` } - }) - - expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) - expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, - expect.objectContaining({ - worktreeId: childId, - worktreeInstanceId: 'child-instance', - parentWorktreeId: parentId, - parentWorktreeInstanceId: 'parent-instance', - origin: 'manual' - }) - ) - }) - it('resolves SSH lineage updates from stored metadata when the scan cache misses', async () => { const remoteRepo = { id: 'remote-repo', @@ -144,16 +76,6 @@ describe('OrcaRuntimeService', () => { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) const runtimeStore = { ...store, getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), @@ -199,16 +121,6 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) const runtimeStore = { ...store, getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), @@ -231,73 +143,6 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('selector_not_found') }) - it('rejects SSH lineage updates when Orca worktree identity is missing', async () => { - const remoteRepo = { - id: 'remote-repo', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` - const metaById: Record = {} - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) - const fsProvider = { - readFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/home/user/repo-child', - head: 'abc', - branch: 'feature/child', - isBare: false, - isMainWorktree: false - }, - { - path: '/home/user/repo-parent', - head: 'def', - branch: 'feature/parent', - isBare: false, - isMainWorktree: false - } - ]) - }) - registerSshFilesystemProvider('ssh-1', fsProvider as never) - const runtimeStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo], - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }, - getWorktreeLineage: () => undefined, - setWorktreeLineage - } - const runtime = new OrcaRuntimeService(runtimeStore as never) - - try { - await expect( - runtime.updateManagedWorktreeMeta(`id:${childId}`, { - lineage: { parentWorktree: `id:${parentId}` } - }) - ).rejects.toThrow('Worktree instance identity was unavailable') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.createDir).not.toHaveBeenCalled() - expect(fsProvider.writeFile).not.toHaveBeenCalled() - expect(setWorktreeLineage).not.toHaveBeenCalled() - }) - it('rejects local lineage updates when Orca worktree identity is missing', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-lineage-')) const repoPath = join(tempRoot, 'repo') diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index d28ca1027a8..31492db4702 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -3,7 +3,6 @@ import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fi import { OrcaRuntimeService, getDefaultWorkspaceSession, - registerSshGitProvider, setPlatform, win32, worktreePathComparison @@ -17,6 +16,16 @@ import { store } from '../orca-runtime-test-fixtures.spec' +// Why: this process serves no SSH git, so an SSH repo lists its stored rows. +function storedWorktreeMeta( + repoId: string, + worktrees: readonly { path: string }[] +): Record { + return Object.fromEntries( + worktrees.map((worktree) => [`${repoId}::${worktree.path}`, makeWorktreeMeta()]) + ) +} + describe('OrcaRuntimeService', () => { it('keeps an OSC row from an SSH pane after its PTY disconnects', async () => { // Why: OSC snapshots must carry the pane transport; hardcoding local would @@ -180,9 +189,6 @@ describe('OrcaRuntimeService', () => { getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], getWorkspaceSession: () => getDefaultWorkspaceSession() } - registerSshGitProvider('ssh-6072', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => [ @@ -253,9 +259,6 @@ describe('OrcaRuntimeService', () => { return metaById[worktreeId] } } - registerSshGitProvider('ssh-1', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { @@ -335,20 +338,17 @@ describe('OrcaRuntimeService', () => { path: slashPath, branch: 'refs/heads/slash' } + const storedMeta = storedWorktreeMeta( + remoteRepo.id, + includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree] + ) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-relative', { - listWorktrees: vi - .fn() - .mockResolvedValue( - includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree] - ) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { @@ -413,16 +413,14 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false } }) + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-pair-aware-scale', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -471,16 +469,14 @@ describe('OrcaRuntimeService', () => { } }) const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined)) + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo, - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-malformed-scale', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -522,16 +518,14 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } + const storedMeta = storedWorktreeMeta(remoteRepo.id, [remoteWorktree]) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-repeated-miss', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -586,16 +580,14 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false } ] + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-truncated', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => [ diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts index 67c0a5caeda..93c83631b73 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { OrcaRuntimeService, - registerSshGitProvider, setPlatform, worktreePathComparison } from '../orca-runtime-test-mocks.spec' @@ -27,23 +26,17 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false })) const pinnedPath = 'relative/zzz-pinned' - const pinnedWorktree = { - path: pinnedPath, - head: 'pinned', - branch: 'refs/heads/pinned', - isBare: false, - isMainWorktree: false - } const unreadPath = 'relative/zzz-unread' - const unreadWorktree = { - ...pinnedWorktree, - path: unreadPath, - head: 'unread', - branch: 'refs/heads/unread' - } const pinnedId = `${remoteRepo.id}::${pinnedPath}` const unreadId = `${remoteRepo.id}::${unreadPath}` + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. const metaById: Record = { + ...Object.fromEntries( + activeWorktrees.map((worktree) => [ + `${remoteRepo.id}::${worktree.path}`, + makeWorktreeMeta() + ]) + ), [pinnedId]: makeWorktreeMeta({ isPinned: true }), [unreadId]: makeWorktreeMeta({ isUnread: true }) } @@ -54,9 +47,6 @@ describe('OrcaRuntimeService', () => { getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] } - registerSshGitProvider('ssh-pinned-limit', { - listWorktrees: vi.fn().mockResolvedValue([...activeWorktrees, pinnedWorktree, unreadWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => diff --git a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts index dd80f786653..167db4e6f7a 100644 --- a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts +++ b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts @@ -2,9 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, OrcaRuntimeService, - electronMocks, - registerSshGitProvider, - unregisterSshGitProvider + electronMocks } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -178,7 +176,6 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null }) const listWorktrees = vi.fn(() => new Promise(() => {})) - registerSshGitProvider('ssh-target', { listWorktrees } as never) vi.useFakeTimers() try { @@ -207,7 +204,6 @@ describe('OrcaRuntimeService', () => { }) } finally { vi.useRealTimers() - unregisterSshGitProvider('ssh-target') } }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index d2b1c957cf9..794aafdd981 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -10,9 +10,7 @@ import { join, mkdtemp, randomUUID, - registerSshGitProvider, - tmpdir, - unregisterSshGitProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import { TEST_FOLDER_PROJECT_GROUP_ID, @@ -25,6 +23,7 @@ import { expectStablePaneKeyEnv, makeFolderProjectGroup, makeFolderWorkspace, + makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' @@ -65,6 +64,10 @@ describe('OrcaRuntimeService', () => { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}::C:/remote/repo`]: makeWorktreeMeta() + }), getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], @@ -74,20 +77,7 @@ describe('OrcaRuntimeService', () => { agentDefaultEnv: {} }) } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: 'C:/remote/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - } const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-windows-bare' }) - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(remoteStore as never) runtime.setPtyController({ spawn, @@ -96,21 +86,17 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null }) - try { - const terminal = await runtime.createTerminal('path:C:/remote/repo', { - command: 'claude', - title: 'worker' - }) + const terminal = await runtime.createTerminal('path:C:/remote/repo', { + command: 'claude', + title: 'worker' + }) - const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined - expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") - expect(terminal).toMatchObject({ - executionHostId: 'ssh:ssh-1', - hostPlatform: 'linux' - }) - } finally { - unregisterSshGitProvider('ssh-1') - } + const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined + expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") + expect(terminal).toMatchObject({ + executionHostId: 'ssh:ssh-1', + hostPlatform: 'linux' + }) }) it('matches canonical bare agent commands when a command override is configured', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 01e6e8f5826..b13db8acaa3 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -5,9 +5,7 @@ import { getDefaultWorkspaceSession, join, mkdtemp, - registerSshFilesystemProvider, - tmpdir, - unregisterSshFilesystemProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' @@ -279,7 +277,7 @@ describe('OrcaRuntimeService', () => { }) }) - it('adopts an SSH folder legacy worker through its SSH workspace-session partition', async () => { + it('defers an SSH folder legacy worker while its folder path is unavailable', async () => { const connectionId = 'ssh-folder' const ptyId = `ssh:${connectionId}@@pty-folder-legacy` const workerPaneKey = `legacy-ssh-folder-worker:${HEADLESS_LEAF_ID}` @@ -374,52 +372,21 @@ describe('OrcaRuntimeService', () => { }) ) runtime.setNotifier({ revealTerminalSession } as never) - registerSshFilesystemProvider(connectionId, { - stat: vi.fn(async () => ({ size: 0, type: 'directory', mtime: 1 })) - } as never) - try { - expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId, - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: ['dispatch-ssh-folder'], - exitedDispatchIds: [], - deferredDispatchIds: [] + expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + // Why: this process serves no SSH filesystem, so the folder path stays unavailable. + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId, + materializeRenderer: true }) - } finally { - unregisterSshFilesystemProvider(connectionId) - } - - expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) - expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`) - expect(listProcesses).toHaveBeenCalledTimes(3) - expect(listProcesses).toHaveBeenCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) - expect(sshSession.tabsByWorktree[TEST_FOLDER_WORKSPACE_KEY]).toContainEqual( - expect.objectContaining({ - id: 'legacy-ssh-folder-worker', - ptyId, - worktreeId: TEST_FOLDER_WORKSPACE_KEY - }) - ) - expect(revealTerminalSession).toHaveBeenCalledWith(TEST_FOLDER_WORKSPACE_KEY, { - ptyId, - title: 'SSH folder worker', - activate: false, - presentation: 'background', - tabId: 'legacy-ssh-folder-worker', - leafId: HEADLESS_LEAF_ID, - focus: false, - expectedProcessIdentity: { - terminalHandle: 'term_ssh_folder', - incarnationId - } + ).resolves.toMatchObject({ + adoptedDispatchIds: [], + exitedDispatchIds: [], + deferredDispatchIds: ['dispatch-ssh-folder'] }) + expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() + expect(revealTerminalSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 8559bbe0d74..804c3fbc75b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -3,9 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession, - registerSshGitProvider, - setPlatform, - unregisterSshGitProvider + setPlatform } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' @@ -130,47 +128,32 @@ describe('OrcaRuntimeService', () => { }) ) runtime.setNotifier({ revealTerminalSession } as never) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn(async () => [ - { - path: TEST_WORKTREE_PATH, - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: false - } - ]) - } as never) - try { - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId: 'ssh-wrong-host', - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: [], - exitedDispatchIds: [], - deferredDispatchIds: ['dispatch-ssh'] + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId: 'ssh-wrong-host', + materializeRenderer: true }) - expect(listProcesses).not.toHaveBeenCalled() - expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() + ).resolves.toMatchObject({ + adoptedDispatchIds: [], + exitedDispatchIds: [], + deferredDispatchIds: ['dispatch-ssh'] + }) + expect(listProcesses).not.toHaveBeenCalled() + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId, - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: ['dispatch-ssh'], - exitedDispatchIds: [], - deferredDispatchIds: [] + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId, + materializeRenderer: true }) - expect(listProcesses).toHaveBeenLastCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) - } finally { - unregisterSshGitProvider(connectionId) - } + ).resolves.toMatchObject({ + adoptedDispatchIds: ['dispatch-ssh'], + exitedDispatchIds: [], + deferredDispatchIds: [] + }) + expect(listProcesses).toHaveBeenLastCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) diff --git a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts index 981e9860463..05906574448 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts @@ -1,10 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - OrcaRuntimeService, - addWorktree, - registerSshGitProvider, - unregisterSshGitProvider -} from '../orca-runtime-test-mocks.spec' +import { OrcaRuntimeService, addWorktree } from '../orca-runtime-test-mocks.spec' import { store } from '../orca-runtime-test-fixtures.spec' const RUNTIME_REPO_PATH = '/remote/repo' @@ -38,24 +33,17 @@ describe('OrcaRuntimeService worktree create execution host', () => { expect(addWorktree).not.toHaveBeenCalled() }) - it('refuses a runtime-hosted repo whose nested SSH target is dialable in this namespace', async () => { - // `target-a` names a target inside env-1. The same-named one registered here is another - // machine, so creating through it would put the checkout on the wrong host. - const provider = { exec: vi.fn(), addWorktree: vi.fn(), listWorktrees: vi.fn() } - registerSshGitProvider('target-a', provider as never) + it('refuses a runtime-hosted repo that names a nested SSH target', async () => { + // `target-a` names a target inside env-1, so creating through it here would put the checkout + // on the wrong host. const runtime = new OrcaRuntimeService( makeRuntimeHostedStore({ connectionId: 'target-a' }) as never ) - try { - await expect( - runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') - expect(provider.addWorktree).not.toHaveBeenCalled() - expect(addWorktree).not.toHaveBeenCalled() - } finally { - unregisterSshGitProvider('target-a') - } + expect(addWorktree).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts index edef0e391c8..2b396c8afa7 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts @@ -10,16 +10,11 @@ import { addWorktree, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - getSshGitProviderMock, join, listWorktrees, mkdir, mkdtemp, - registerSshFilesystemProvider, - registerSshGitProvider, tmpdir, - unregisterSshFilesystemProvider, - unregisterSshGitProvider, writeFile } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' @@ -109,50 +104,26 @@ describe('OrcaRuntimeService', () => { badgeColor: 'blue', addedAt: 1, connectionId: 'ssh-1' - }) + }), + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}:://Server/Share/Repo`]: makeWorktreeMeta() + }), + getWorktreeMeta: () => makeWorktreeMeta() } - const fsProvider = { readDir: vi.fn().mockResolvedValue([]) } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '//Server/Share/Repo', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn().mockResolvedValue({ - branch: 'feature/foo', - files: [], - ahead: 0, - behind: 0, - hasConflicts: false - }) - } - registerSshFilesystemProvider('ssh-1', fsProvider as never) - registerSshGitProvider('ssh-1', gitProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow( - SshHostNotServedError - ) - await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow( - SshHostNotServedError - ) - await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ - path: '//Server/Share/Repo' - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - unregisterSshGitProvider('ssh-1') - } + await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow( + SshHostNotServedError + ) + await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow( + SshHostNotServedError + ) + await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ + path: '//Server/Share/Repo' + }) expect(listWorktrees).not.toHaveBeenCalled() - expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') - expect(fsProvider.readDir).not.toHaveBeenCalled() - expect(gitProvider.getStatus).not.toHaveBeenCalled() }) it.each([ @@ -181,44 +152,25 @@ describe('OrcaRuntimeService', () => { it('refuses SSH folder workspace file explorer reads instead of reading this machine', async () => { const folderPath = '/srv/platform' - const fsProvider = { - stat: vi.fn(async (pathValue: string) => ({ - size: pathValue.endsWith('/app.ts') ? 8 : 0, - type: pathValue.endsWith('/app.ts') ? 'file' : 'directory', - mtime: 1 - })), - readDir: vi.fn().mockResolvedValue([ - { - name: 'app.ts', - isDirectory: false, - isSymlink: false - } - ]), - readFile: vi.fn().mockResolvedValue({ content: 'remote\n', isBinary: false }) - } - const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId: 'ssh-folder' }) + const folderWorkspace = makeFolderWorkspace({ + folderPath, + connectionId: 'ssh-folder' + }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never ) - registerSshFilesystemProvider('ssh-folder', fsProvider as never) - try { - await expect( - runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') - ).rejects.toThrow(SshHostNotServedError) - await expect( - runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') - ).rejects.toThrow(SshHostNotServedError) - } finally { - unregisterSshFilesystemProvider('ssh-folder') - } - - expect(fsProvider.readDir).not.toHaveBeenCalled() - expect(fsProvider.readFile).not.toHaveBeenCalled() + // Why: this process serves no SSH filesystem, so the folder path itself is unavailable. + await expect( + runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') + ).rejects.toThrow('folder_workspace_path_unavailable:/srv/platform') + await expect( + runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') + ).rejects.toThrow('folder_workspace_path_unavailable:/srv/platform') }) - it('lists persisted SSH worktrees while the git provider is unavailable', async () => { + it('lists persisted SSH worktrees from stored rows', async () => { vi.mocked(listWorktrees).mockClear() const remoteRepo = { id: 'remote-repo', @@ -250,7 +202,6 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listManagedWorktrees('id:remote-repo') expect(listWorktrees).not.toHaveBeenCalled() - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-missing') expect(listed).toMatchObject({ totalCount: 2, truncated: false, @@ -304,7 +255,7 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() }) - it('guides registered SSH repo ids without probing the provider', async () => { + it('guides registered SSH repo ids to the full id shape', async () => { const remoteRepo = { ...store.getRepos()[0], connectionId: 'ssh-1' } const remoteStore = { ...store, @@ -312,14 +263,12 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) } vi.mocked(listWorktrees).mockClear() - getSshGitProviderMock.mockClear() const runtime = new OrcaRuntimeService(remoteStore) await expect(runtime.showManagedWorktree(`id:${TEST_REPO_ID}`)).rejects.toMatchObject({ code: 'worktree_id_requires_full_path' }) expect(listWorktrees).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('rejects bare repo ids through terminal list RPC with the structured code', async () => { diff --git a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts b/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts index a126dbeb112..05701907f64 100644 --- a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts +++ b/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts @@ -8,7 +8,6 @@ import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' @@ -102,11 +101,10 @@ export class OrcaRuntimeWithTransitionGraphReloadToTerminalState extends OrcaRun if (!this.store) { throw new Error('runtime_unavailable') } - const status = await getFolderWorkspacePathStatus( - this.store, - { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatus(this.store, { + scope: 'folder-workspace', + folderWorkspaceId: workspace.id + }) assertFolderWorkspacePathUsable(status) return { id: folderWorkspaceKey(workspace.id), diff --git a/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts b/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts index 85fb35f7bd0..50115ecb99d 100644 --- a/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts +++ b/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts @@ -20,13 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn(), - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: vi.fn() -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts index d95139045a7..85313d07b00 100644 --- a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts +++ b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts @@ -21,22 +21,11 @@ vi.mock('electron', () => electronMocks) const scanLocalRepoWorktreesForResolution = vi.hoisted(() => vi.fn()) vi.mock('./repo-worktree-resolution-scan', () => ({ scanLocalRepoWorktreesForResolution })) -const getSshGitProvider = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider, - getSshGitProviderGeneration: vi.fn(() => 0), - requireSshGitProvider: (connectionId: string) => getSshGitProvider(connectionId) -})) - import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { OrcaRuntimeService } from './orca-runtime' const REPO_ID = 'repo-1' @@ -124,7 +113,6 @@ describe('orchestration worker workspace resolution', () => { } ] }) - getSshGitProvider.mockReset() }) afterEach(async () => { @@ -208,17 +196,6 @@ describe('orchestration worker workspace resolution', () => { addedAt: 1, connectionId: 'ssh-1' } satisfies Repo - getSshGitProvider.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: WORKTREE_PATH, - head: 'remote', - branch: 'remote-feature', - isBare: false, - isMainWorktree: false - } - ]) - }) const runtime = new OrcaRuntimeService( makeStore({ repos: [makeStore().getRepos()[0], remoteRepo], @@ -234,7 +211,7 @@ describe('orchestration worker workspace resolution', () => { ) }) - it('resolves local and SSH folder workspaces without a Git catalog scan', async () => { + it('resolves a local folder workspace and refuses an SSH one without a Git catalog scan', async () => { const localPath = await mkdtemp(join(tmpdir(), 'orca-worker-local-folder-')) tempPaths.push(localPath) const group = { id: 'group-1', name: 'Group', parentPath: localPath } as ProjectGroup @@ -251,30 +228,24 @@ describe('orchestration worker workspace resolution', () => { folderPath: '/srv/app', connectionId: 'ssh-folder' } - registerSshFilesystemProvider('ssh-folder', { - stat: vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 1 }) - } as never) - try { - const runtime = new OrcaRuntimeService( - makeStore({ - folderWorkspaces: [localFolder, remoteFolder], - projectGroups: [group] - }) as never - ) + const runtime = new OrcaRuntimeService( + makeStore({ + folderWorkspaces: [localFolder, remoteFolder], + projectGroups: [group] + }) as never + ) - await expect( - runtime.showManagedTerminalWorkspace('folder:local-folder') - ).resolves.toMatchObject({ - id: 'folder:local-folder', - path: localPath, - hostId: 'local' - }) - await expect( - runtime.showManagedTerminalWorkspace('id:folder:remote-folder') - ).resolves.toMatchObject({ id: 'folder:remote-folder', hostId: 'ssh:ssh-folder' }) - } finally { - unregisterSshFilesystemProvider('ssh-folder') - } + await expect( + runtime.showManagedTerminalWorkspace('folder:local-folder') + ).resolves.toMatchObject({ + id: 'folder:local-folder', + path: localPath, + hostId: 'local' + }) + // Why: this process serves no SSH filesystem, so the SSH folder path is unavailable. + await expect(runtime.showManagedTerminalWorkspace('id:folder:remote-folder')).rejects.toThrow( + 'folder_workspace_path_unavailable:/srv/app' + ) expect(scanLocalRepoWorktreesForResolution).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orchestration/worker-output-archive.test.ts b/src/main/runtime/orchestration/worker-output-archive.test.ts index d5a9d5da73a..37919576ef7 100644 --- a/src/main/runtime/orchestration/worker-output-archive.test.ts +++ b/src/main/runtime/orchestration/worker-output-archive.test.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' -import * as sshFilesystemDispatch from '../../providers/ssh-filesystem-dispatch' import * as workerTranscriptRead from './worker-transcript-read' import { captureWorkerOutputArchive, summarizeWorkerOutputArchive } from './worker-output-archive' @@ -34,18 +33,15 @@ function codexMessage(id: string, text: string): string { describe('worker output archive WSL routing', () => { let directory: string let transcriptPath: string - let sshProviderLookup: { mockRestore: () => void } let transcriptReadSpy: { mockRestore: () => void } | undefined beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-worker-archive-')) transcriptPath = join(directory, 'session.jsonl') await writeFile(transcriptPath, `${codexMessage('wsl', 'WSL archive output')}\n`) - sshProviderLookup = vi.spyOn(sshFilesystemDispatch, 'getSshFilesystemProvider') }) afterEach(async () => { - sshProviderLookup.mockRestore() transcriptReadSpy?.mockRestore() await rm(directory, { recursive: true, force: true }) }) @@ -101,8 +97,7 @@ describe('worker output archive WSL routing', () => { sessionId: 'wsl-session', transcriptPath: guestTranscriptPath, wslDistro: 'Ubuntu', - limit: expect.any(Number), - filesystemProvider: undefined + limit: expect.any(Number) }) expect(result).toMatchObject({ kind: 'transcript_pin', @@ -111,11 +106,9 @@ describe('worker output archive WSL routing', () => { messages: [{ id: 'wsl', blocks: [{ type: 'text', text: 'WSL archive output' }] }] } }) - expect(sshProviderLookup).not.toHaveBeenCalled() }) - it('does not resolve an SSH transcript locally when its provider is unavailable', async () => { - vi.mocked(sshFilesystemDispatch.getSshFilesystemProvider).mockReturnValue(undefined) + it('does not resolve an SSH transcript locally', async () => { transcriptReadSpy = vi.spyOn(workerTranscriptRead, 'readWorkerTranscript') const runtime = { getExactWorkerProviderSession: vi.fn(() => ({ @@ -144,7 +137,6 @@ describe('worker output archive WSL routing', () => { attachedAtMs: Date.now() - 1 }) - expect(sshFilesystemDispatch.getSshFilesystemProvider).toHaveBeenCalledWith('ssh:remote-host') expect(workerTranscriptRead.readWorkerTranscript).not.toHaveBeenCalled() expect(result).toMatchObject({ kind: 'terminal_tail', diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index ddae515a7f2..5aec3ab0887 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -12,7 +12,6 @@ import { redactWorkerTerminalLines } from './worker-transcript-payload' import { readWorkerTranscript } from './worker-transcript-read' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import { captureStructuredWorkerArchive } from '../rpc/methods/orchestration-structured-worker-lifecycle' import type { WorkerStructuredJournalArchive } from './structured-worker-journal-archive' @@ -106,11 +105,9 @@ export async function captureWorkerOutputArchive(args: { if (session) { transcriptFallbackReason = 'transcript_unreadable' const isWslSession = isWslHookRelayConnectionId(session.connectionId) - const remoteConnectionId = session.connectionId && !isWslSession ? session.connectionId : null - const remoteFilesystemProvider = remoteConnectionId - ? getSshFilesystemProvider(remoteConnectionId) - : undefined - if ((isWslSession && !session.wslDistro) || (remoteConnectionId && !remoteFilesystemProvider)) { + // Why: this process serves no SSH filesystem, so an SSH worker's transcript is unreachable. + const isSshSession = Boolean(session.connectionId) && !isWslSession + if ((isWslSession && !session.wslDistro) || isSshSession) { transcriptFallbackReason = 'remote_capability_unavailable' } else { const snapshot = await readWorkerTranscript({ @@ -118,8 +115,7 @@ export async function captureWorkerOutputArchive(args: { sessionId: session.providerSession.id, transcriptPath: session.providerSession.transcriptPath, wslDistro: session.wslDistro, - limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT, - filesystemProvider: remoteFilesystemProvider + limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT }).catch(() => null) if (snapshot?.ok && snapshot.messages.length > 0) { return { diff --git a/src/main/runtime/orchestration/worker-transcript-local-read.ts b/src/main/runtime/orchestration/worker-transcript-local-read.ts index 50bdc1a924f..3ce5385e367 100644 --- a/src/main/runtime/orchestration/worker-transcript-local-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-local-read.ts @@ -6,7 +6,6 @@ import { type NativeChatLineDecoder } from '../../native-chat/transcript-tail-reader' import { transcriptFallbackId } from '../../native-chat/transcript-fallback-id' -import { MAX_REMOTE_TRANSCRIPT_SCAN_BYTES } from './worker-transcript-remote-read' import { localTranscriptOffsetStartsInsideRecord, readLocalTranscriptHandleBoundaryCheckpoint, @@ -19,6 +18,8 @@ import { type WorkerTranscriptSourceIdentity } from './worker-transcript-source-identity' +const MAX_TRANSCRIPT_SCAN_BYTES = 8 * 1024 * 1024 + type LocalTranscriptReadSuccess = { ok: true filePath: string @@ -87,7 +88,7 @@ export async function readForwardLocalWorkerTranscriptPage( if (startOffset > fileSize) { return sourceChanged() } - const scanEnd = Math.min(fileSize, startOffset + MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) + const scanEnd = Math.min(fileSize, startOffset + MAX_TRANSCRIPT_SCAN_BYTES) const handle = await open(filePath, 'r') const opened = localWorkerTranscriptSourceIdentity(await handle.stat({ bigint: true })) if (!opened || opened.fingerprint !== sourceIdentity.fingerprint || opened.size < scanEnd) { diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts index cb6f0467c4a..3272281bad8 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -3,7 +3,6 @@ import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-ag import type { OrchestrationWorkerReadFallbackReason } from '../../../shared/orchestration-worker-output' import { resolveSessionFilePath } from '../../native-chat/session-file-resolver' import { nativeChatLineDecoderForAgent } from '../../native-chat/transcript-tail-reader' -import type { IFilesystemProvider } from '../../providers/types' import { boundWorkerTranscriptMessages, clampWorkerTranscriptLimit @@ -12,7 +11,6 @@ import { readForwardLocalWorkerTranscriptPage, readInitialLocalWorkerTranscriptPage } from './worker-transcript-local-read' -import { readRemoteWorkerTranscript } from './worker-transcript-remote-read' type WorkerTranscriptReadFailure = { ok: false @@ -46,8 +44,6 @@ export async function readWorkerTranscript(args: { expectedSourceFingerprint?: string /** Hash of the bounded content immediately before a cursor offset. */ expectedBoundaryCheckpoint?: string - /** Remote execution-host provider. When present no local filesystem lookup occurs. */ - filesystemProvider?: IFilesystemProvider }): Promise { const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) if (!transcriptAgent) { @@ -58,24 +54,6 @@ export async function readWorkerTranscript(args: { return { ok: false, reason: 'provider_unsupported', warnings: [] } } let filePath: string | null - if (args.filesystemProvider) { - // A remote provider can only read the hook-attested path. Never search the - // desktop's provider roots for a remote session (same-path sentinels are a - // real authority boundary, not merely a portability concern). - filePath = args.transcriptPath?.trim() || null - if (!filePath) { - return { ok: false, reason: 'transcript_missing', warnings: [] } - } - const page = await readRemoteWorkerTranscript(args, filePath, decode) - if ( - page.ok && - args.expectedSourceFingerprint && - page.sourceFingerprint !== args.expectedSourceFingerprint - ) { - return { ok: false, reason: 'source_changed', warnings: [] } - } - return page - } try { filePath = await resolveSessionFilePath(args.agent, args.sessionId, { transcriptPath: args.transcriptPath, diff --git a/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts b/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts deleted file mode 100644 index 1403cb2c010..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { MAX_FILE_RANGE_READ_BYTES } from '../../../shared/file-range-read' -import type { IFilesystemProvider } from '../../providers/types' -import { - createWorkerTranscriptBoundaryCheckpoint, - remoteWorkerTranscriptSourceIdentity, - workerTranscriptBoundaryCheckpointStart, - workerTranscriptSourceChanged, - type WorkerTranscriptSourceIdentity -} from './worker-transcript-source-identity' - -export type RemoteTranscriptWindow = { - bytes: Buffer - fileSize: number - startOffset: number - scanEnd: number - startsInsideRecord: boolean - boundaryPrefix: Buffer - sourceIdentity: WorkerTranscriptSourceIdentity -} - -export async function supportsRemoteTranscriptRangeRead( - provider: IFilesystemProvider -): Promise { - if (!provider.readFileRange) { - return false - } - return provider.supportsFileRangeRead ? provider.supportsFileRangeRead() : true -} - -export async function readRemoteTranscriptBoundaryBytes( - provider: IFilesystemProvider, - filePath: string, - offset: number -): Promise { - const start = workerTranscriptBoundaryCheckpointStart(offset) - const expectedBytes = offset - start - const bytes = await readRemoteTranscriptRange(provider, filePath, start, expectedBytes) - return bytes.length === expectedBytes ? bytes : null -} - -export async function readRemoteTranscriptRangedWindow(args: { - provider: IFilesystemProvider - filePath: string - requestedOffset?: number - expectedBoundaryCheckpoint?: string - maxScanBytes: number -}): Promise { - const remoteStat = await args.provider.stat(args.filePath) - const sourceIdentity = remoteWorkerTranscriptSourceIdentity(remoteStat) - if (!sourceIdentity) { - throw new Error('Remote transcript host did not provide stable file identity') - } - const fileSize = remoteStat.size - const startOffset = args.requestedOffset ?? Math.max(0, fileSize - args.maxScanBytes) - if (startOffset > fileSize) { - return null - } - const scanEnd = - args.requestedOffset === undefined - ? fileSize - : Math.min(fileSize, startOffset + args.maxScanBytes) - const boundaryPrefix = await readRemoteTranscriptBoundaryBytes( - args.provider, - args.filePath, - startOffset - ) - if (!boundaryPrefix) { - return null - } - const boundaryCheckpoint = createWorkerTranscriptBoundaryCheckpoint(boundaryPrefix) - if ( - args.expectedBoundaryCheckpoint !== undefined && - boundaryCheckpoint !== args.expectedBoundaryCheckpoint - ) { - return null - } - const startsInsideRecord = boundaryPrefix.length > 0 && boundaryPrefix.at(-1) !== 0x0a - const bytes = await readRemoteTranscriptRange( - args.provider, - args.filePath, - startOffset, - scanEnd - startOffset - ) - if (bytes.length !== scanEnd - startOffset) { - return null - } - const boundaryAfter = await readRemoteTranscriptBoundaryBytes( - args.provider, - args.filePath, - startOffset - ) - const after = remoteWorkerTranscriptSourceIdentity(await args.provider.stat(args.filePath)) - if ( - !boundaryAfter || - createWorkerTranscriptBoundaryCheckpoint(boundaryAfter) !== boundaryCheckpoint || - workerTranscriptSourceChanged(sourceIdentity, after, scanEnd) - ) { - return null - } - return { - bytes, - fileSize, - startOffset, - scanEnd, - startsInsideRecord, - boundaryPrefix, - sourceIdentity - } -} - -export async function readRemoteTranscriptRange( - provider: IFilesystemProvider, - filePath: string, - position: number, - length: number -): Promise { - const windows: Buffer[] = [] - let bytesRead = 0 - while (bytesRead < length) { - const windowLength = Math.min(MAX_FILE_RANGE_READ_BYTES, length - bytesRead) - const window = await provider.readFileRange!(filePath, position + bytesRead, windowLength) - windows.push(window.bytes) - bytesRead += window.bytesRead - if (window.bytesRead < windowLength) { - break - } - } - return Buffer.concat(windows, bytesRead) -} diff --git a/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts b/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts deleted file mode 100644 index 595ec07b6cc..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { MAX_FILE_RANGE_READ_BYTES } from '../../../shared/file-range-read' -import type { IFilesystemProvider } from '../../providers/types' -import { sshFileStreamReadCap } from '../../ssh/ssh-file-stream-read-cap' -import { readWorkerTranscript } from './worker-transcript-read' -import { MAX_REMOTE_TRANSCRIPT_SCAN_BYTES } from './worker-transcript-remote-read' - -function codexMessage(id: string, text: string): Buffer { - return Buffer.from( - `${JSON.stringify({ - type: 'event_msg', - payload: { id, type: 'agent_message', message: text } - })}\n` - ) -} - -function fileStat(readContents: () => Buffer, readIdentity: () => number = () => 1) { - return { - size: readContents().length, - type: 'file' as const, - mtime: 0, - mtimeMs: 0, - dev: 7, - ino: readIdentity() - } -} - -function rangedProvider( - readContents: () => Buffer, - readIdentity?: () => number -): { - provider: IFilesystemProvider - readFile: ReturnType - readFileRange: ReturnType -} { - const readFile = vi.fn(async () => { - throw new Error('Whole-file reads must not serve a ranged transcript') - }) - const readFileRange = vi.fn(async (_path: string, position: number, length: number) => { - const bytes = readContents().subarray(position, position + length) - return { bytes, bytesRead: bytes.length } - }) - return { - provider: { - readFile, - readFileRange, - supportsFileRangeRead: vi.fn(async () => true), - stat: vi.fn(async () => fileStat(readContents, readIdentity)) - } as unknown as IFilesystemProvider, - readFile, - readFileRange - } -} - -function preRangeProvider( - readContents: () => Buffer, - readIdentity?: () => number -): IFilesystemProvider { - return { - readFile: vi.fn(async () => ({ content: readContents().toString('utf8'), isBinary: false })), - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(readContents, readIdentity)) - } as unknown as IFilesystemProvider -} - -describe('remote worker transcript reads', () => { - it('reports a missing attested path separately from remote capability loss', async () => { - const result = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'missing-path-session', - filesystemProvider: preRangeProvider(() => Buffer.from('')) - }) - - expect(result).toEqual({ ok: false, reason: 'transcript_missing', warnings: [] }) - }) - - it.each([ - ['ranged', (readContents: () => Buffer) => rangedProvider(readContents).provider], - ['pre-range', preRangeProvider] - ])( - 'holds a split EOF record at its start and emits it once after append on a %s host', - async (_providerKind, createProvider) => { - const first = codexMessage('first', 'complete before split') - const splitRecord = codexMessage('split', 'completed by second append') - const splitAt = Math.floor(splitRecord.length / 2) - let contents = Buffer.concat([first, splitRecord.subarray(0, splitAt)]) - const provider = createProvider(() => contents) - const transcriptPath = '/remote/split-append.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - limit: 10 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [{ id: 'first', blocks: [{ type: 'text', text: 'complete before split' }] }], - nextOffset: first.length, - limited: false, - warnings: [] - }) - if (!initial.ok) { - throw new Error('Expected an initial split transcript page') - } - - contents = Buffer.concat([contents, splitRecord.subarray(splitAt)]) - const completed = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 10 - }) - expect(completed).toMatchObject({ - ok: true, - messages: [{ id: 'split', blocks: [{ type: 'text', text: 'completed by second append' }] }], - nextOffset: contents.length, - limited: false - }) - if (!completed.ok) { - throw new Error('Expected the completed split transcript page') - } - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - offset: completed.nextOffset, - expectedSourceFingerprint: completed.sourceFingerprint, - expectedBoundaryCheckpoint: completed.boundaryCheckpoint, - limit: 10 - }) - ).resolves.toMatchObject({ ok: true, messages: [], nextOffset: contents.length }) - } - ) - - it('returns and redacts the newest bounded page from an append-only transcript over 8 MiB', async () => { - const capability = `dcap_${'A'.repeat(43)}` - let contents = Buffer.concat([ - Buffer.alloc(MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128, 0x78), - Buffer.from('\n'), - codexMessage('latest', `newest output ${capability}`) - ]) - const { provider, readFile, readFileRange } = rangedProvider(() => contents) - const transcriptPath = '/remote/home/ada/.codex/sessions/rollout.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'remote-session', - transcriptPath, - filesystemProvider: provider, - limit: 2 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [ - { - id: 'latest', - blocks: [{ type: 'text', text: 'newest output [dispatch capability redacted]' }] - } - ], - nextOffset: contents.length, - limited: true, - warnings: expect.arrayContaining([ - 'Dispatch capability tokens were redacted from transcript output.', - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ]) - }) - expect(readFile).not.toHaveBeenCalled() - expect(readFileRange.mock.calls.every((call) => call[2] <= MAX_FILE_RANGE_READ_BYTES)).toBe( - true - ) - expect(readFileRange.mock.calls.reduce((sum, call) => sum + call[2], 0)).toBeLessThanOrEqual( - MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128 - ) - expect(JSON.stringify(initial)).not.toContain(capability) - if (!initial.ok) { - throw new Error('Expected an initial transcript page') - } - expect(initial.warnings.join(' ')).not.toContain('continue with the cursor') - - contents = Buffer.concat([contents, codexMessage('appended', 'arrived after the first read')]) - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'remote-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 2 - }) - ).resolves.toMatchObject({ - ok: true, - messages: [ - { id: 'appended', blocks: [{ type: 'text', text: 'arrived after the first read' }] } - ], - nextOffset: contents.length, - limited: false - }) - }) - - it('keeps the bounded whole-file fallback for an older SSH host', async () => { - const contents = codexMessage('legacy', 'small legacy transcript') - const readFile = vi.fn(async () => ({ content: contents.toString('utf8'), isBinary: false })) - const readFileRange = vi.fn() - const provider = { - readFile, - readFileRange, - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(() => contents)) - } as unknown as IFilesystemProvider - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-session', - transcriptPath: '/remote/legacy.jsonl', - filesystemProvider: provider - }) - ).resolves.toMatchObject({ - ok: true, - messages: [{ id: 'legacy', blocks: [{ type: 'text', text: 'small legacy transcript' }] }] - }) - expect(readFile).toHaveBeenCalledWith('/remote/legacy.jsonl', { - maxTextBytes: sshFileStreamReadCap(false) - }) - expect(readFileRange).not.toHaveBeenCalled() - }) - - it('tails above the scan cap on a pre-range host and follows its EOF cursor', async () => { - let contents = Buffer.concat([ - Buffer.alloc(MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128, 0x78), - Buffer.from('\n'), - codexMessage('legacy-tail', 'newest legacy output') - ]) - const readFile = vi.fn(async (_path: string, limits?: { maxTextBytes?: number }) => { - if (contents.length > (limits?.maxTextBytes ?? 0)) { - throw new Error('Reported totalSize exceeds client cap') - } - return { content: contents.toString('utf8'), isBinary: false } - }) - const provider = { - readFile, - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(() => contents)) - } as unknown as IFilesystemProvider - const transcriptPath = '/remote/legacy-large.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-large-session', - transcriptPath, - filesystemProvider: provider, - limit: 2 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [{ id: 'legacy-tail', blocks: [{ type: 'text', text: 'newest legacy output' }] }], - nextOffset: contents.length, - limited: true, - warnings: expect.arrayContaining([ - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ]) - }) - if (!initial.ok) { - throw new Error('Expected an initial legacy transcript page') - } - - contents = Buffer.concat([contents, codexMessage('legacy-appended', 'followed from cursor')]) - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-large-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 2 - }) - ).resolves.toMatchObject({ - ok: true, - messages: [ - { id: 'legacy-appended', blocks: [{ type: 'text', text: 'followed from cursor' }] } - ], - nextOffset: contents.length, - limited: false - }) - expect(readFile).toHaveBeenLastCalledWith(transcriptPath, { - maxTextBytes: sshFileStreamReadCap(false) - }) - }) - - it.each([ - ['equal-size', 0], - ['larger', 64] - ])('rejects a same-identity ranged truncate/regrow at %s', async (_label, extraBytes) => { - let contents = codexMessage( - 'first', - 'original transcript with enough padding for equal-size rewrite' - ) - const { provider } = rangedProvider(() => contents) - const transcriptPath = '/remote/replaced.jsonl' - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'replacement-session', - transcriptPath, - filesystemProvider: provider, - limit: 10 - }) - if (!initial.ok) { - throw new Error('Expected the original remote transcript') - } - - const replacement = codexMessage('unrelated', 'replacement content') - contents = Buffer.concat([ - replacement, - Buffer.alloc(Math.max(0, initial.nextOffset + extraBytes - replacement.length), 0x20) - ]) - const replaced = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'replacement-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 10 - }) - - expect(replaced).toEqual({ ok: false, reason: 'source_changed', warnings: [] }) - }) - - it('degrades when a remote host cannot prove stable file identity', async () => { - const contents = codexMessage('legacy', 'identity unavailable') - const provider = { - readFile: vi.fn(async () => ({ content: contents.toString('utf8'), isBinary: false })), - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => ({ size: contents.length, type: 'file' as const, mtime: 0 })) - } as unknown as IFilesystemProvider - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-no-identity', - transcriptPath: '/remote/legacy-no-identity.jsonl', - filesystemProvider: provider - }) - ).resolves.toEqual({ - ok: false, - reason: 'remote_capability_unavailable', - warnings: [] - }) - }) -}) diff --git a/src/main/runtime/orchestration/worker-transcript-remote-read.ts b/src/main/runtime/orchestration/worker-transcript-remote-read.ts deleted file mode 100644 index 1c2c68b98e0..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-read.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { NativeChatMessage } from '../../../shared/native-chat-types' -import type { OrchestrationWorkerReadFallbackReason } from '../../../shared/orchestration-worker-output' -import { FileRangeReadUnsupportedError, type IFilesystemProvider } from '../../providers/types' -import { - MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, - type NativeChatLineDecoder -} from '../../native-chat/transcript-tail-reader' -import { transcriptFallbackId } from '../../native-chat/transcript-fallback-id' -import { sshFileStreamReadCap } from '../../ssh/ssh-file-stream-read-cap' -import { - boundWorkerTranscriptMessages, - clampWorkerTranscriptLimit -} from './worker-transcript-payload' -import { - createWorkerTranscriptBoundaryCheckpoint, - remoteWorkerTranscriptSourceIdentity, - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES, - workerTranscriptSourceChanged -} from './worker-transcript-source-identity' -import { - readRemoteTranscriptRangedWindow, - supportsRemoteTranscriptRangeRead, - type RemoteTranscriptWindow -} from './worker-transcript-remote-range-read' - -export const MAX_REMOTE_TRANSCRIPT_SCAN_BYTES = 8 * 1024 * 1024 -// The legacy snapshot stays at SSH's established ceiling while parsing only the scan window. -const MAX_LEGACY_REMOTE_TRANSCRIPT_READ_BYTES = sshFileStreamReadCap(false) - -type RemoteReadArgs = { - agent: string - sessionId: string - transcriptPath?: string - offset?: number - limit?: number - expectedBoundaryCheckpoint?: string - filesystemProvider?: IFilesystemProvider -} - -type RemoteReadResult = - | { - ok: true - filePath: string - sourceFingerprint: string - boundaryCheckpoint: string - messages: NativeChatMessage[] - nextOffset: number - limited: boolean - clipping: string[] - warnings: string[] - } - | { - ok: false - reason: OrchestrationWorkerReadFallbackReason | 'source_changed' - warnings: string[] - } - -class RemoteTranscriptIdentityUnavailableError extends Error {} - -export async function readRemoteWorkerTranscript( - args: RemoteReadArgs, - filePath: string, - decode: NativeChatLineDecoder -): Promise { - try { - const window = await readTranscriptWindow(args, filePath) - if (!window) { - return { ok: false, reason: 'source_changed', warnings: [] } - } - return parseTranscriptWindow(args, filePath, decode, window) - } catch (error) { - const code = (error as NodeJS.ErrnoException | null)?.code - return { - ok: false, - reason: code === 'ENOENT' ? 'transcript_missing' : 'remote_capability_unavailable', - warnings: [] - } - } -} - -async function readTranscriptWindow( - args: RemoteReadArgs, - filePath: string -): Promise { - const provider = args.filesystemProvider! - if (await supportsRemoteTranscriptRangeRead(provider)) { - try { - return await readRemoteTranscriptRangedWindow({ - provider, - filePath, - requestedOffset: args.offset, - expectedBoundaryCheckpoint: args.expectedBoundaryCheckpoint, - maxScanBytes: MAX_REMOTE_TRANSCRIPT_SCAN_BYTES - }) - } catch (error) { - // A stale capability answer can race an older relay; degrade once through its bounded snapshot. - if (!(error instanceof FileRangeReadUnsupportedError)) { - throw error - } - } - } - return readLegacyWindow(provider, filePath, args.offset, args.expectedBoundaryCheckpoint) -} - -async function readLegacyWindow( - provider: IFilesystemProvider, - filePath: string, - requestedOffset: number | undefined, - expectedBoundaryCheckpoint: string | undefined -): Promise { - const sourceIdentity = remoteWorkerTranscriptSourceIdentity(await provider.stat(filePath)) - if (!sourceIdentity) { - throw new RemoteTranscriptIdentityUnavailableError( - 'Remote transcript host did not provide stable file identity' - ) - } - const result = await provider.readFile(filePath, { - maxTextBytes: MAX_LEGACY_REMOTE_TRANSCRIPT_READ_BYTES - }) - if (typeof result.content !== 'string') { - throw new Error('Remote transcript read returned invalid content') - } - const allBytes = Buffer.from(result.content, 'utf8') - const fileSize = allBytes.length - const startOffset = requestedOffset ?? Math.max(0, fileSize - MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) - if (startOffset > fileSize) { - return null - } - const scanEnd = Math.min(fileSize, startOffset + MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) - const boundaryStart = Math.max(0, startOffset - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES) - const boundaryPrefix = allBytes.subarray(boundaryStart, startOffset) - if ( - expectedBoundaryCheckpoint !== undefined && - createWorkerTranscriptBoundaryCheckpoint(boundaryPrefix) !== expectedBoundaryCheckpoint - ) { - return null - } - const after = remoteWorkerTranscriptSourceIdentity(await provider.stat(filePath)) - if (workerTranscriptSourceChanged(sourceIdentity, after, scanEnd)) { - return null - } - return { - bytes: allBytes.subarray(startOffset, scanEnd), - fileSize, - startOffset, - scanEnd, - startsInsideRecord: startOffset > 0 && allBytes[startOffset - 1] !== 0x0a, - boundaryPrefix, - sourceIdentity - } -} - -function parseTranscriptWindow( - args: RemoteReadArgs, - filePath: string, - decode: NativeChatLineDecoder, - window: RemoteTranscriptWindow -): RemoteReadResult { - const limit = clampWorkerTranscriptLimit(args.limit) - const initialRead = args.offset === undefined - const messages: NativeChatMessage[] = [] - const decodedMessages: NativeChatMessage[] = [] - let malformed = 0 - let oversized = 0 - let relativeCursor = 0 - let nextOffset = window.startOffset - if (window.startsInsideRecord) { - const newline = window.bytes.indexOf(0x0a) - if (newline === -1) { - return finish(window.scanEnd < window.fileSize ? window.scanEnd : window.startOffset) - } - relativeCursor = newline + 1 - nextOffset = window.startOffset + relativeCursor - } - while (relativeCursor < window.bytes.length && (initialRead || messages.length < limit)) { - const newline = window.bytes.indexOf(0x0a, relativeCursor) - if (newline === -1) { - if (window.scanEnd < window.fileSize) { - if (window.bytes.length - relativeCursor > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { - oversized++ - nextOffset = window.scanEnd - } - } - break - } - const lineEnd = newline + 1 - const line = window.bytes - .subarray(relativeCursor, lineEnd) - .toString('utf8') - .replace(/\r?\n$/, '') - if (Buffer.byteLength(line, 'utf8') > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { - oversized++ - } else if (line) { - try { - JSON.parse(line) - const absoluteLineStart = window.startOffset + relativeCursor - const message = decode(line, transcriptFallbackId(filePath, absoluteLineStart)) - if (message) { - const destination = initialRead ? decodedMessages : messages - destination.push(message) - } - } catch { - malformed++ - } - } - relativeCursor = lineEnd - nextOffset = window.startOffset + relativeCursor - } - if (initialRead) { - messages.push(...decodedMessages.slice(-limit)) - } - return finish(nextOffset) - - function finish(cursor: number): RemoteReadResult { - const bounded = boundWorkerTranscriptMessages(messages, filePath) - const scanLimited = window.startOffset > 0 || window.scanEnd < window.fileSize - const initialTailClipped = initialRead && window.startOffset > 0 - const pageLimited = initialRead - ? scanLimited || decodedMessages.length > limit - : cursor < window.fileSize - return { - ok: true, - filePath, - sourceFingerprint: window.sourceIdentity.fingerprint, - boundaryCheckpoint: boundaryCheckpointAt(window, cursor), - messages: bounded.messages, - nextOffset: cursor, - limited: bounded.limited || pageLimited, - clipping: [ - ...(pageLimited ? ['message_limit_or_scan_window'] : []), - ...(bounded.limited ? ['transcript_payload'] : []) - ], - warnings: [ - ...(malformed > 0 ? [`${malformed} malformed transcript record(s) were skipped.`] : []), - ...(oversized > 0 ? [`${oversized} oversized transcript record(s) were skipped.`] : []), - ...bounded.warnings, - ...(initialTailClipped - ? [ - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ] - : scanLimited - ? ['Transcript scanning stopped at the bounded byte limit; continue with the cursor.'] - : []) - ] - } - } -} - -function boundaryCheckpointAt(window: RemoteTranscriptWindow, offset: number): string { - const relativeOffset = offset - window.startOffset - if (relativeOffset >= WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES) { - return createWorkerTranscriptBoundaryCheckpoint( - window.bytes.subarray( - relativeOffset - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES, - relativeOffset - ) - ) - } - const prefixBytes = Math.min( - window.boundaryPrefix.length, - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES - relativeOffset - ) - return createWorkerTranscriptBoundaryCheckpoint( - Buffer.concat([ - window.boundaryPrefix.subarray(window.boundaryPrefix.length - prefixBytes), - window.bytes.subarray(0, relativeOffset) - ]) - ) -} diff --git a/src/main/runtime/orchestration/worker-transcript-source-identity.ts b/src/main/runtime/orchestration/worker-transcript-source-identity.ts index 5693c9bcdac..3733891037f 100644 --- a/src/main/runtime/orchestration/worker-transcript-source-identity.ts +++ b/src/main/runtime/orchestration/worker-transcript-source-identity.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import type { BigIntStats } from 'node:fs' -import type { FileStat } from '../../providers/types' export type WorkerTranscriptSourceIdentity = { fingerprint: string @@ -8,7 +7,7 @@ export type WorkerTranscriptSourceIdentity = { mtimeMs: number } -export const WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES = 64 +const WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES = 64 export function createWorkerTranscriptBoundaryCheckpoint(bytes: Uint8Array): string { return createHash('sha256') @@ -40,24 +39,6 @@ export function localWorkerTranscriptSourceIdentity( ) } -export function remoteWorkerTranscriptSourceIdentity( - stats: FileStat -): WorkerTranscriptSourceIdentity | null { - const mtimeMs = stats.mtimeMs ?? stats.mtime - if ( - stats.type !== 'file' || - !Number.isSafeInteger(stats.size) || - stats.size < 0 || - !Number.isSafeInteger(stats.dev) || - !Number.isSafeInteger(stats.ino) || - ((stats.dev ?? 0) === 0 && (stats.ino ?? 0) === 0) || - !Number.isFinite(mtimeMs) - ) { - return null - } - return createIdentity(String(stats.dev), String(stats.ino), stats.size, mtimeMs) -} - export function workerTranscriptSourceChanged( before: WorkerTranscriptSourceIdentity, after: WorkerTranscriptSourceIdentity | null, diff --git a/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts b/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts index 996a905e04b..e4ae0dba48a 100644 --- a/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts @@ -1,16 +1,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { mergeNativeChatMessages } from '../../../../shared/native-chat-merge' -import { foldToolMessages, pairToolBlocks } from '../../../../shared/native-chat-tool-fold' -import type { IFilesystemProvider } from '../../../providers/types' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../../../providers/ssh-filesystem-dispatch' import { eraseRpcMethods, type RpcContext } from '../core' import { NATIVE_CHAT_METHODS } from './native-chat' @@ -34,36 +27,6 @@ function claudeLines(...texts: string[]): string { .join('') } -/** An SSH host's filesystem as the relay serves it: stat plus positional reads. */ -function sshHost(files: Map): IFilesystemProvider { - const bytes = (path: string): Buffer => { - const body = files.get(path) - if (body === undefined) { - // As the relay delivers it: the multiplexer swaps Node's 'ENOENT' code for its transport code. - const error = new Error(`ENOENT: no such file or directory, lstat '${path}'`) - Object.defineProperty(error, 'code', { value: -32000 }) - throw error - } - return Buffer.from(body) - } - const provider: Pick = { - stat: async (path) => ({ - size: bytes(path).length, - type: 'file', - mtime: 0, - mtimeMs: bytes(path).length, - dev: 1, - ino: 1 - }), - readFileRange: async (path, position, length) => { - const slice = bytes(path).subarray(position, position + length) - return { bytes: slice, bytesRead: slice.length } - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: transcript reads use only stat and readFileRange. - return provider as IFilesystemProvider -} - function sshHookRow(transcriptPath: string): Partial { return { paneKey: 'tab-1:leaf-1', @@ -138,240 +101,10 @@ describe('native chat for an agent whose transcript lives on an SSH host (#26057 }) afterEach(() => { - unregisterSshFilesystemProvider(CONNECTION_ID) rmSync(localDir, { recursive: true, force: true }) }) - it('reads the history from the SSH host the hook store attests the session to', async () => { - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost(new Map([[transcriptPath, claudeLines('first remote turn', 'second remote turn')]])) - ) - - const page = await readSession(transcriptPath) - - expect(page.error).toBeUndefined() - expect(page.texts).toEqual(['first remote turn', 'second remote turn']) - }) - - it('publishes raw provider order to old peers without private folded-result markers', async () => { - const records = [ - { - type: 'assistant', - uuid: 'a', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 'a', name: 'Bash', input: { command: 'a' } }] - } - }, - { - type: 'user', - uuid: 'old-x', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'x', content: 'OLD X' }] - } - }, - { - type: 'assistant', - uuid: 'x', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: { command: 'x' } }] - } - } - ] - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost( - new Map([ - [transcriptPath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`] - ]) - ) - ) - const read = method('nativeChat.readSession') - if ('stream' in read) { - throw new Error('nativeChat.readSession is a stream') - } - const page = await read.handler( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath }, - phoneContext({}) - ) - if ( - !page || - typeof page !== 'object' || - !('messages' in page) || - !Array.isArray(page.messages) - ) { - throw new Error('nativeChat.readSession did not publish messages') - } - const published: NativeChatMessage[] = page.messages - expect(published.map((message) => message.id)).toEqual(['a', 'old-x', 'x']) - expect(published.every((message) => !Object.hasOwn(message, 'unpairedToolResults'))).toBe(true) - const oldPeerRoundTrip: NativeChatMessage[] = JSON.parse(JSON.stringify(published)) - const projected = foldToolMessages(oldPeerRoundTrip) - expect(pairToolBlocks(projected[0]!.blocks).every((pair) => pair.result === undefined)).toBe( - true - ) - expect(projected[1]).toMatchObject({ id: 'old-x', role: 'tool', blocks: [{ output: 'OLD X' }] }) - expect(foldToolMessages(projected)).toEqual(projected) - }) - - it('keeps remote page cursors and reattaches several outputs to their earlier named calls', async () => { - const records = [ - { - type: 'user', - uuid: 'prompt', - message: { role: 'user', content: [{ type: 'text', text: 'remote question' }] } - }, - ...['a', 'b'].map((id) => ({ - type: 'assistant', - uuid: `call-${id}`, - message: { - role: 'assistant', - content: [{ type: 'tool_use', id, name: 'Bash', input: { command: id } }] - } - })), - { - type: 'assistant', - uuid: 'backlog', - message: { - role: 'assistant', - content: Array.from({ length: 64 }, (_, index) => ({ - type: 'tool_use', - id: `silent-${index}`, - name: 'Bash', - input: { command: `silent ${index}` } - })) - } - }, - { - type: 'user', - uuid: 'outputs', - message: { - role: 'user', - content: [ - { type: 'tool_result', tool_use_id: 'a', content: 'output A' }, - { type: 'tool_result', tool_use_id: 'b', content: 'output B', is_error: true } - ] - } - } - ] - const lines = records.map((record) => `${JSON.stringify(record)}\n`) - const cursor = Buffer.byteLength(lines.slice(0, 3).join('')) - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost(new Map([[transcriptPath, lines.join('')]])) - ) - const read = method('nativeChat.readSession') - if ('stream' in read) { - throw new Error('nativeChat.readSession is a stream') - } - const readPage = read.handler - async function page(limit: number, beforeOffset?: number) { - const result = await readPage( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath, limit, beforeOffset }, - phoneContext({}) - ) - if ( - !result || - typeof result !== 'object' || - !('messages' in result) || - !Array.isArray(result.messages) || - !('beforeOffset' in result) || - !('hasMore' in result) - ) { - throw new Error('nativeChat.readSession did not publish a page') - } - const messages: NativeChatMessage[] = result.messages - return { messages, beforeOffset: result.beforeOffset, hasMore: result.hasMore } - } - const tail = await page(2) - expect(tail.beforeOffset).toBe(cursor) - expect(tail.hasMore).toBe(true) - expect(tail.messages.map((row) => row.id)).toEqual(['backlog', 'outputs']) - expect(tail.messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true) - const partial = foldToolMessages(tail.messages) - expect(partial[1]).toMatchObject({ - id: 'outputs', - role: 'tool', - blocks: [{ output: 'output A' }, { output: 'output B', isError: true }] - }) - expect(pairToolBlocks(partial[0]!.blocks).every((pair) => pair.result === undefined)).toBe(true) - expect(foldToolMessages(partial)).toEqual(partial) - - const older = await page(100, cursor) - expect(older.beforeOffset).toBe(0) - expect(older.hasMore).toBe(false) - expect(older.messages.map((row) => row.id)).toEqual(['prompt', 'call-a', 'call-b']) - const raw = mergeNativeChatMessages(older.messages, tail.messages) - expect(raw.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true) - const whole = foldToolMessages(raw) - expect(whole.some((row) => row.role === 'tool')).toBe(false) - expect( - whole.flatMap((row) => - pairToolBlocks(row.blocks).flatMap((pair) => - pair.result ? [[pair.call?.callId, pair.result.output]] : [] - ) - ) - ).toEqual([ - ['a', 'output A'], - ['b', 'output B'] - ]) - expect(foldToolMessages(whole)).toEqual(whole) - expect(tail.beforeOffset).toBe(cursor) - }) - - it('streams the SSH host history and its later turns to a subscribed phone', async () => { - const files = new Map([[transcriptPath, claudeLines('remote history')]]) - registerSshFilesystemProvider(CONNECTION_ID, sshHost(files)) - const frames: unknown[] = [] - let cleanup = (): void => {} - const subscribe = method('nativeChat.subscribe') - if (!('stream' in subscribe)) { - throw new Error('nativeChat.subscribe is not a stream') - } - - await subscribe.handler( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath, subscriptionId: 'sub-1' }, - phoneContext({ - registerSubscriptionCleanup: (_id, fn) => { - cleanup = fn - }, - cleanupSubscription: () => cleanup() - }), - (frame) => frames.push(frame) - ) - try { - await vi.waitFor(() => expect(texts(frames[0])).toEqual(['remote history'])) - files.set(transcriptPath, claudeLines('remote history', 'remote follow-up')) - await vi.waitFor( - () => - expect( - frames - .filter( - (frame) => - frame && typeof frame === 'object' && 'type' in frame && frame.type === 'appended' - ) - .flatMap((frame) => texts(frame)) - ).toEqual(['remote follow-up']), - { timeout: 5_000 } - ) - } finally { - cleanup() - } - }) - - it('reads a transcript the SSH host has not written yet as not found, so the chat keeps waiting', async () => { - registerSshFilesystemProvider(CONNECTION_ID, sshHost(new Map())) - - const page = await readSession(transcriptPath) - - expect(page.texts).toEqual([]) - expect(page.notFound).toBe(true) - }) - - it('never answers from this machine while the SSH host is disconnected', async () => { + it('never answers from this machine for an SSH-hosted transcript', async () => { const page = await readSession(transcriptPath) expect(page.texts).toEqual([]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts index bad08eba258..c3f0c1e9266 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import * as sshFilesystemDispatch from '../../../../../providers/ssh-filesystem-dispatch' import { readExactWorkerOutput } from './worker-output' function codexMessage(id: string, text: string): string { @@ -20,7 +19,6 @@ describe('exact orchestration worker output', () => { let providerSession: ReturnType let runtime: OrcaRuntimeService const readTerminal = vi.fn() - let sshProviderLookup: { mockRestore: () => void } beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-worker-output-')) @@ -47,7 +45,6 @@ describe('exact orchestration worker output', () => { truncated: false, nextCursor: '9' }) - sshProviderLookup = vi.spyOn(sshFilesystemDispatch, 'getSshFilesystemProvider') runtime = { getExactWorkerProviderSession: vi.fn(() => providerSession), getTerminalProcessIncarnation: vi.fn(() => 'pty:incarnation-1'), @@ -57,7 +54,6 @@ describe('exact orchestration worker output', () => { }) afterEach(async () => { - sshProviderLookup.mockRestore() await rm(directory, { recursive: true, force: true }) }) @@ -134,7 +130,6 @@ describe('exact orchestration worker output', () => { messages: [{ id: 'a', blocks: [{ type: 'text', text: 'worker A only' }] }] } }) - expect(sshProviderLookup).not.toHaveBeenCalled() }) it('falls back safely when a WSL session lacks an attested distro', async () => { @@ -167,7 +162,7 @@ describe('exact orchestration worker output', () => { expect(result.cursor).toMatch(/^owr1_/) }) - it('keeps SSH transcript reads behind the remote filesystem capability', async () => { + it('falls back to the terminal for an SSH transcript', async () => { providerSession = { ...providerSession!, connectionId: 'ssh-target' @@ -179,7 +174,6 @@ describe('exact orchestration worker output', () => { source: 'terminal', fallbackReason: 'remote_capability_unavailable' }) - expect(sshProviderLookup).toHaveBeenCalledWith('ssh-target') }) it('reads Grok through the shared Native Chat transcript decoder', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts index 99555843791..516b4e9608f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts @@ -13,7 +13,6 @@ import { } from '../../../../orchestration/worker-output-cursor' import { redactWorkerTerminalLines } from '../../../../orchestration/worker-transcript-payload' import { readWorkerTranscript } from '../../../../orchestration/worker-transcript-read' -import { getSshFilesystemProvider } from '../../../../../providers/ssh-filesystem-dispatch' import { isWslHookRelayConnectionId } from '../../../../../../shared/wsl-hook-relay-contract' export async function readExactWorkerOutput(args: { @@ -48,11 +47,8 @@ export async function readExactWorkerOutput(args: { if (isWslSession && !session.wslDistro) { return fallbackOrThrow(args, 'remote_capability_unavailable') } - const remoteFilesystemProvider = - session.connectionId && !isWslSession - ? getSshFilesystemProvider(session.connectionId) - : undefined - if (session.connectionId && !isWslSession && !remoteFilesystemProvider) { + // Why: this process serves no SSH filesystem, so an SSH worker's transcript is unreachable. + if (session.connectionId && !isWslSession) { return fallbackOrThrow(args, 'remote_capability_unavailable') } if (cursor?.source === 'transcript' && !cursor.boundaryCheckpoint) { @@ -66,8 +62,7 @@ export async function readExactWorkerOutput(args: { offset: cursor?.source === 'transcript' ? cursor.position : undefined, expectedBoundaryCheckpoint: cursor?.source === 'transcript' ? (cursor.boundaryCheckpoint ?? undefined) : undefined, - limit: args.limit, - filesystemProvider: remoteFilesystemProvider + limit: args.limit }) if (!transcript.ok) { if (transcript.reason === 'source_changed') { diff --git a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts index e6168093595..04d540b5db9 100644 --- a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts +++ b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts @@ -1,7 +1,5 @@ // @ts-nocheck -- mechanically split declarations. -import type { FileReadLimits, IFilesystemProvider } from '../providers/types' import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { REMOTE_RPC_MAX_CONTENT_BYTES, remoteRpcResultExceedsContentBudget @@ -43,21 +41,6 @@ export function previewableBinaryByteLimit(maxContentBytes: number): number { return Math.floor(base64Bytes / 4) * 3 } -export async function readPreviewFileWithinCap( - provider: IFilesystemProvider, - filePath: string, - limits: FileReadLimits -): Promise { - try { - return await provider.readFile(filePath, limits) - } catch (error) { - if (error instanceof FileReadCapExceededError) { - throw new Error('file_too_large') - } - throw error - } -} - export function assertPreviewWithinTransportBudget( result: RuntimeFilePreviewResult, maxContentBytes: number | undefined diff --git a/src/main/runtime/runtime-file-search.test.ts b/src/main/runtime/runtime-file-search.test.ts index ef3b8e522ae..203da0ba189 100644 --- a/src/main/runtime/runtime-file-search.test.ts +++ b/src/main/runtime/runtime-file-search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' -import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { RuntimeFileSearch, type RuntimeFileSearchHost } from './runtime-file-search' function createSearch(executionHostId: 'local' | 'ssh:unavailable' | 'runtime:other' = 'local') { @@ -62,7 +62,7 @@ describe('RuntimeFileSearch', () => { it('preserves distinct search and listing results for unavailable SSH hosts', async () => { const { search, requireStore } = createSearch('ssh:unavailable') await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toThrow( - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) await expect(search.listRuntimeFiles('folder-1')).resolves.toEqual([]) await expect(search.searchQuickOpenFilePaths('folder-1', 'needle', 10)).resolves.toEqual({ diff --git a/src/main/runtime/runtime-git-execution-host-ownership.test.ts b/src/main/runtime/runtime-git-execution-host-ownership.test.ts index 9d42d003eb6..b9818313bc6 100644 --- a/src/main/runtime/runtime-git-execution-host-ownership.test.ts +++ b/src/main/runtime/runtime-git-execution-host-ownership.test.ts @@ -2,23 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type * as GitRemoteModule from '../git/remote' import type * as GitStatusModule from '../git/status' -import type * as SshGitDispatchModule from '../providers/ssh-git-dispatch' import type { ResolvedRuntimeGitWorktree } from './orca-runtime-git' import { RuntimeGitCommands } from './orca-runtime-git' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' const mocks = vi.hoisted(() => ({ - getSshGitProvider: vi.fn(), getStatus: vi.fn(), gitFetch: vi.fn(), stageFile: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', async () => ({ - ...(await vi.importActual('../providers/ssh-git-dispatch')), - getSshGitProvider: mocks.getSshGitProvider -})) - vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), getStatus: mocks.getStatus, @@ -45,23 +38,20 @@ function remoteCommands(): RuntimeGitCommands { describe('runtime Git execution-host ownership', () => { beforeEach(() => { - mocks.getSshGitProvider.mockReset().mockReturnValue(undefined) mocks.getStatus.mockReset() mocks.gitFetch.mockReset() mocks.stageFile.mockReset() }) - it('never substitutes local reads, sync, or mutations when the SSH provider is absent', async () => { + it('never substitutes local reads, sync, or mutations for an SSH host', async () => { const commands = remoteCommands() await expect(commands.getRuntimeGitStatus('id:wt-1')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - ) - await expect(commands.fetchRuntimeGit('id:wt-1')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) + await expect(commands.fetchRuntimeGit('id:wt-1')).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE) await expect(commands.stageRuntimeGitPath('id:wt-1', 'src/a.ts')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) expect(mocks.getStatus).not.toHaveBeenCalled() expect(mocks.gitFetch).not.toHaveBeenCalled() diff --git a/src/main/runtime/runtime-github-worktree-base.ts b/src/main/runtime/runtime-github-worktree-base.ts index f2bc88d0912..d4c6baa3675 100644 --- a/src/main/runtime/runtime-github-worktree-base.ts +++ b/src/main/runtime/runtime-github-worktree-base.ts @@ -54,9 +54,11 @@ export async function resolveRuntimeGitHubWorktreeBase( gitExec }) const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef(repo, null, remote, branch, { localGitExecOptions }) + fetchPrHeadTrackingRef(repo, remote, branch, { localGitExecOptions }) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef(repo, null, remote, prNumber, { localGitExecOptions }) + fetchGitHubPullRequestHeadRef(repo, remote, prNumber, { + localGitExecOptions + }) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, diff --git a/src/main/runtime/runtime-gitlab-fork-mr-base.ts b/src/main/runtime/runtime-gitlab-fork-mr-base.ts index 71270798b3f..58ed9142929 100644 --- a/src/main/runtime/runtime-gitlab-fork-mr-base.ts +++ b/src/main/runtime/runtime-gitlab-fork-mr-base.ts @@ -49,7 +49,6 @@ export async function resolveRuntimeGitLabForkMrBase(args: { try { const localRef = await fetchGitLabMergeRequestHeadRef( args.repo, - null, args.remote, args.mrIid, args.localGitExecOptions ? { localGitExecOptions: args.localGitExecOptions } : {} diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 0e5ea78bb72..65da6fd0103 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -13,7 +13,6 @@ import { getFolderWorkspacePathStatusForPath } from '../project-groups/folder-workspace-path-status' import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' import { folderWorkspaceKey } from '../../shared/workspace-scope' @@ -150,16 +149,13 @@ export class RuntimeProjectGroupController { if (!group || !folderPath) { throw new FolderWorkspaceCreateRefusedError('folder_workspace_project_group_not_found') } - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath, - projectGroupId: group.id, - connectionId: input.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath, + projectGroupId: group.id, + connectionId: input.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: store.getRepos() + }) const pathRefusal = folderWorkspacePathRefusal(status) if (pathRefusal) { throw new FolderWorkspaceCreateRefusedError(pathRefusal) @@ -179,7 +175,7 @@ export class RuntimeProjectGroupController { if (!store) { throw new Error('runtime_unavailable') } - return getFolderWorkspacePathStatus(store, request, { getSshFilesystemProvider }) + return getFolderWorkspacePathStatus(store, request) } async updateFolderWorkspace( @@ -198,19 +194,16 @@ export class RuntimeProjectGroupController { return null } const projectGroups = store.getProjectGroups?.() ?? [] - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath: updates.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: - workspace.connectionId ?? - projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath: updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) } const updated = store.updateFolderWorkspace(folderWorkspaceId, updates) diff --git a/src/main/runtime/runtime-repository-ref-queries.test.ts b/src/main/runtime/runtime-repository-ref-queries.test.ts index accf18dec15..ee0027b781d 100644 --- a/src/main/runtime/runtime-repository-ref-queries.test.ts +++ b/src/main/runtime/runtime-repository-ref-queries.test.ts @@ -1,11 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { Repo } from '../../shared/repo-types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries' -const { getProvider } = vi.hoisted(() => ({ getProvider: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getProvider })) - const repo: Repo = { id: 'remote-repo', path: '/repo', @@ -17,7 +13,6 @@ const repo: Repo = { describe('qualified refs in repository searches', () => { it('keeps folder workspaces outside Git searches', async () => { - vi.mocked(getSshGitProvider).mockClear() const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => ({ ...repo, kind: 'folder' }) }) @@ -25,6 +20,5 @@ describe('qualified refs in repository searches', () => { refs: [], truncated: false }) - expect(getSshGitProvider).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/runtime-search-line-fragments.test.ts b/src/main/runtime/runtime-search-line-fragments.test.ts index 67308d8667f..4451a1e9960 100644 --- a/src/main/runtime/runtime-search-line-fragments.test.ts +++ b/src/main/runtime/runtime-search-line-fragments.test.ts @@ -24,10 +24,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } diff --git a/src/main/runtime/ssh-file-explorer-chunk-read.ts b/src/main/runtime/ssh-file-explorer-chunk-read.ts deleted file mode 100644 index fdf3c9c5981..00000000000 --- a/src/main/runtime/ssh-file-explorer-chunk-read.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' -import type { RuntimeFileReadChunkResult } from '../../shared/runtime-types' -import { FileRangeReadUnsupportedError, type IFilesystemProvider } from '../providers/types' - -export const SSH_RANGED_READ_UNAVAILABLE_MESSAGE = - 'This SSH host cannot serve ranged file reads; reconnect the SSH target to update its Orca helper and retry' - -/** - * Serves one `readFileExplorerChunk` window from an SSH host, mirroring the local positional read. - * The host refuses a window wider than its range cap rather than clamping it, so a caller-sized - * window is paged into bounded reads here instead of failing the whole chunk. - */ -export async function readSshFileExplorerChunk( - provider: IFilesystemProvider, - filePath: string, - fileSize: number, - offset: number, - length: number -): Promise { - const readFileRange = provider.readFileRange?.bind(provider) - if (!readFileRange) { - throw new Error(SSH_RANGED_READ_UNAVAILABLE_MESSAGE) - } - const want = Math.min(length, Math.max(0, fileSize - offset)) - const windows: Buffer[] = [] - let bytesRead = 0 - while (bytesRead < want) { - const windowLength = Math.min(MAX_FILE_RANGE_READ_BYTES, want - bytesRead) - let window - try { - window = await readFileRange(filePath, offset + bytesRead, windowLength) - } catch (error) { - if (error instanceof FileRangeReadUnsupportedError) { - throw new Error(SSH_RANGED_READ_UNAVAILABLE_MESSAGE) - } - throw error - } - windows.push(window.bytes) - bytesRead += window.bytesRead - // A short window means EOF, so the file shrank under the stat: stop instead of spinning. This - // chunk reports `eof` false, but the caller's next chunk re-stats the smaller file and ends the - // transfer short — the same silent outcome the local branch produces, fixable only in both. - if (window.bytesRead < windowLength) { - break - } - } - return { - contentBase64: Buffer.concat(windows).toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileSize - } -} diff --git a/src/main/runtime/worktree-path-selector-wsl-posix.test.ts b/src/main/runtime/worktree-path-selector-wsl-posix.test.ts index 2346afa500b..b06ee2bbcdf 100644 --- a/src/main/runtime/worktree-path-selector-wsl-posix.test.ts +++ b/src/main/runtime/worktree-path-selector-wsl-posix.test.ts @@ -20,14 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -138,7 +130,6 @@ async function selectorTheCliWouldSend( } beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() }) diff --git a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts index 8b01d20e7a3..5f930d65462 100644 --- a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts +++ b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts @@ -15,14 +15,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -137,16 +129,6 @@ function neverSettles() { return new Promise(() => {}) } -/** A healthy remote scan, so the only difference between the co-hosted cases is how `metaById` is stamped. */ -function makeHealthySshScan() { - return { - listWorktrees: vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } - ]) - } -} - async function advancePastRepoScanBudget(pending: Promise): Promise { await vi.advanceTimersByTimeAsync(6_000) return pending @@ -154,7 +136,6 @@ async function advancePastRepoScanBudget(pending: Promise): Promise { describe('worktree.ps on a degraded repo scan', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([]) }) @@ -162,7 +143,6 @@ describe('worktree.ps on a degraded repo scan', () => { it('keeps persisted worktrees when a remote scan stalls past the per-repo budget', async () => { vi.useFakeTimers() try { - getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn(neverSettles) }) const runtime = new OrcaRuntimeService(makeStore({ connectionId: 'ssh-remote-1' }) as never) const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) @@ -174,7 +154,6 @@ describe('worktree.ps on a degraded repo scan', () => { }) it('keeps persisted worktrees when a remote repo is unreachable', async () => { - getSshGitProviderMock.mockReturnValue(undefined) const runtime = new OrcaRuntimeService(makeStore({ connectionId: 'ssh-remote-1' }) as never) const result = await runtime.getWorktreePs(10_000) @@ -249,33 +228,10 @@ describe('worktree.ps on a degraded repo scan', () => { } }) - // Why: remote disconnects should recover on the next poll; only local failures need backoff against spawn storms. - it('rescans a degraded remote repo on the next poll instead of caching the failure', async () => { - vi.useFakeTimers() - try { - const listWorktrees = vi.fn(async () => { - throw new Error('provider offline') - }) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) - const runtime = new OrcaRuntimeService( - makeStore({ connectionId: SSH_CONNECTION_ID }) as never - ) - - await runtime.getWorktreePs(10_000) - await vi.advanceTimersByTimeAsync(2_000) - await runtime.getWorktreePs(10_000) - - expect(listWorktrees).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - // Why: one repo id can be registered on several execution hosts, so a stalled host must not republish another host's rows. it('does not overwrite another execution host’s healthy rows when this host’s scan stalls', async () => { vi.useFakeTimers() try { - getSshGitProviderMock.mockReturnValue(makeHealthySshScan()) listWorktreesStrictMock.mockImplementation(neverSettles) const runtime = new OrcaRuntimeService( makeStore({ @@ -295,39 +251,12 @@ describe('worktree.ps on a degraded repo scan', () => { const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) const remote = result.worktrees.find((worktree) => worktree.worktreeId === WORKTREE_ID) - expect(remote?.branch).toBe('feature') expect(remote?.hostId).toBe(SSH_HOST_ID) } finally { vi.useRealTimers() } }) - it('does not claim an unstamped persisted row when the repo id is registered on two hosts', async () => { - vi.useFakeTimers() - try { - getSshGitProviderMock.mockReturnValue(makeHealthySshScan()) - listWorktreesStrictMock.mockImplementation(neverSettles) - const runtime = new OrcaRuntimeService( - makeStore({ - connectionId: SSH_CONNECTION_ID, - coHostedRepos: [{ id: REPO_ID, path: LOCAL_REPO_PATH }], - metaById: { - [WORKTREE_ID]: makeMeta(), - [MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main', instanceId: 'parent-instance' }) - } - }) as never - ) - - const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) - - const remote = result.worktrees.find((worktree) => worktree.worktreeId === WORKTREE_ID) - expect(remote?.hostId).toBe(SSH_HOST_ID) - expect(remote?.branch).toBe('feature') - } finally { - vi.useRealTimers() - } - }) - // Why: the ownership gate must not cost the single-host case the fix exists for — a stamped row on its own host still comes back. it('restores persisted rows stamped for this host when its only owner stalls', async () => { vi.useFakeTimers() diff --git a/src/main/runtime/worktree-ps-host-scope.test.ts b/src/main/runtime/worktree-ps-host-scope.test.ts index cf718cd267f..22174a50367 100644 --- a/src/main/runtime/worktree-ps-host-scope.test.ts +++ b/src/main/runtime/worktree-ps-host-scope.test.ts @@ -15,14 +15,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -43,7 +35,15 @@ function gitWorktree(path: string, isMain = false) { /** Local rows sort ahead of the remote ones, mirroring the fleet order that starves the cap. */ function makeStore() { - const metaById: Record = {} + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + const metaById: Record = { + [`${SSH_REPO_ID}::${SSH_REPO_PATH}`]: { + hostId: `ssh:${SSH_CONNECTION_ID}` + }, + [`${SSH_REPO_ID}::${SSH_REPO_PATH}-a`]: { + hostId: `ssh:${SSH_CONNECTION_ID}` + } + } return { getRepo: (id: string) => makeStore() @@ -91,7 +91,6 @@ function makeStore() { describe('worktree.ps host coverage', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([ gitWorktree(LOCAL_REPO_PATH, true), @@ -99,12 +98,6 @@ describe('worktree.ps host coverage', () => { gitWorktree(`${LOCAL_REPO_PATH}-b`), gitWorktree(`${LOCAL_REPO_PATH}-c`) ]) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [ - gitWorktree(SSH_REPO_PATH, true), - gitWorktree(`${SSH_REPO_PATH}-a`) - ]) - }) }) it('names every host the page covers', async () => { diff --git a/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts b/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts index 575dc71150a..2499c879653 100644 --- a/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts +++ b/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' vi.mock('electron', () => ({ @@ -8,14 +8,6 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } })) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - import { OrcaRuntimeService } from './orca-runtime' // BUG-9: a network drop to a relay host made `worktree ps` print `live:0 pty:no` for a terminal @@ -42,7 +34,8 @@ function makeStore() { setWorkspaceSession: vi.fn(), getRepos: vi.fn(() => [REPO]), getRepo: vi.fn((id: string) => (id === REPO.id ? REPO : undefined)), - getAllWorktreeMeta: vi.fn(() => ({})), + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + getAllWorktreeMeta: vi.fn(() => ({ [WORKTREE_ID]: {} })), getWorktreeMeta: vi.fn(() => undefined), setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), @@ -81,15 +74,6 @@ async function psRow(runtime: OrcaRuntimeService) { } describe('worktree.ps terminal verdicts for an SSH host', () => { - beforeEach(() => { - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } - ]) - }) - }) - it('counts a terminal the reachable host lists as live', async () => { const runtime = makeRuntime({ hasPty: () => true, diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts index f4e61a76381..751a5b4535b 100644 --- a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -20,14 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -106,7 +98,6 @@ type RemovalInternals = { } beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() }) diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 05412275168..b41d476b8a3 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -18,14 +18,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -181,7 +173,6 @@ function stallProbeOnce(): (fingerprint: string | null) => void { describe('worktree scan admin-fingerprint gate', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([ { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, @@ -326,17 +317,13 @@ describe('worktree scan admin-fingerprint gate', () => { it('never consults the probe for SSH repos', async () => { vi.useFakeTimers() try { - const listWorktrees = vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) const { list } = makeRuntime({ connectionId: 'ssh-remote-1' }) await list() vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() - expect(listWorktrees).toHaveBeenCalledTimes(2) + expect(listWorktreesStrictMock).not.toHaveBeenCalled() expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -609,7 +596,6 @@ describe('worktree scan admin-fingerprint gate', () => { describe('scoped explicit worktree-id resolution', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockImplementation(async (repoPath: string) => [ { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, diff --git a/src/main/runtime/worktree-scan-execution-host-routing.test.ts b/src/main/runtime/worktree-scan-execution-host-routing.test.ts index 9e40789da82..237a6b80e02 100644 --- a/src/main/runtime/worktree-scan-execution-host-routing.test.ts +++ b/src/main/runtime/worktree-scan-execution-host-routing.test.ts @@ -18,14 +18,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -130,23 +122,15 @@ function makeRuntime(repoOverrides: Record): { describe('worktree scan execution-host routing', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([]) }) - it('scans an executionHostId-only SSH repo over its SSH provider, not on the client', async () => { - const listWorktrees = vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) + it('lists an executionHostId-only SSH repo from its stored rows, not on the client', async () => { const { list } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` }) const worktrees = await list() - expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID) - expect(listWorktrees).toHaveBeenCalledWith(REPO_PATH) // A client-side `git worktree list` against a remote path is the silent-substitution failure. expect(listWorktreesStrictMock).not.toHaveBeenCalled() expect(worktrees.map((worktree) => worktree.path).sort()).toEqual([REPO_PATH, WORKTREE_PATH]) @@ -154,18 +138,6 @@ describe('worktree scan execution-host routing', () => { }) it('routes the PTY of an executionHostId-only SSH worktree to its host', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { - path: WORKTREE_PATH, - head: 'def', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ] - }) const { runtime } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` }) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ @@ -183,18 +155,6 @@ describe('worktree scan execution-host routing', () => { }) it('still routes a legacy connectionId-only SSH repo the same way', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { - path: WORKTREE_PATH, - head: 'def', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ] - }) const { runtime } = makeRuntime({ connectionId: TARGET_ID }) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ @@ -206,7 +166,6 @@ describe('worktree scan execution-host routing', () => { await runtime.createTerminal(`id:${WORKTREE_ID}`) - expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH }) ) diff --git a/src/main/runtime/worktree-scan-repeated-path.test.ts b/src/main/runtime/worktree-scan-repeated-path.test.ts index a559e66a70c..8934f52fa41 100644 --- a/src/main/runtime/worktree-scan-repeated-path.test.ts +++ b/src/main/runtime/worktree-scan-repeated-path.test.ts @@ -25,12 +25,6 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ readRepoWorktreeAdminFingerprint: vi.fn(async () => null) })) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({ - ...(await importOriginal>()), - getSshGitProvider: getSshGitProviderMock -})) - import { OrcaRuntimeService } from './orca-runtime' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -101,7 +95,6 @@ async function listedRows(runtime: OrcaRuntimeService): Promise { describe('worktree scan with a repeated path', () => { beforeEach(() => { localScanMock.mockReset() - getSshGitProviderMock.mockReset() }) describe('rows that agree, as reported', () => { @@ -149,15 +142,4 @@ describe('worktree scan with a repeated path', () => { await expect(listedRows(runtime)).resolves.toEqual([`${MAIN_WORKTREE_ID} refs/heads/dev_ops`]) await expect(runtime.showManagedWorktree('branch:stale')).rejects.toThrow('selector_not_found') }) - - it('lists an SSH repo the remote host repeats once', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [MAIN_ROW, { ...MAIN_ROW, isMainWorktree: false }]) - }) - - await expect(listedRows(makeRuntime({ connectionId: 'builder' }))).resolves.toEqual([ - `${MAIN_WORKTREE_ID} refs/heads/dev_ops` - ]) - expect(localScanMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/source-control/hosted-review-base-ref-suffix.test.ts b/src/main/source-control/hosted-review-base-ref-suffix.test.ts index fa20d8f94bc..ec8541fc45b 100644 --- a/src/main/source-control/hosted-review-base-ref-suffix.test.ts +++ b/src/main/source-control/hosted-review-base-ref-suffix.test.ts @@ -1,24 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../github/gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - import { baseRefExistsOnRemote } from './hosted-review-creation-git-state' describe('baseRefExistsOnRemote suffix fallback', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() }) it('does not answer a bare base with a nested branch of the same final segment', async () => { 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 fa0e02d5c55..294390b9d1f 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -17,7 +17,6 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -36,7 +35,6 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getEnterpriseGitHubRepoSlugMock: vi.fn() })) @@ -105,9 +103,9 @@ vi.mock('../gitlab/gl-utils', () => ({ vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) - -vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' import { baseRefExistsOnRemote } from './hosted-review-creation-git-state' @@ -141,7 +139,6 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() @@ -769,7 +766,6 @@ describe('getHostedReviewCreationEligibility', () => { stderr: '' })) } - getSshGitProviderMock.mockReturnValue(remoteGit) await expect( getHostedReviewCreationEligibility({ diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts index d6ab34eaaf5..16b56b1b4c6 100644 --- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -8,8 +8,7 @@ const { getBitbucketRepoSlugMock, getGiteaRepoSlugMock, getHostedReviewForBranchMock, - getRepoSlugMock, - getSshGitProviderMock + getRepoSlugMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -18,8 +17,7 @@ const { getBitbucketRepoSlugMock: vi.fn(), getGiteaRepoSlugMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), - getRepoSlugMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getRepoSlugMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -53,10 +51,6 @@ vi.mock('../gitea/client', () => ({ getGiteaPullRequest: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -75,8 +69,7 @@ function resetMocks(): void { getBitbucketRepoSlugMock, getGiteaRepoSlugMock, getHostedReviewForBranchMock, - getRepoSlugMock, - getSshGitProviderMock + getRepoSlugMock ]) { mock.mockReset() } diff --git a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts index cafe2685b5e..5be3918c1a1 100644 --- a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts +++ b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts @@ -15,8 +15,7 @@ const { ghExecFileAsyncMock, glabExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock, - getSshGitProviderMock + getUpstreamStatusMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), getRepoSlugMock: vi.fn(), @@ -29,8 +28,7 @@ const { ghExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getUpstreamStatusMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -82,9 +80,12 @@ vi.mock('../gitlab/gl-utils', () => ({ glabExecFileAsync: glabExecFileAsyncMock, glabRepoExecOptions: (repoPath: string) => ({ cwd: repoPath }) })) -vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) -vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) import { createHostedReview } from './hosted-review-creation' @@ -123,8 +124,7 @@ describe('createHostedReview with shared symlinks', () => { ghExecFileAsyncMock, glabExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock, - getSshGitProviderMock + getUpstreamStatusMock ]) { mock.mockReset() } diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 8ce2eae3459..eb40dd27810 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -17,7 +17,6 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -36,7 +35,6 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getEnterpriseGitHubRepoSlugMock: vi.fn() })) @@ -107,10 +105,6 @@ vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -143,7 +137,6 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() 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 1a84f5909bb..2a104a0001f 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 @@ -11,7 +11,6 @@ import type { Repo } from '../../shared/repo-types' */ const { - getSshGitProviderMock, gitExecFileAsyncMock, ghExecFileAsyncMock, glabExecFileAsyncMock, @@ -36,7 +35,6 @@ const { probeAnyExactRefMock, assertRemoteUrlReadableMock } = vi.hoisted(() => ({ - getSshGitProviderMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -62,11 +60,6 @@ const { assertRemoteUrlReadableMock: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../github/gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, @@ -230,7 +223,6 @@ beforeEach(() => { sshProviders.clear() sshProviders.set('ssh-a', makeSshProvider()) sshProviders.set('ssh-b', makeSshProvider()) - getSshGitProviderMock.mockImplementation((id: string) => sshProviders.get(id) ?? null) gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => ({ stdout: argv[0] === 'status' ? '' : 'feature\n', @@ -310,7 +302,6 @@ describe('hosted-review lookups route on the resolved execution host', () => { await commands.getHostedReviewForBranch({ repoSelector: 'repo-1', branch: 'feature' }) expect(getBitbucketRepoSlugMock).toHaveBeenCalledWith(REPO_PATH, null) - expect(getSshGitProviderMock).not.toHaveBeenCalledWith('ssh-b') }) it('keeps a runtime row with no nested target on this process (self-addressed stamp)', async () => { diff --git a/src/main/source-control/pull-request-template.ts b/src/main/source-control/pull-request-template.ts index e5f474a4958..631c3ac0f60 100644 --- a/src/main/source-control/pull-request-template.ts +++ b/src/main/source-control/pull-request-template.ts @@ -1,8 +1,6 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { HostedReviewProvider } from '../../shared/hosted-review' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' const PULL_REQUEST_TEMPLATE_CANDIDATES = [ '.github/pull_request_template.md', @@ -43,21 +41,11 @@ export async function readHostedReviewTemplate( connectionId?: string | null, provider?: HostedReviewProvider | null ): Promise { - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of getTemplateCandidates(provider)) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional hosted-review template path. diff --git a/src/main/source-control/repo-default-branch.test.ts b/src/main/source-control/repo-default-branch.test.ts index 02ea27b1e94..5fe1862e4ac 100644 --- a/src/main/source-control/repo-default-branch.test.ts +++ b/src/main/source-control/repo-default-branch.test.ts @@ -1,19 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0) -})) - import { getRepoDefaultBranchName, shouldHideNonOpenReviewOnDefaultBranch, @@ -32,7 +26,6 @@ function primeLocalGitExec(defaultRef = 'refs/remotes/origin/master'): void { describe('getRepoDefaultBranchName', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() __resetRepoDefaultBranchCacheForTests() }) @@ -60,28 +53,6 @@ describe('getRepoDefaultBranchName', () => { ) }) - it('routes resolution through the SSH provider exec and never local git', async () => { - const provider = { - exec: vi.fn(async (args: string[], repoPath: string) => { - expect(repoPath).toBe('/remote/repo') - if (args[0] === 'for-each-ref') { - return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/trunk\n' } - } - return { stdout: 'oid\n' } - }) - } - getSshGitProviderMock.mockReturnValue(provider) - - await expect(getRepoDefaultBranchName('/remote/repo', 'ssh-1')).resolves.toBe('trunk') - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') - expect(provider.exec).toHaveBeenCalledWith( - expect.arrayContaining(['for-each-ref', '--format=%(refname)%00%(symref)']), - '/remote/repo', - { timeoutMs: expect.any(Number) } - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('shares one wall-clock timeout budget across all fallback probes', async () => { let now = 1_000 const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now) @@ -106,8 +77,6 @@ describe('getRepoDefaultBranchName', () => { }) it('returns null without running local git when the SSH provider is unavailable', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - await expect(getRepoDefaultBranchName('/remote/repo', 'ssh-gone')).resolves.toBeNull() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -168,7 +137,6 @@ describe('getRepoDefaultBranchName', () => { describe('shouldHideNonOpenReviewOnDefaultBranch', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() __resetRepoDefaultBranchCacheForTests() }) diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index 8a45ba7f13a..7aa6bf02fba 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,7 +1,6 @@ import { resolveDefaultBaseRefViaExec } from '../git/repo' import { resolveRemoteHeadBranchViaExec, type GitExec } from '../../shared/git-default-base-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { getRepoExecutionHostId } from '../../shared/execution-host' import { hostedReviewRepoScope, scopeGeneration } from './hosted-review-scope-generations' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' @@ -31,12 +30,7 @@ function getRepoDefaultBranchCacheKey( ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` const scope = hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId })) - return [ - runtimeKey, - repoPath, - scopeGeneration(scope), - connectionId ? getSshGitProviderGeneration(connectionId) : 0 - ].join('\0') + return [runtimeKey, repoPath, scopeGeneration(scope)].join('\0') } function pruneRepoDefaultBranchCache(now: number): void { @@ -54,36 +48,30 @@ function pruneRepoDefaultBranchCache(now: number): void { } } -/** Git over the transport the hosted-review call uses; null when the SSH provider is gone. */ +/** Local git for the hosted-review call; null for an SSH repo, which this process cannot reach. */ function createRepoGitExec( repoPath: string, connectionId: string | null | undefined, localGitOptions: HostedReviewLocalGitOptions ): GitExec | null { - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - // Why: a dropped SSH provider must not fall back to local git — the - // repoPath is remote, so a local run could answer for the wrong repo. + if (connectionId) { + // Why: the repoPath is remote, so a local run could answer for the wrong repo. return null } const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS // Why: the resolver can try five refs; share one deadline so an unhealthy - // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. + // local/WSL host cannot multiply the refresh delay per fallback probe. return (argv) => { const timeoutMs = resolutionDeadline - Date.now() if (timeoutMs <= 0) { return Promise.reject(new Error('Default branch resolution timed out.')) } - return provider - ? provider.exec(argv, repoPath, { timeoutMs }) - : gitExecFileAsync(argv, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(localGitOptions.admissionTier - ? { admissionTier: localGitOptions.admissionTier } - : {}), - timeout: timeoutMs - }) + return gitExecFileAsync(argv, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}), + timeout: timeoutMs + }) } } diff --git a/src/main/source-control/repo-remote-head-branch.test.ts b/src/main/source-control/repo-remote-head-branch.test.ts index 6c223844235..b5e05e9f419 100644 --- a/src/main/source-control/repo-remote-head-branch.test.ts +++ b/src/main/source-control/repo-remote-head-branch.test.ts @@ -1,18 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock, getSshGitProviderGenerationMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0) - }) -) +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock -})) import { bumpScopeGeneration, hostedReviewRepoScope } from './hosted-review-scope-generations' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -30,8 +22,6 @@ function remoteHead(remote: string, branch: string): { stdout: string; stderr: s describe('getRemoteHeadBranchName', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() - getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) __resetRepoDefaultBranchCacheForTests() }) @@ -90,26 +80,7 @@ describe('getRemoteHeadBranchName', () => { ]) }) - it('isolates native, WSL and SSH results for the same path and remote', async () => { - gitExecFileAsyncMock.mockImplementation(async (_args, options: { wslDistro?: string }) => - remoteHead('upstream', options.wslDistro ? 'wsl-default' : 'native-default') - ) - const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'ssh-default')) - getSshGitProviderMock.mockReturnValue({ exec }) - await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('native-default') - await expect( - getRemoteHeadBranchName('/repo', 'upstream', null, { wslDistro: 'Ubuntu' }) - ).resolves.toBe('wsl-default') - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('ssh-default') - expect(exec).toHaveBeenCalledWith( - ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], - '/repo', - { timeoutMs: expect.any(Number) } - ) - }) - it('does not run local Git for a disconnected SSH host', async () => { - getSshGitProviderMock.mockReturnValue(undefined) await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-gone')).resolves.toBeNull() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -131,17 +102,6 @@ describe('getRemoteHeadBranchName', () => { } }) - it('does not reuse defaults from a replaced SSH provider', async () => { - getSshGitProviderGenerationMock.mockReturnValue(1) - const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'old-default')) - getSshGitProviderMock.mockReturnValue({ exec }) - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('old-default') - getSshGitProviderGenerationMock.mockReturnValue(2) - exec.mockResolvedValue(remoteHead('upstream', 'new-default')) - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('new-default') - expect(exec).toHaveBeenCalledTimes(2) - }) - it('refreshes defaults after the repository review scope is invalidated', async () => { gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'old-default')) await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('old-default') diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index 770d9cfdca3..197fcf94f42 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -57,8 +57,7 @@ async function setup() { targetId: TARGET.id, destinationEnvironmentId: 'env-1', destinationName: 'Managed', - terminalProof: { verdict: 'exited', provenPtyIds: [] }, - hasDirectSshAuthority: () => false + terminalProof: { verdict: 'exited', provenPtyIds: [] } }) if (fenced.outcome !== 'fenced') { throw new Error('expected a fence') diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index bd566295e0c..d553f1a0dd3 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -36,8 +36,7 @@ const mocks = vi.hoisted(() => { }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) @@ -120,7 +119,6 @@ async function convertedThenChangedOnOlderBuild(): Promise { name: 'Managed', censusHost: async () => ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) @@ -140,7 +138,6 @@ function deltaMove(at: () => Date = now) { environment: listEnvironments(userDataPath)[0]!, destination, censusHost: async () => ({ verdict: 'exited', count: 0 }), - releaseDirectSession: async () => {}, ensureTunnel: async () => {}, runTargetLifecycle, now: at @@ -492,7 +489,6 @@ async function convertKeepingSource(): Promise { name: 'Managed', censusHost: async () => ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index e84a7da8895..b472d8587e9 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -55,8 +55,6 @@ export type OrcadDeltaMoveArgs = { destination: OrcadMigrationDestinationCatalog /** The census of the host's relay endpoints that must prove exit. */ censusHost?: CensusHostRelayTerminals | null - /** Releases the relay session after the terminal check, as a conversion does. */ - releaseDirectSession: (sshTargetId: string) => Promise ensureTunnel: () => Promise /** Serializes the journal write through the commit with every other change to this host. */ runTargetLifecycle: (targetId: string, operation: () => Promise) => Promise @@ -83,7 +81,6 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise { // Why re-checked: another move of this host may have journaled or finished while this waited. const changedAt = store.getSshTarget(target.id)?.orcadFence?.sourceChangedAt diff --git a/src/main/ssh/orcad-migration-delta-snapshot.test.ts b/src/main/ssh/orcad-migration-delta-snapshot.test.ts index 7818596f228..ef2a2224d98 100644 --- a/src/main/ssh/orcad-migration-delta-snapshot.test.ts +++ b/src/main/ssh/orcad-migration-delta-snapshot.test.ts @@ -24,8 +24,7 @@ const mocks = vi.hoisted(() => { }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) @@ -136,7 +135,6 @@ async function convertedThenAdded(addOnOlderBuild: () => string): Promise ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) @@ -160,7 +158,6 @@ function deltaMoveTypingDuringTerminalCheck(whileChecking: () => void) { whileChecking() return { verdict: 'exited', count: 0 } }, - releaseDirectSession: async () => {}, ensureTunnel: async () => {}, runTargetLifecycle, now diff --git a/src/main/ssh/orcad-migration-source-fence.test.ts b/src/main/ssh/orcad-migration-source-fence.test.ts index 2c6444603ff..bf7b1019f65 100644 --- a/src/main/ssh/orcad-migration-source-fence.test.ts +++ b/src/main/ssh/orcad-migration-source-fence.test.ts @@ -60,7 +60,6 @@ function setup() { destinationEnvironmentId: 'env-1', destinationName: 'Managed', terminalProof: exited, - hasDirectSshAuthority: () => false, ...overrides }) const target = () => store.getSshTarget(TARGET.id)! @@ -180,14 +179,6 @@ describe('migration source fence', () => { expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) }) - it('refuses while the host is still connected directly', async () => { - const harness = setup() - await expect(harness.fence({ hasDirectSshAuthority: () => true })).resolves.toMatchObject({ - code: 'orcad_migration_direct_ssh_connected' - }) - expect(harness.target().orcadFence).toBeUndefined() - }) - it('keeps a blocker it could not read unverifiable, never live', async () => { const harness = setup() vi.spyOn(harness.store, 'inspectOrcadMigrationUntransferredDependencies').mockImplementation( diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index 4500d3dde69..10c0513770a 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -83,7 +83,6 @@ export async function fenceOrcadMigrationSource(args: { destinationName: string /** Taken by the caller while the relay could still answer; see orcad-migration-terminal-gate. */ terminalProof: OrcadMigrationTerminalVerdict - hasDirectSshAuthority: (targetId: string) => boolean now?: () => Date signal?: AbortSignal }): Promise { @@ -111,9 +110,6 @@ export async function fenceOrcadMigrationSource(args: { args.terminalProof.reason ) } - if (args.hasDirectSshAuthority(target.id)) { - return refuse('live', 'orcad_migration_direct_ssh_connected', 'Disconnect this SSH host first.') - } const preflight = preflightOrcadMigrationExport(args.store, target.id) if (!preflight.claimable) { return { diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index 87529c7a990..5e2caf36c71 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -20,12 +20,11 @@ import { SshConnectionStore } from './ssh-connection-store' const mocks = vi.hoisted(() => { const state: { targetStore: unknown } = { targetStore: null } - return { state, deploy: vi.fn(), ensureTunnel: vi.fn(), directAuthority: vi.fn() } + return { state, deploy: vi.fn(), ensureTunnel: vi.fn() } }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: mocks.directAuthority + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ @@ -66,7 +65,6 @@ beforeEach(() => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. mocks.state.targetStore = new SshConnectionStore(store as never) destination = fakeOrcadMigrationDestination() - mocks.directAuthority.mockReturnValue(false) mocks.ensureTunnel.mockResolvedValue(undefined) // Registers the server the fence named, as the real deploy would after pairing. mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 4167ff22a7d..7691e7e5450 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -37,7 +37,6 @@ import { } from './orcad-migration-terminal-gate' import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' export type OrcadManagedConversionArgs = { sshTargetId: string @@ -47,7 +46,7 @@ export type OrcadManagedConversionArgs = { /** The destination's T6-9 catalog client, reached through the server's tunnel. */ destinationFor: (environment: KnownRuntimeEnvironment) => OrcadMigrationDestinationCatalog /** Releases the direct SSH session after the terminal check, before the fence. */ - releaseDirectSession: (sshTargetId: string) => Promise + releaseDirectSession?: (sshTargetId: string) => Promise now?: () => Date signal?: AbortSignal } @@ -154,7 +153,7 @@ async function fenceOrResume( return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) } retireProvenExitedLeases(store, target.id, terminalProof) - await args.releaseDirectSession(target.id) + await args.releaseDirectSession?.(target.id) const result = await runTargetLifecycle(target.id, () => fenceOrcadMigrationSource({ userDataPath, @@ -164,7 +163,6 @@ async function fenceOrResume( destinationEnvironmentId: randomUUID(), destinationName: args.name, terminalProof, - hasDirectSshAuthority: hasRegisteredDirectSshAuthority, now: args.now, signal: args.signal }) diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index ba5072dca31..f143ab80363 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -26,7 +26,6 @@ const mocks = vi.hoisted(() => { state, events: new Array(), connect: vi.fn(), - hasDirectAuthority: vi.fn(), resolveContext: vi.fn(), recover: vi.fn(), readRecord: vi.fn(), @@ -41,8 +40,7 @@ const mocks = vi.hoisted(() => { vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({ connect: mocks.connect }), - getSshTargetRegistryStore: () => mocks.state.store, - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority + getSshTargetRegistryStore: () => mocks.state.store })) vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) @@ -133,7 +131,6 @@ beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orcad-deployment-test-')) target = { id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: 'dev' } setupStore() - mocks.hasDirectAuthority.mockReturnValue(false) mocks.connect.mockImplementation(async () => { mocks.events.push('connect') return {} @@ -312,9 +309,7 @@ describe('createManagedOrcadEnvironment', () => { expect(mocks.connect).not.toHaveBeenCalled() }) - it('refuses a connected direct SSH session and a taken server name', async () => { - mocks.hasDirectAuthority.mockReturnValueOnce(true) - await expect(deploy()).rejects.toThrow('Disconnect this SSH host') + it('refuses a taken server name', async () => { await deploy() target = { ...target, id: 'ssh-2', orcadFence: undefined } await expect( diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index c44440d90c9..307a266fdec 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -29,7 +29,6 @@ import { refuseOrcadHostDowngrade } from './orcad-host-version-admission' import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from './orcad-installed-activation' import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' import type { SshTarget } from '../../shared/ssh-types' import { deployedOrcadTunnelChecks } from './orcad-managed-tunnel-identity' @@ -66,9 +65,6 @@ export async function createManagedOrcadEnvironment( if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { throw new Error(`A server named "${args.name}" already exists.`) } - if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { - throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') - } const current = targetStore.getTarget(args.sshTargetId) if (args.migration) { assertMigrationFence(userDataPath, current, environmentId, args.name) diff --git a/src/main/ssh/orcad-unreachable-setup-release.test.ts b/src/main/ssh/orcad-unreachable-setup-release.test.ts index b4aedd774a0..ec284b68cf9 100644 --- a/src/main/ssh/orcad-unreachable-setup-release.test.ts +++ b/src/main/ssh/orcad-unreachable-setup-release.test.ts @@ -57,8 +57,7 @@ async function strandedConversion() { targetId: TARGET.id, destinationEnvironmentId: 'env-1', destinationName: 'Managed', - terminalProof: { verdict: 'exited', provenPtyIds: [] }, - hasDirectSshAuthority: () => false + terminalProof: { verdict: 'exited', provenPtyIds: [] } }) if (fenced.outcome !== 'fenced') { throw new Error('expected a fence') diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 6a63bef5b50..49e9f0d8f56 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -8,27 +8,10 @@ import { FrameDecoder, parseJsonRpcMessage, parseUnameToRelayPlatform, - isGitResponseStreamMarker, type JsonRpcRequest, type DecodedFrame } from './relay-protocol' -describe('git response stream marker', () => { - it('accepts only complete non-negative integer metadata', () => { - expect( - isGitResponseStreamMarker({ - __orcaGitResponseStream: { streamId: 1, totalBytes: 1024, chunkCount: 2 } - }) - ).toBe(true) - expect(isGitResponseStreamMarker({ __orcaGitResponseStream: {} })).toBe(false) - expect( - isGitResponseStreamMarker({ - __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } - }) - ).toBe(false) - }) -}) - describe('frame encoding', () => { it('encodes a frame with 13-byte header', () => { const payload = Buffer.from('hello') diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index f675ac6ed9d..633ab126bb7 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -36,61 +36,10 @@ export const MessageType = { KeepAlive: 9 } as const -/** Keepalive/timeout (VS Code ProtocolConstants). */ +/** Keepalive/timeout. */ export const KEEPALIVE_SEND_MS = 5_000 export const TIMEOUT_MS = 20_000 -// ── Relay error codes ─────────────────────────────────────────────── - -export const RelayErrorCode = { - CommandNotFound: -33001, - PermissionDenied: -33002, - PathNotFound: -33003, - PtyAllocationFailed: -33004, - DiskFull: -33005, - TooManyStreams: -33006, - StreamProtocolError: -33007 -} as const - -export const JsonRpcErrorCode = { - MethodNotFound: -32601 -} as const - -// ── Streaming constants (see docs/relay-file-stream-design.md) ───── - -/** Per-chunk payload size for fs.readFileStream. Mirrors VS Code's - * `bufferSize: 256 * 1024` (vs/platform/files/node/diskFileSystemProvider.ts). - * 256KB raw → ~340KB base64, well under MAX_MESSAGE_SIZE. */ -export const STREAM_CHUNK_SIZE = 256 * 1024 - -// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── - -/** Sentinel the relay returns as the RPC result when the real payload streams - * as git.responseChunk frames. Absent from old relays, so a new client falls - * back to the plain result they return. */ -export type GitResponseStreamMarker = { - __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } -} - -export function isGitResponseStreamMarker(value: unknown): value is GitResponseStreamMarker { - if (typeof value !== 'object' || value === null || !('__orcaGitResponseStream' in value)) { - return false - } - const marker = (value as { __orcaGitResponseStream?: unknown }).__orcaGitResponseStream - if (typeof marker !== 'object' || marker === null) { - return false - } - const fields = marker as Record - return ( - Number.isInteger(fields.streamId) && - (fields.streamId as number) > 0 && - Number.isInteger(fields.totalBytes) && - (fields.totalBytes as number) >= 0 && - Number.isInteger(fields.chunkCount) && - (fields.chunkCount as number) >= 0 - ) -} - // ── JSON-RPC types ────────────────────────────────────────────────── export type JsonRpcRequest = { diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts index 229e261d934..8a13d00565f 100644 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ b/src/main/ssh/runtime-ssh-access.test.ts @@ -21,8 +21,7 @@ const mocks = vi.hoisted(() => ({ ensure: vi.fn(), verify: vi.fn(), flush: vi.fn(), - preflight: vi.fn(), - hasDirectAuthority: vi.fn() + preflight: vi.fn() })) vi.mock('./orcad-managed-runtime-context', () => ({ requireManagedOrcadInfrastructure: mocks.infrastructure @@ -35,9 +34,6 @@ vi.mock('./orcad-managed-tunnel', () => ({ vi.mock('./runtime-ssh-access-verification', () => ({ verifyRuntimeEnvironmentSshTunnel: mocks.verify })) -vi.mock('./ssh-target-registry', () => ({ - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) import { fingerprintRuntimeSshTarget, linkRuntimeSshAccess, @@ -364,15 +360,6 @@ describe('independent runtime SSH access coordinator', () => { expect(mocks.connect).not.toHaveBeenCalled() }) - it('refuses a connected direct provider even when it has no catalog or leases', async () => { - mocks.hasDirectAuthority.mockReturnValue(true) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'Disconnect direct SSH authority' - ) - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - it('treats a lost successful unlink response as an idempotent no-op', async () => { await linkRuntimeSshAccess(userDataPath, request()) const unlinked = await unlink() diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts index 2240760da0d..9ecd3d7d110 100644 --- a/src/main/ssh/runtime-ssh-access.ts +++ b/src/main/ssh/runtime-ssh-access.ts @@ -25,7 +25,6 @@ import { startOrcadManagedTunnel } from './orcad-managed-tunnel' import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' type TargetClaims = ReturnType['claims'] @@ -63,9 +62,6 @@ function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { ge function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) { const target = requireTarget(claims, targetId) - if (hasRegisteredDirectSshAuthority(targetId)) { - throw new Error('Disconnect direct SSH authority before linking access to this paired server.') - } // Omitting the environment bypasses the same-owner shortcut and rechecks direct authority. const blockers = claims .preflight(targetId) diff --git a/src/main/ssh/ssh-file-stream-read-cap.ts b/src/main/ssh/ssh-file-stream-read-cap.ts deleted file mode 100644 index e5bdce97e5a..00000000000 --- a/src/main/ssh/ssh-file-stream-read-cap.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { FileReadLimits } from '../providers/types' - -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 - -export function sshFileStreamReadCap(isBinary: boolean, limits?: FileReadLimits): number { - const defaultCap = isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - const requestedCap = isBinary ? limits?.maxBinaryBytes : limits?.maxTextBytes - return requestedCap === undefined ? defaultCap : Math.min(defaultCap, requestedCap) -} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts deleted file mode 100644 index 8c5b8632fa7..00000000000 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' -import type { FileReadLimits, FileReadResult } from '../providers/types' -import { - createSshFileStreamInactivityDeadline, - SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS -} from './ssh-file-stream-inactivity-deadline' -import { sshFileStreamReadCap } from './ssh-file-stream-read-cap' - -const RESULT_ENCODING_BASE64 = 'base64' -const SENTINEL_STREAM_ID = -1 - -type StreamMetadataResponse = { - streamId?: number - totalSize: number - isBinary: boolean - isImage?: boolean - mimeType?: string - resultEncoding?: 'base64' | 'utf-8' - empty?: boolean -} - -export function isMethodNotFoundError(err: unknown): boolean { - if (!err || typeof err !== 'object') { - return false - } - const code = (err as { code?: unknown }).code - return code === JsonRpcErrorCode.MethodNotFound -} - -export class StreamProtocolError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -// Why: exceeding a cap the caller itself set is a size verdict, not a protocol fault — callers -// translate it into their own too-large error rather than leaking the raw stream message. -export class FileReadCapExceededError extends StreamProtocolError {} - -export async function readFileViaStream( - mux: SshChannelMultiplexer, - filePath: string, - limits?: FileReadLimits -): Promise { - // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving - // immediately after the response cannot beat the listener registration. - // streamIdRef stays at SENTINEL_STREAM_ID until metadata resolves; chunk - // handlers compare against it and drop unmatched ids cleanly. - const streamIdRef = { current: SENTINEL_STREAM_ID } - const unsubscribers: (() => void)[] = [] - const cleanup = (): void => { - while (unsubscribers.length > 0) { - const fn = unsubscribers.pop() - try { - fn?.() - } catch { - // Best-effort cleanup - } - } - } - - return new Promise((resolve, reject) => { - let buffer: Buffer | null = null - let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64 - let isBinary = false - let isImage: boolean | undefined - let mimeType: string | undefined - let totalSize = 0 - let expectedSeq = 0 - let receivedChunks = 0 - let totalChunks = 0 - let bytesReceived = 0 - let settled = false - - // Install metadata during response dispatch, before adjacent stream frames. - let metadataReady = false - - const inactivity = createSshFileStreamInactivityDeadline(() => { - fail( - new StreamProtocolError( - `File stream stalled (>${SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS}ms without data)` - ) - ) - }) - - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { - try { - mux.notify('fs.cancelStream', { streamId: streamIdRef.current }) - } catch { - // Best-effort - } - } - } - - const fail = (err: Error): void => { - if (settled) { - return - } - settled = true - inactivity.clear() - cancel() - cleanup() - reject(err) - } - - const succeed = (value: FileReadResult): void => { - if (settled) { - return - } - settled = true - inactivity.clear() - cleanup() - resolve(value) - } - - const handleChunk = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - const seq = params.seq as number - const data = params.data as string - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new StreamProtocolError(`Malformed chunk for stream ${id}`)) - return - } - if (seq !== expectedSeq) { - fail( - new StreamProtocolError( - `Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}` - ) - ) - return - } - const offset = seq * STREAM_CHUNK_SIZE - const decoded = Buffer.from(data, 'base64') - // Why: a short chunk would leave the pre-allocated buffer zero-filled and - // resolve as silently-corrupt data; validate each chunk's exact length. - const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - if (decoded.length !== expectedLength) { - fail( - new StreamProtocolError( - `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` - ) - ) - return - } - if (!buffer) { - fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`)) - return - } - decoded.copy(buffer, offset) - expectedSeq += 1 - receivedChunks += 1 - bytesReceived += decoded.length - inactivity.reset() - // Why: credit-based flow control — the relay caps unacked chunks so bulk - // stream frames cannot queue unbounded ahead of interactive pty.data - // frames on the shared SSH channel. Old relays ignore this notification. - mux.notify('fs.streamAck', { streamId: id, seq }) - } - - const handleEnd = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - if (receivedChunks !== totalChunks) { - fail( - new StreamProtocolError( - `Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}` - ) - ) - return - } - // Why: redundant given the per-chunk length + count checks, but kept as a - // last-line invariant guard; never resolve with fewer bytes than declared. - if (bytesReceived !== totalSize) { - fail( - new StreamProtocolError( - `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` - ) - ) - return - } - if (!buffer) { - fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) - return - } - const content = - resultEncoding === RESULT_ENCODING_BASE64 - ? buffer.toString('base64') - : buffer.toString('utf-8') - succeed({ - content, - isBinary, - ...(isImage !== undefined ? { isImage } : {}), - ...(mimeType !== undefined ? { mimeType } : {}) - }) - } - - const handleStreamError = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - const message = (params.message as string | undefined) ?? 'stream error' - const code = (params.code as string | undefined) ?? 'ESTREAMERROR' - const err = new Error(message) as Error & { code: string } - err.code = code - fail(err) - } - - unsubscribers.push( - mux.onNotificationByMethod('fs.streamChunk', (params) => { - if (!metadataReady) { - return - } - handleChunk(params) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('fs.streamEnd', (params) => { - if (!metadataReady) { - return - } - handleEnd(params) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('fs.streamError', (params) => { - if (!metadataReady) { - return - } - handleStreamError(params) - }) - ) - - const onDispose = mux.onDispose((reason) => { - const message = - reason === 'connection_lost' - ? 'SSH connection lost, reconnecting...' - : 'Multiplexer disposed' - const err = new Error(message) as Error & { code: string } - err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' - fail(err) - }) - unsubscribers.push(onDispose) - - void mux - // Why: flowControl declares this client acks each chunk, letting a new - // relay pace the pump. Old relays ignore the extra param and flood. - .request( - 'fs.readFileStream', - { filePath, flowControl: 'ack' }, - { - beforeResolve: (rawMetadata) => { - if (settled) { - return - } - const metadata = rawMetadata as StreamMetadataResponse - isBinary = metadata.isBinary - isImage = metadata.isImage - mimeType = metadata.mimeType - resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 - - if (metadata.empty) { - succeed({ - content: '', - isBinary: metadata.isBinary, - ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), - ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) - }) - return - } - - if (typeof metadata.streamId !== 'number') { - fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) - return - } - - const cap = sshFileStreamReadCap(metadata.isBinary, limits) - if (metadata.totalSize < 0 || metadata.totalSize > cap) { - streamIdRef.current = metadata.streamId - fail( - new FileReadCapExceededError( - `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` - ) - ) - return - } - - totalSize = metadata.totalSize - totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) - try { - buffer = Buffer.alloc(totalSize) - } catch (err) { - streamIdRef.current = metadata.streamId - fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) - return - } - streamIdRef.current = metadata.streamId - metadataReady = true - inactivity.reset() - } - } - ) - // Why: beforeResolve is an optional hook; if a mux ever resolves without running - // it, metadata never installs and no deadline is armed. Fail instead of hanging. - .then(() => { - if (!settled && !metadataReady) { - fail(new StreamProtocolError('Metadata response resolved without stream identity')) - } - }) - .catch((err) => { - fail(err as Error) - }) - }) -} diff --git a/src/main/ssh/ssh-git-response-pending-retention.test.ts b/src/main/ssh/ssh-git-response-pending-retention.test.ts deleted file mode 100644 index 3659169b9c5..00000000000 --- a/src/main/ssh/ssh-git-response-pending-retention.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -function fixture() { - const listeners = new Map) => void>>() - const replies: ((value: unknown) => void)[] = [] - const mux = { - request: vi.fn(() => new Promise((resolve) => replies.push(resolve))), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - const callbacks = listeners.get(method) ?? new Set() - callbacks.add(callback) - listeners.set(method, callbacks) - return () => callbacks.delete(callback) - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the request, disposal and notification surface used by this reader. - const typedMux = mux as unknown as SshChannelMultiplexer - return { - mux: typedMux, - mock: mux, - replies, - emit: (method: string, params: Record) => { - for (const callback of listeners.get(method) ?? []) { - callback(params) - } - }, - listenerCount: () => [...listeners.values()].reduce((sum, callbacks) => sum + callbacks.size, 0) - } -} - -const marker = (streamId: number, totalBytes: number, chunkCount: number) => ({ - __orcaGitResponseStream: { streamId, totalBytes, chunkCount } -}) - -it('drops oversized frames before metadata and fails only their identified owner', async () => { - const f = fixture() - const own = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const foreign = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(own).rejects.toThrow('retention budget') - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'x'.repeat(1024 * 1024) }) - f.emit('git.responseChunk', { streamId: 2, seq: 0, data: Buffer.from('[]').toString('base64') }) - f.emit('git.responseEnd', { streamId: 2 }) - f.replies[1](marker(2, 2, 1)) - await expect(foreign).resolves.toEqual([]) - f.replies[0](marker(1, 64, 1)) - await rejected - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(f.listenerCount()).toBe(0) -}) - -it('refuses chunked pre-metadata overflow even when the discarded data was foreign-shaped', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('retention budget') - for (let seq = 0; seq < 100; seq++) { - f.emit('git.responseChunk', { streamId: 1, seq, data: 'x'.repeat(40) }) - } - f.replies[0](marker(1, 64, 100)) - await rejected - expect(f.listenerCount()).toBe(0) -}) - -it('cleans abandoned queues and cancels a sentinel that arrives after abort', async () => { - const f = fixture() - const controller = new AbortController() - const result = requestGitStreamable( - f.mux, - 'fs.readDir', - {}, - { signal: controller.signal, maxResponseBytes: 64 } - ) - const rejected = expect(result).rejects.toThrow('cancelled') - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'e30=' }) - controller.abort() - await rejected - expect(f.listenerCount()).toBe(0) - f.replies[0](marker(1, 2, 1)) - await new Promise((resolve) => setImmediate(resolve)) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) -}) - -it('preserves honest ordered pre-metadata chunks and ignores malformed foreign params', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - f.emit('git.responseChunk', { streamId: { large: 'x'.repeat(1024) }, data: 'x'.repeat(1024) }) - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: Buffer.from('[1,').toString('base64') }) - f.emit('git.responseChunk', { streamId: 1, seq: 1, data: Buffer.from('2]').toString('base64') }) - f.emit('git.responseEnd', { streamId: 1 }) - f.replies[0](marker(1, 5, 2)) - await expect(result).resolves.toEqual([1, 2]) - expect(f.listenerCount()).toBe(0) -}) - -it('cleans the pending queue when the metadata request fails', async () => { - const f = fixture() - f.mock.request.mockRejectedValueOnce(new Error('metadata unavailable')) - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'e30=' }) - await expect(result).rejects.toThrow('metadata unavailable') - expect(f.listenerCount()).toBe(0) -}) - -it('drops assembled parts and subscriptions on a stalled owned stream', async () => { - vi.useFakeTimers() - try { - const f = fixture() - const result = requestGitStreamable( - f.mux, - 'fs.readDir', - {}, - { maxResponseBytes: 64, inactivityTimeoutMs: 10 } - ) - const rejected = expect(result).rejects.toThrow('stalled') - f.replies[0](marker(1, 4, 2)) - await Promise.resolve() - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: Buffer.from('[').toString('base64') }) - await vi.advanceTimersByTimeAsync(10) - await rejected - expect(f.listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - } finally { - vi.useRealTimers() - } -}) - -it('accepts an exact-budget honest response split across padded base64 chunks', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const payload = JSON.stringify('x'.repeat(62)) - expect(Buffer.byteLength(payload)).toBe(64) - for (let seq = 0; seq < 4; seq++) { - f.emit('git.responseChunk', { - streamId: 1, - seq, - data: Buffer.from(payload.slice(seq * 16, (seq + 1) * 16)).toString('base64') - }) - } - f.emit('git.responseEnd', { streamId: 1 }) - f.replies[0](marker(1, 64, 4)) - await expect(result).resolves.toBe('x'.repeat(62)) -}) - -it.each(['', '====', '!'])( - 'rejects zero-progress chunks %j without retaining or ACKing them', - async (data) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('byte progress') - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - for (let seq = 0; seq < 100_000; seq++) { - f.emit('git.responseChunk', { streamId: 1, seq, data }) - } - await rejected - expect(f.mock.notify).not.toHaveBeenCalledWith('git.responseAck', expect.anything()) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(f.listenerCount()).toBe(0) - } -) - -it.each([ - ['chunks', 2, 1, '['], - ['bytes', 2, 2, '[]'] -])('rejects excess declared %s before ACKing', async (_kind, total, chunks, text) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('declared') - f.replies[0](marker(1, Number(total), Number(chunks))) - await Promise.resolve() - f.emit('git.responseChunk', { - streamId: 1, - seq: 0, - data: Buffer.from(String(text)).toString('base64') - }) - f.emit('git.responseChunk', { streamId: 1, seq: 1, data: Buffer.from(']').toString('base64') }) - await rejected - expect(f.mock.notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength( - 1 - ) - expect(f.listenerCount()).toBe(0) -}) - -it('rejects an enormous declared count independently of continued empty frames', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('chunk count') - f.replies[0](marker(1, 2, Number.MAX_SAFE_INTEGER)) - await rejected - expect(f.listenerCount()).toBe(0) -}) - -it.each([true, false])( - 'bounds owned error diagnostics with metadata first: %s', - async (metadataFirst) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('retention budget') - if (metadataFirst) { - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - } - f.emit('git.responseError', { streamId: 1, message: 'x'.repeat(1024 * 1024) }) - if (!metadataFirst) { - f.replies[0](marker(1, 2, 1)) - } - await rejected - expect(f.listenerCount()).toBe(0) - } -) - -it('preserves short upstream error text', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('permission denied') - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - f.emit('git.responseError', { streamId: 1, message: 'permission denied' }) - await rejected -}) - -it('reassembles one-byte valid chunks without a per-chunk retained buffer', async () => { - const f = fixture() - const text = JSON.stringify('x'.repeat(100_000)) - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: text.length }) - f.replies[0](marker(1, text.length, text.length)) - await Promise.resolve() - for (let seq = 0; seq < text.length; seq++) { - f.emit('git.responseChunk', { - streamId: 1, - seq, - data: Buffer.from(text[seq]).toString('base64') - }) - } - f.emit('git.responseEnd', { streamId: 1 }) - await expect(result).resolves.toBe('x'.repeat(100_000)) - expect(f.listenerCount()).toBe(0) -}) diff --git a/src/main/ssh/ssh-git-response-stream-reader.test.ts b/src/main/ssh/ssh-git-response-stream-reader.test.ts deleted file mode 100644 index 8b08701a10d..00000000000 --- a/src/main/ssh/ssh-git-response-stream-reader.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { requestGitStreamable } from './ssh-git-response-stream-reader' -import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' - -function createMockTransport(): MultiplexerTransport { - return { - write: () => {}, - onData: () => {}, - onClose: () => {} - } -} - -describe('requestGitStreamable on an already-dead multiplexer', () => { - it('rejects as a transient relay loss and leaves no listener on the caller signal', async () => { - const mux = new SshChannelMultiplexer(createMockTransport()) - mux.dispose('connection_lost') - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - await expect( - requestGitStreamable(mux, 'git.status', { cwd: '/repo' }, { signal: controller.signal }) - ).rejects.toThrow('SSH connection lost, reconnecting...') - - // #11953: a disposed mux fails synchronously inside onDispose, so the abort - // listener must already be registered when that cleanup runs — otherwise it - // outlives the request for the lifetime of the caller's signal. - expect(removeListener).toHaveBeenCalledTimes(addListener.mock.calls.length) - }) -}) diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts deleted file mode 100644 index 1bf084ab0c5..00000000000 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - SshResponsePendingFrames, - boundedSshResponseDiagnostic -} from './ssh-response-pending-frames' -import { SshResponsePayload } from './ssh-response-payload' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { createSshDisposalError } from './ssh-channel-multiplexer' -import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' - -const SENTINEL_STREAM_ID = -1 - -/** Reject if no stream frame (chunk/end/error) arrives within this window, - * reset on each frame. mux.request's own timeout only bounds the fast sentinel - * response; without this, a relay pump that breaks on staleness (which sends no - * responseEnd) while the SSH channel stays up would hang the client forever. */ -const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 - -export class GitResponseStreamError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -/** - * Request a git method that may return a large payload, opting into response - * streaming so a big diff/exec response is chunked onto the relay's bulk lane - * instead of one JSON-RPC frame (which would head-of-line-block pty.data echo - * on the shared SSH channel). - * - * Cross-version behavior: - * - New relay + big result → returns the stream sentinel; we reassemble chunks. - * - New relay + small result, or old client → plain single-frame result. - * - Old relay (ignores `__streamResponse`) → returns the plain result; the - * marker check fails and we return it directly, i.e. today's behavior. - */ -export function requestGitStreamable( - mux: SshChannelMultiplexer, - method: string, - params: Record, - options?: { - signal?: AbortSignal - /** Bounds only the sentinel request (forwarded to mux.request), like today. */ - timeoutMs?: number - /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ - maxResponseBytes?: number - inactivityTimeoutMs?: number - } -): Promise { - // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a - // chunk that lands in the same dispatch tick as the response is not dropped - // (mirrors readFileViaStream). streamIdRef stays SENTINEL until the sentinel - // resolves; frames are queued until then and drained. - const streamIdRef = { current: SENTINEL_STREAM_ID } - const unsubscribers: (() => void)[] = [] - const cleanup = (): void => { - while (unsubscribers.length > 0) { - try { - unsubscribers.pop()?.() - } catch { - // best-effort - } - } - } - - return new Promise((resolve, reject) => { - let payload: SshResponsePayload | undefined - let expectedSeq = 0 - let totalBytes = 0 - let chunkCount = 0 - let settled = false - let metadataReady = false - const pending = new SshResponsePendingFrames(options?.maxResponseBytes) - - const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS - let inactivityTimer: ReturnType | null = null - const clearInactivity = (): void => { - if (inactivityTimer) { - clearTimeout(inactivityTimer) - inactivityTimer = null - } - } - // Why: reset on every stream frame so a legitimately long stream is not - // killed, but a wedged stream (no frames arriving) rejects instead of - // hanging the caller forever. - const armInactivity = (): void => { - if (inactivityTimer) { - inactivityTimer.refresh() - return - } - inactivityTimer = setTimeout(() => { - fail( - new GitResponseStreamError( - `Git response stream stalled (>${inactivityMs}ms without data)` - ) - ) - }, inactivityMs) - inactivityTimer.unref?.() - } - - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { - try { - mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) - } catch { - // best-effort - } - } - } - const fail = (err: Error): void => { - if (settled) { - return - } - settled = true - payload?.clear() - pending.clear() - clearInactivity() - cancel() - cleanup() - reject(err) - } - const succeed = (value: unknown): void => { - if (settled) { - return - } - settled = true - payload?.clear() - pending.clear() - clearInactivity() - cleanup() - resolve(value) - } - - const handleChunk = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - const seq = p.seq - const data = p.data - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) - return - } - if (seq !== expectedSeq) { - fail( - new GitResponseStreamError( - `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` - ) - ) - return - } - try { - payload?.append(data) - } catch (error) { - fail(new GitResponseStreamError(String(error))) - return - } - expectedSeq += 1 - armInactivity() - // Why: credit-based flow control — the relay caps unacked chunks so a big - // response cannot queue unbounded ahead of interactive pty.data frames. - if (!mux.isDisposed()) { - try { - mux.notify('git.responseAck', { streamId: streamIdRef.current, seq }) - } catch { - // Disposal can race the check; the ACK is best-effort during teardown. - } - } - } - - const handleEnd = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - if (expectedSeq !== chunkCount || payload?.receivedBytes !== totalBytes) { - fail( - new GitResponseStreamError( - `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${payload?.receivedBytes}/${totalBytes}` - ) - ) - return - } - try { - succeed(JSON.parse(payload?.takeString() ?? '')) - } catch (err) { - fail( - new GitResponseStreamError( - `Git stream ${streamIdRef.current} JSON parse failed: ${boundedSshResponseDiagnostic(String(err), options?.maxResponseBytes)}` - ) - ) - } - } - - const handleStreamError = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - fail(new Error(boundedSshResponseDiagnostic(p.message, options?.maxResponseBytes))) - } - - const drainPending = (): void => { - let frame = pending.shift() - while (!settled && frame) { - if (frame.kind === 'chunk') { - handleChunk(frame.params) - } else if (frame.kind === 'end') { - handleEnd(frame.params) - } else { - handleStreamError(frame.params) - } - frame = pending.shift() - } - } - - unsubscribers.push( - mux.onNotificationByMethod('git.responseChunk', (p) => { - if (!metadataReady) { - pending.push('chunk', p) - return - } - handleChunk(p) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('git.responseEnd', (p) => { - if (!metadataReady) { - pending.push('end', p) - return - } - handleEnd(p) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('git.responseError', (p) => { - if (!metadataReady) { - pending.push('error', p) - return - } - handleStreamError(p) - }) - ) - if (options?.signal) { - const signal = options.signal - if (signal.aborted) { - const err = new Error('Request was cancelled') as Error & { name: string } - err.name = 'AbortError' - fail(err) - return - } - const onAbort = (): void => { - const err = new Error('Request was cancelled') as Error & { name: string } - err.name = 'AbortError' - fail(err) - } - signal.addEventListener('abort', onAbort, { once: true }) - unsubscribers.push(() => signal.removeEventListener('abort', onAbort)) - } - - // Why: registered last because an already-disposed mux fails synchronously here, - // and that cleanup must be able to drop the abort listener above (#11953). - unsubscribers.push(mux.onDispose((reason) => fail(createSshDisposalError(reason)))) - - // Why: forward only the mux-request options (signal/timeoutMs) and omit them - // entirely when absent, so callers that previously issued a 2-arg - // mux.request keep the same call shape (and their tests). inactivityTimeoutMs - // governs reassembly here, not the sentinel request. - const streamParams = { ...params, __streamResponse: true } - const requestOptions = - options?.signal !== undefined || options?.timeoutMs !== undefined - ? { signal: options.signal, timeoutMs: options.timeoutMs } - : undefined - const requestPromise = requestOptions - ? mux.request(method, streamParams, requestOptions) - : mux.request(method, streamParams) - void requestPromise - .then((result) => { - if (settled) { - if (isGitResponseStreamMarker(result) && !mux.isDisposed()) { - mux.notify('git.cancelResponseStream', { - streamId: result.__orcaGitResponseStream.streamId - }) - } - return - } - // Old relay / small result: plain single-frame value, no stream follows. - if (!isGitResponseStreamMarker(result)) { - if (options?.maxResponseBytes !== undefined) { - stringifyJsonWithinByteLimit(result, options.maxResponseBytes) - } - succeed(result) - return - } - const marker = result.__orcaGitResponseStream - totalBytes = marker.totalBytes - chunkCount = marker.chunkCount - streamIdRef.current = marker.streamId - if (pending.lostFrames(marker.streamId)) { - fail( - new GitResponseStreamError( - 'Filesystem response exceeds the retention budget before metadata' - ) - ) - return - } - payload = new SshResponsePayload(totalBytes, chunkCount, options?.maxResponseBytes) - metadataReady = true - // Why: start the inactivity deadline now — mux.request's timeout only - // covered the sentinel; the reassembly phase needs its own guard. - armInactivity() - drainPending() - }) - .catch((err) => fail(err as Error)) - }) -} diff --git a/src/main/ssh/ssh-git-stream-idle-timer.test.ts b/src/main/ssh/ssh-git-stream-idle-timer.test.ts deleted file mode 100644 index 7cd5207c63a..00000000000 --- a/src/main/ssh/ssh-git-stream-idle-timer.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -it.each(['end', 'abort', 'timeout'] as const)( - 'reuses the idle deadline across 1000 chunks and cleans up on %s', - async (finish) => { - vi.useFakeTimers() - const setTimer = vi.spyOn(globalThis, 'setTimeout') - try { - const listeners = new Map) => void>() - const controller = new AbortController() - const content = 'x'.repeat(998) - const encoded = Buffer.from(JSON.stringify(content)) - const notify = vi.fn() - const mux = { - request: vi.fn(async () => ({ - __orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1000 } - })), - isDisposed: () => false, - notify, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - listeners.set(method, callback) - return () => listeners.delete(method) - } - } - const promise = requestGitStreamable( - mux as unknown as SshChannelMultiplexer, - 'git.diff', - {}, - { - signal: controller.signal - } - ) - const outcome = promise.then( - (value) => ({ value }), - (error: Error) => ({ error: error.message }) - ) - await vi.advanceTimersByTimeAsync(15_000) - for (let seq = 0; seq < encoded.length; seq++) { - listeners.get('git.responseChunk')!({ - streamId: 7, - seq, - data: encoded.subarray(seq, seq + 1).toString('base64') - }) - } - await vi.advanceTimersByTimeAsync(29_999) - expect(listeners.size).toBe(3) - expect(notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength( - 1000 - ) - const allocations = setTimer.mock.calls.filter(([, delay]) => delay === 30_000).length - if (finish === 'end') { - listeners.get('git.responseEnd')!({ streamId: 7 }) - expect(await outcome).toEqual({ value: content }) - } else if (finish === 'abort') { - controller.abort() - expect(await outcome).toEqual({ error: 'Request was cancelled' }) - } else { - await vi.advanceTimersByTimeAsync(1) - expect(await outcome).toEqual({ - error: 'Git response stream stalled (>30000ms without data)' - }) - } - expect(allocations).toBe(1) - expect(vi.getTimerCount()).toBe(0) - expect(listeners.size).toBe(0) - expect( - notify.mock.calls.filter(([method]) => method === 'git.cancelResponseStream') - ).toHaveLength(finish === 'end' ? 0 : 1) - } finally { - setTimer.mockRestore() - vi.useRealTimers() - } - } -) diff --git a/src/main/ssh/ssh-listing-response-retention.test.ts b/src/main/ssh/ssh-listing-response-retention.test.ts deleted file mode 100644 index 3846b4185b7..00000000000 --- a/src/main/ssh/ssh-listing-response-retention.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -function fixture(result: unknown) { - const listeners = new Map) => void>() - const mock = { - request: vi.fn().mockResolvedValue(result), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - listener: (params: Record) => void - ) => { - listeners.set(method, listener) - return () => { - listeners.delete(method) - } - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the reader's request, notification, and disposal operations. - return { mux: mock as unknown as SshChannelMultiplexer, mock, listeners } -} - -it('refuses oversized stream metadata before retaining any chunks and cancels the pump', async () => { - const { mux, mock, listeners } = fixture({ - __orcaGitResponseStream: { streamId: 1, totalBytes: 129, chunkCount: 2 } - }) - await expect( - requestGitStreamable(mux, 'fs.readDirBounded', {}, { maxResponseBytes: 128 }) - ).rejects.toThrow('retention budget') - expect(mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(listeners.size).toBe(0) -}) - -it('rejects a chunk past the advertised retention limit and detaches listeners', async () => { - const { mux, mock, listeners } = fixture({ - __orcaGitResponseStream: { streamId: 2, totalBytes: 64, chunkCount: 1 } - }) - const result = requestGitStreamable(mux, 'fs.readDirBounded', {}, { maxResponseBytes: 64 }) - const outcome = expect(result).rejects.toThrow('retention budget') - await Promise.resolve() - listeners.get('git.responseChunk')?.({ - streamId: 2, - seq: 0, - data: Buffer.alloc(65).toString('base64') - }) - await outcome - expect(mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 2 }) - expect(listeners.size).toBe(0) -}) - -it('validates old-peer plain replies against the same reader byte budget', async () => { - const { mux, listeners } = fixture(['x'.repeat(129)]) - await expect( - requestGitStreamable(mux, 'fs.listFiles', {}, { maxResponseBytes: 128 }) - ).rejects.toThrow('exceeds') - expect(listeners.size).toBe(0) -}) diff --git a/src/main/ssh/ssh-plain-ssh-mode.ts b/src/main/ssh/ssh-plain-ssh-mode.ts deleted file mode 100644 index ee259a52288..00000000000 --- a/src/main/ssh/ssh-plain-ssh-mode.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Design D6 rung D: no Orca runtime runs on the host, so the target connects with plain SSH - * terminals and SFTP browsing only. This module owns the per-target mode record and the - * wording every relay-only feature uses when it refuses. - */ -import type { SshPlainSshMode } from '../../shared/ssh-types' -import type { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' - -const PLAIN_SSH_SUFFIX = - ' Connected with plain SSH terminals and SFTP file browsing; these terminals end when the ' + - 'connection drops and are not restored on reconnect.' - -const plainModes = new Map() - -export function plainSshModeFromRuntimeUnavailable( - error: RemoteRuntimeUnavailableError -): SshPlainSshMode { - return { reason: error.reason, message: `${error.message}${PLAIN_SSH_SUFFIX}` } -} - -export function setSshPlainSshMode(targetId: string, mode: SshPlainSshMode): void { - plainModes.set(targetId, mode) -} - -export function clearSshPlainSshMode(targetId: string): void { - plainModes.delete(targetId) -} - -export function getSshPlainSshMode(targetId: string): SshPlainSshMode | undefined { - return plainModes.get(targetId) -} - -/** Refusal for an operation only the Orca remote server can perform. */ -export class PlainSshUnsupportedError extends Error { - readonly code = 'plain_ssh_unsupported' - - constructor(feature: string, mode: SshPlainSshMode | undefined) { - const reason = mode ? ` (${mode.reason})` : '' - super(`${feature} needs the Orca remote server, which is not running on this host${reason}.`) - this.name = 'PlainSshUnsupportedError' - } -} diff --git a/src/main/ssh/ssh-target-registry.test.ts b/src/main/ssh/ssh-target-registry.test.ts deleted file mode 100644 index 660736311ea..00000000000 --- a/src/main/ssh/ssh-target-registry.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 b03b0d88444..5008f3b9fc9 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -1,6 +1,4 @@ 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 { SshConnectionState, @@ -21,8 +19,8 @@ import type { * This holds only the registry: the store plus the two callbacks the handler layer * installs. `registerSshHandlers` populates it; the runtime reads it. Keeping the * indirection (rather than the runtime holding a manager directly) is deliberate — - * SSH providers register after construction and may reconnect, so callers must - * resolve the current generation rather than freeze one. + * `registerSshHandlers` may re-run, so callers must resolve the current generation + * rather than freeze one. */ let sshStore: SshConnectionStore | null = null @@ -88,16 +86,7 @@ export function getRegisteredSshTargetMutations(): SshTargetMutations { return registeredSshTargetMutations } -/** - * 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 { - throw new SshHostNotServedError(connectionId) -} - let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null -let registeredHasDirectSshAuthority: ((targetId: string) => boolean) | null = null export function setSshConnectionManagerResolver( resolve: (() => SshConnectionManager | null) | null @@ -116,14 +105,3 @@ export function setSshConnectionManagerResolver( export function getSshConnectionManager(): SshConnectionManager | null { return registeredGetSshConnectionManager?.() ?? null } - -export function setDirectSshAuthorityResolver( - resolve: ((targetId: string) => boolean) | null -): void { - registeredHasDirectSshAuthority = resolve -} - -/** Whether this process currently holds the target's relay session; false when unregistered. */ -export function hasRegisteredDirectSshAuthority(targetId: string): boolean { - return registeredHasDirectSshAuthority?.(targetId) ?? false -} diff --git a/src/main/startup/headless-serve-ssh-registration.test.ts b/src/main/startup/headless-serve-ssh-registration.test.ts index 715a8448814..b41fa91a167 100644 --- a/src/main/startup/headless-serve-ssh-registration.test.ts +++ b/src/main/startup/headless-serve-ssh-registration.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('../ipc/ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('../ipc/pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts index ba32e82c3b9..952fc48a88b 100644 --- a/src/main/window/clipboard-agent-session-attachment-paste.test.ts +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -26,8 +26,7 @@ vi.mock('../../shared/runtime-environment-store', () => ({ })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => {}), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 99144519d58..984e7de1698 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -39,8 +39,7 @@ vi.mock('electron', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) import { diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 7e479a9ecba..fd4e5e0064d 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,13 +1,12 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ +const { writeFileMock, mkdirMock, getPathMock } = vi.hoisted(() => ({ writeFileMock: vi.fn(), mkdirMock: vi.fn(), getPathMock: vi.fn((name: string) => name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca' - ), - writeFileBase64Mock: vi.fn() + ) })) vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock, mkdir: mkdirMock } })) @@ -15,12 +14,6 @@ vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: getPathMock }) })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: () => ({ - getTempDir: async () => '/remote/tmp', - writeFileBase64: writeFileBase64Mock - }) -})) import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' @@ -53,14 +46,4 @@ describe('saveClipboardImageBufferAsTempFile', () => { ) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) }) - - it('writes an SSH paste to the remote temp folder', async () => { - const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1]), { - connectionId: 'conn-1' - }) - - expect(savedPath.startsWith('/remote/tmp/')).toBe(true) - expect(writeFileBase64Mock).toHaveBeenCalled() - expect(writeFileMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 223b89c79a6..049f98bf03f 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,9 +2,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { getAppEnvironment } from '../../shared/app-environment' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { nativeChatPasteFolder } from './native-chat-paste-files' @@ -18,15 +17,6 @@ export type SaveClipboardImageAsTempFileArgs = { forNativeChatDraft?: boolean } -const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' - -function joinRemotePath(basePath: string, fileName: string): string { - if (isWindowsAbsolutePathLike(basePath)) { - return path.win32.join(basePath, fileName) - } - return path.posix.join(basePath, fileName) -} - export async function saveClipboardImageBufferAsTempFile( buffer: Buffer, args?: SaveClipboardImageAsTempFileArgs @@ -36,13 +26,7 @@ export async function saveClipboardImageBufferAsTempFile( const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` if (args?.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const remoteTempDir = (await provider.getTempDir?.()) ?? REMOTE_CLIPBOARD_IMAGE_TEMP_DIR - const remotePath = joinRemotePath(remoteTempDir, fileName) - // Why: SSH terminal agents run on the remote host, so the pasted path must - // name a remote file. The provider's base64 path writes binary bytes via SFTP. - await provider.writeFileBase64(remotePath, buffer.toString('base64')) - return remotePath + throw new SshHostNotServedError(args.connectionId) } // Why only a composer paste goes to the paste folder: its draft can bring it back after a diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index dc7168f7d65..8cb0c850c90 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, @@ -30,7 +29,6 @@ const { clipboardWriteBufferMock, nativeImageCreateFromBufferMock, randomUUIDMock, - getSshFilesystemProviderMock, callRuntimeEnvironmentMock } = vi.hoisted(() => ({ removeHandlerMock: vi.fn(), @@ -66,7 +64,6 @@ const { clipboardWriteBufferMock: vi.fn(), nativeImageCreateFromBufferMock: vi.fn(), randomUUIDMock: vi.fn(() => '00000000-0000-4000-8000-000000000000'), - getSshFilesystemProviderMock: vi.fn(), callRuntimeEnvironmentMock: vi.fn() })) @@ -119,19 +116,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - } -})) - vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock })) @@ -142,11 +126,6 @@ import { setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' -const REMOTE_CLIPBOARD_STAGING_ROOT = join( - '/tmp', - `orca-clipboard-files${typeof process.getuid === 'function' ? `-${process.getuid()}` : ''}` -) - function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() for (const [channel, handler] of handleMock.mock.calls as [ @@ -233,7 +212,6 @@ describe('registerClipboardHandlers', () => { nativeImageCreateFromBufferMock.mockReset() randomUUIDMock.mockReset() randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000') - getSshFilesystemProviderMock.mockReset() callRuntimeEnvironmentMock.mockReset() setTrustedClipboardRendererWebContentsId(null) }) @@ -305,7 +283,6 @@ describe('registerClipboardHandlers', () => { expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('writes local files through the trusted clipboard IPC handler', async () => { @@ -328,92 +305,6 @@ describe('registerClipboardHandlers', () => { } }) - it('materializes remote files before writing them to the OS clipboard', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - const tempDir = join( - REMOTE_CLIPBOARD_STAGING_ROOT, - '1760000000000-00000000-0000-4000-8000-000000000000' - ) - const tempPath = join(tempDir, 'report.pdf') - - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ ok: true }) - - expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') - expect(fsMkdirMock).toHaveBeenCalledWith(REMOTE_CLIPBOARD_STAGING_ROOT, { - recursive: true, - mode: 0o700 - }) - expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) - expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsStatMock).toHaveBeenCalledWith(tempPath) - expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() - expect(fsRmMock).not.toHaveBeenCalled() - }) - - it('does not materialize remote directories for OS clipboard copy', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ ok: false, reason: 'is-directory' }) - - expect(provider.downloadFile).not.toHaveBeenCalled() - expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - }) - - it('cleans up remote clipboard temp files when transfer fails', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - const tempDir = join( - REMOTE_CLIPBOARD_STAGING_ROOT, - '1760000000000-00000000-0000-4000-8000-000000000000' - ) - const tempPath = join(tempDir, 'report.pdf') - - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsRmMock).toHaveBeenCalledWith(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 - }) - expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - }) - it('rejects unauthorized local files before touching the OS clipboard', async () => { resolveAuthorizedPathMock.mockRejectedValue( new Error( @@ -607,12 +498,6 @@ describe('registerClipboardHandlers', () => { isEmpty: () => false, toPNG: () => png }) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - getSshFilesystemProviderMock.mockReturnValue({ - getTempDir: vi.fn().mockResolvedValue('/var/tmp'), - writeFileBase64 - }) - try { registerClipboardHandlers({} as never) @@ -620,19 +505,20 @@ describe('registerClipboardHandlers', () => { expect(probe?.(makeClipboardEvent())).toBe(true) expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow() const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') - await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + await expect(handler?.(makeClipboardEvent())).resolves.toMatch( + /orca-paste-1760000000000-00000000-0000-4000-8000-000000000000\.png$/ ) expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) expect(close).toHaveBeenCalled() - expect(writeFileBase64).toHaveBeenCalledWith( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') + expect(fsWriteFileMock).toHaveBeenCalledWith( + expect.stringMatching( + /orca-paste-1760000000000-00000000-0000-4000-8000-000000000000\.png$/ + ), + png ) - expect(fsWriteFileMock).not.toHaveBeenCalled() } finally { platformSpy.mockRestore() } @@ -717,7 +603,6 @@ describe('registerClipboardHandlers', () => { 30_000 ) expect(fsWriteFileMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('aborts remote runtime clipboard image uploads when a chunk fails', async () => { @@ -762,63 +647,6 @@ describe('registerClipboardHandlers', () => { expect(fsWriteFileMock).not.toHaveBeenCalled() }) - it('uploads clipboard images to the SSH host when a connection is provided', async () => { - const png = Buffer.from([0, 1, 2, 3]) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - const getTempDir = vi.fn().mockResolvedValue('/var/tmp') - clipboardReadImageMock.mockReturnValue({ - getSize: () => ({ height: 1, width: 1 }), - isEmpty: () => false, - toPNG: () => png - }) - getSshFilesystemProviderMock.mockReturnValue({ getTempDir, writeFileBase64 }) - - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { - connectionId: 'ssh-1' - }) - ).resolves.toBe('/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png') - expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-1') - expect(getTempDir).toHaveBeenCalled() - expect(writeFileBase64).toHaveBeenCalledWith( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') - ) - expect(fsWriteFileMock).not.toHaveBeenCalled() - }) - - it('uses Windows path joining for Windows SSH temp directories', async () => { - const png = Buffer.from([0, 1, 2, 3]) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - clipboardReadImageMock.mockReturnValue({ - getSize: () => ({ height: 1, width: 1 }), - isEmpty: () => false, - toPNG: () => png - }) - getSshFilesystemProviderMock.mockReturnValue({ - getTempDir: vi.fn().mockResolvedValue('C:\\Users\\alice\\AppData\\Local\\Temp'), - writeFileBase64 - }) - - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { - connectionId: 'ssh-1' - }) - ).resolves.toBe( - 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' - ) - expect(writeFileBase64).toHaveBeenCalledWith( - 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') - ) - }) - it('rejects oversized clipboard image dimensions before PNG conversion', async () => { const toPNG = vi.fn(() => Buffer.from([0, 1, 2, 3])) clipboardReadImageMock.mockReturnValue({ @@ -835,7 +663,6 @@ describe('registerClipboardHandlers', () => { ).rejects.toThrow('Clipboard image is too large') expect(toPNG).not.toHaveBeenCalled() expect(fsWriteFileMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('rejects oversized clipboard PNG bytes before SSH provider lookup', async () => { @@ -853,7 +680,6 @@ describe('registerClipboardHandlers', () => { connectionId: 'ssh-secret' }) ).rejects.toThrow('Clipboard image is too large') - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() expect(fsWriteFileMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 80dad3bd02b..3f567982b01 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -32,10 +32,10 @@ import { type ClipboardFileDeps, type ClipboardFileResult } from './clipboard-file-copy' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { cleanupExpiredRemoteClipboardFiles, - scheduleLegacyRemoteClipboardFileCleanup, - writeRemoteFileToClipboard + scheduleLegacyRemoteClipboardFileCleanup } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' @@ -184,6 +184,9 @@ export function registerClipboardHandlers(store: Store): void { if (!request) { return { ok: false, reason: 'invalid-path' } } + if (request.connectionId) { + throw new SshHostNotServedError(request.connectionId) + } const deps = makeClipboardFileDeps(async (path) => { try { const authorizedPath = await resolveDesktopAuthorizedPath(path, store) @@ -196,13 +199,6 @@ export function registerClipboardHandlers(store: Store): void { return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } } }) - if (request.connectionId) { - return writeRemoteFileToClipboard({ - remotePath: request.filePath, - connectionId: request.connectionId, - deps - }) - } return writeFileToClipboard(request.filePath, deps) } ) diff --git a/src/main/window/clipboard-macos-image-paste.test.ts b/src/main/window/clipboard-macos-image-paste.test.ts index 87ed0793b61..660d264030b 100644 --- a/src/main/window/clipboard-macos-image-paste.test.ts +++ b/src/main/window/clipboard-macos-image-paste.test.ts @@ -12,9 +12,7 @@ const mocks = vi.hoisted(() => ({ readImage: vi.fn(), readBuffer: vi.fn(), toPNG: vi.fn(), - writeFile: vi.fn(), - writeFileBase64: vi.fn(), - requireSshFilesystemProvider: vi.fn() + writeFile: vi.fn() })) vi.mock('electron', () => ({ @@ -37,13 +35,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...fs, default: { ...fs, writeFile: mocks.writeFile } } }) vi.mock('../persistence', () => ({ Store: vi.fn() })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: mocks.requireSshFilesystemProvider -})) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) vi.mock('./native-chat-paste-files', () => ({ restoreNativeChatPastes: vi.fn(), @@ -88,11 +82,6 @@ beforeEach(() => { mocks.has.mockImplementation((type: string) => type === 'public.png') mocks.toPNG.mockReturnValue(PNG) mocks.writeFile.mockResolvedValue(undefined) - mocks.writeFileBase64.mockResolvedValue(undefined) - mocks.requireSshFilesystemProvider.mockReturnValue({ - getTempDir: async () => '/remote/tmp', - writeFileBase64: mocks.writeFileBase64 - }) setImage() setTrustedClipboardRendererWebContentsId(17) registerClipboardHandlers(new Store()) @@ -149,21 +138,11 @@ describe('macOS clipboard image data beside a file URL', () => { expect(mocks.writeFile).not.toHaveBeenCalled() }) - it('rejects oversized PNG bytes before looking up an SSH target', async () => { + it('rejects oversized PNG bytes before refusing an SSH target', async () => { mocks.toPNG.mockReturnValue(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) await expect( invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) ).rejects.toThrow('Clipboard image is too large') - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - expect(mocks.writeFileBase64).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - }) - - it('saves native image data on the SSH host instead of the client', async () => { - const savedPath = await invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) - expect(savedPath).toMatch(/^\/remote\/tmp\/orca-paste-.+\.png$/) - expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') - expect(mocks.writeFileBase64).toHaveBeenCalledWith(savedPath, PNG.toString('base64')) expect(mocks.writeFile).not.toHaveBeenCalled() }) }) diff --git a/src/main/window/clipboard-remote-file-copy.test.ts b/src/main/window/clipboard-remote-file-copy.test.ts deleted file mode 100644 index 1f851e1ec09..00000000000 --- a/src/main/window/clipboard-remote-file-copy.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - cleanupLegacyRemoteClipboardStagingMock, - createRemoteClipboardTransferDirectoryMock, - downloadFileMock, - requireSshFilesystemProviderMock, - RemoteClipboardStagingRootUnsafeErrorMock, - spanFailMock, - startSpanMock -} = vi.hoisted(() => { - class RemoteClipboardStagingRootUnsafeErrorMock extends Error {} - const spanFailMock = vi.fn() - return { - cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined), - createRemoteClipboardTransferDirectoryMock: vi.fn(), - downloadFileMock: vi.fn(), - requireSshFilesystemProviderMock: vi.fn(), - RemoteClipboardStagingRootUnsafeErrorMock, - spanFailMock, - startSpanMock: vi.fn(() => ({ fail: spanFailMock })) - } -}) - -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: requireSshFilesystemProviderMock -})) -vi.mock('../observability/tracer', () => ({ startSpan: startSpanMock })) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) -vi.mock('./clipboard-remote-file-staging', () => ({ - cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined), - cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock, - createRemoteClipboardTransferDirectory: createRemoteClipboardTransferDirectoryMock, - RemoteClipboardStagingRootUnsafeError: RemoteClipboardStagingRootUnsafeErrorMock, - removeRemoteClipboardTransferDirectory: vi.fn(), - scheduleRemoteClipboardTransferCleanup: vi.fn() -})) - -import { - scheduleLegacyRemoteClipboardFileCleanup, - writeRemoteFileToClipboard -} from './clipboard-remote-file-copy' -import { RemoteClipboardStagingRootUnsafeError } from './clipboard-remote-file-staging' - -beforeEach(() => { - vi.clearAllMocks() - requireSshFilesystemProviderMock.mockReturnValue({ - stat: vi.fn(async () => ({ type: 'file' })), - downloadFile: downloadFileMock - }) -}) - -describe('remote clipboard staging failures', () => { - it('rejects remote directories before creating staging', async () => { - requireSshFilesystemProviderMock.mockReturnValue({ - stat: vi.fn(async () => ({ type: 'directory' })), - downloadFile: downloadFileMock - }) - - await expect( - writeRemoteFileToClipboard({ - remotePath: '/repo/src', - connectionId: 'ssh-1', - deps: { - platform: 'win32', - writeBuffer: vi.fn(), - runCommand: vi.fn() - } - }) - ).resolves.toEqual({ ok: false, reason: 'is-directory' }) - - expect(createRemoteClipboardTransferDirectoryMock).not.toHaveBeenCalled() - expect(downloadFileMock).not.toHaveBeenCalled() - }) - - it.each([ - { - category: 'unsafe-root', - error: new RemoteClipboardStagingRootUnsafeError() - }, - { - category: 'permissions', - error: Object.assign(new Error('access denied'), { code: 'EACCES' }) - }, - { - category: 'permissions', - error: Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) - }, - { - category: 'path-conflict', - error: Object.assign(new Error('path exists'), { code: 'EEXIST' }) - }, - { - category: 'unavailable', - error: new Error('storage offline') - } - ])( - 'returns a toast-safe reason and records $category diagnostics', - async ({ category, error }) => { - createRemoteClipboardTransferDirectoryMock.mockRejectedValueOnce(error) - - await expect( - writeRemoteFileToClipboard({ - remotePath: '/repo/readme.md', - connectionId: 'ssh-1', - deps: { - platform: 'win32', - writeBuffer: vi.fn(), - runCommand: vi.fn() - } - }) - ).resolves.toEqual({ ok: false, reason: 'staging-unavailable' }) - - expect(startSpanMock).toHaveBeenCalledWith('clipboard.remote_staging_init', { - attributes: { - operation: 'create', - platform: process.platform, - failure_category: category - } - }) - expect(spanFailMock).toHaveBeenCalledWith(error) - expect(downloadFileMock).not.toHaveBeenCalled() - } - ) -}) - -describe('legacy remote clipboard cleanup scheduling', () => { - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - it('runs the shared-root compatibility scan once after the startup delay', async () => { - vi.useFakeTimers() - vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000) - - scheduleLegacyRemoteClipboardFileCleanup() - scheduleLegacyRemoteClipboardFileCleanup() - await vi.advanceTimersByTimeAsync(29_999) - - expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - - expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce() - expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000) - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 47bbbc0c5bb..1deac08d2da 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,102 +1,13 @@ -import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' -import { join } from 'node:path' - import { app } from 'electron' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import { startSpan } from '../observability/tracer' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - writeFileToClipboard, - type ClipboardFileDeps, - type ClipboardFileResult -} from './clipboard-file-copy' import { cleanupExpiredRemoteClipboardStaging, - cleanupLegacyRemoteClipboardStaging, - createRemoteClipboardTransferDirectory, - RemoteClipboardStagingRootUnsafeError, - removeRemoteClipboardTransferDirectory, - scheduleRemoteClipboardTransferCleanup + cleanupLegacyRemoteClipboardStaging } from './clipboard-remote-file-staging' -type RemoteClipboardFileDeps = Omit - const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000 -const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i -const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) let legacyCleanupScheduled = false -export async function writeRemoteFileToClipboard({ - remotePath, - connectionId, - deps -}: { - remotePath: string - connectionId: string - deps: RemoteClipboardFileDeps -}): Promise { - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(remotePath) - if (remoteStat.type === 'directory') { - return { ok: false, reason: 'is-directory' } - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const tempRoot = app.getPath('temp') - let tempDir: string - try { - tempDir = await createRemoteClipboardTransferDirectory(tempRoot, Date.now(), randomUUID()) - } catch (error) { - const span = startSpan('clipboard.remote_staging_init', { - attributes: { - operation: 'create', - platform: process.platform, - failure_category: getStagingFailureCategory(error) - } - }) - span.fail(error instanceof Error ? error : String(error)) - return { ok: false, reason: 'staging-unavailable' } - } - const localPath = join( - tempDir, - sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) - ) - let keepTempFile = false - - try { - await provider.downloadFile(remotePath, localPath) - const result = await writeFileToClipboard(localPath, { - ...deps, - resolveFilePath: async (path) => { - if (path !== localPath) { - return { ok: false, reason: 'invalid-path' } - } - try { - await stat(path) - return { ok: true, path } - } catch { - return { ok: false, reason: 'not-found' } - } - } - }) - if (result.ok) { - // Why: OS file clipboards keep a path reference, so the staged copy must - // survive after this IPC call long enough for the user to paste it. - keepTempFile = true - scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir) - } - return result - } finally { - if (!keepTempFile) { - await removeRemoteClipboardTransferDirectory(tempRoot, tempDir) - } - } -} - export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { await cleanupExpiredRemoteClipboardStaging(app.getPath('temp'), nowMs) } @@ -116,34 +27,8 @@ export function scheduleLegacyRemoteClipboardFileCleanup(): void { unrefTimer(timer) } -function sanitizeLocalClipboardFilename(remoteBasename: string): string { - const sanitized = Array.from(remoteBasename, (char) => - char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char - ) - .join('') - .replace(/[. ]+$/g, '') - if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) { - return 'download' - } - return sanitized -} - function unrefTimer(timer: ReturnType): void { if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } } - -function getStagingFailureCategory(error: unknown): string { - if (error instanceof RemoteClipboardStagingRootUnsafeError) { - return 'unsafe-root' - } - const code = error instanceof Error && 'code' in error ? String(error.code) : undefined - if (code === 'EACCES' || code === 'EPERM') { - return 'permissions' - } - if (code === 'EEXIST' || code === 'ENOTDIR') { - return 'path-conflict' - } - return 'unavailable' -} diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index e2844455c38..bd24a43fe17 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -1,6 +1,5 @@ -// Nested Remote Orca Server -> SSH image paste (#17679). The REAL ssh-filesystem-dispatch registry -// is used on purpose: the runtime's SSH target is never registered in the client process, so any -// route that consults the local registry fails exactly the way the report did. +// Nested Remote Orca Server -> SSH image paste (#17679). This process serves no SSH filesystem, so +// any route that resolved the runtime's SSH target locally would fail the way the report did. import { beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' @@ -52,12 +51,7 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) import { registerClipboardHandlers } from './clipboard-ipc-handlers' -import { - getSshFilesystemProvider, - registerSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' type SaveImageHandler = (event: unknown, args?: unknown) => Promise @@ -115,13 +109,10 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { installFakeAppEnvironment({ getPath: () => '/tmp' }) callRuntimeEnvironmentMock.mockReset() fsWriteFileMock.mockReset() - unregisterSshFilesystemProvider(RUNTIME_SSH_TARGET) - unregisterSshFilesystemProvider(CLIENT_SSH_TARGET) }) it('sends the paste to the runtime and names the runtime SSH target, never this registry', async () => { mockRuntimeUpload() - expect(getSshFilesystemProvider(RUNTIME_SSH_TARGET)).toBeUndefined() // Built the way the renderer builds it: both owner ids, verbatim. const nestedArgs = { connectionId: RUNTIME_SSH_TARGET, runtimeEnvironmentId: RUNTIME_ID } @@ -188,25 +179,10 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { }) }) - it('keeps a client-dialed SSH paste on this process registry without touching the runtime', async () => { - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider(CLIENT_SSH_TARGET, { - getTempDir: async () => '/var/tmp', - writeFileBase64 - } as never) - + it('reports the dropped-connection verdict for a client-dialed SSH target', async () => { await expect( saveImageHandler()(rendererEvent, { connectionId: CLIENT_SSH_TARGET }) - ).resolves.toMatch(/^\/var\/tmp\/orca-paste-.*\.png$/) - - expect(writeFileBase64).toHaveBeenCalledTimes(1) - expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() - }) - - it('still reports the dropped-connection verdict for a client-dialed SSH target that is gone', async () => { - await expect( - saveImageHandler()(rendererEvent, { connectionId: CLIENT_SSH_TARGET }) - ).rejects.toThrow(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + ).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE) expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() }) diff --git a/src/main/workspace-space-analysis-capacity.test.ts b/src/main/workspace-space-analysis-capacity.test.ts index 42a2a2cee48..5de2931823e 100644 --- a/src/main/workspace-space-analysis-capacity.test.ts +++ b/src/main/workspace-space-analysis-capacity.test.ts @@ -37,14 +37,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - import { analyzeWorkspaceSpace } from './workspace-space-analysis' function createStore(repo: Repo): Store { diff --git a/src/main/workspace-space-analysis-du-timeout.test.ts b/src/main/workspace-space-analysis-du-timeout.test.ts index de467d2a7d2..da8d237d37b 100644 --- a/src/main/workspace-space-analysis-du-timeout.test.ts +++ b/src/main/workspace-space-analysis-du-timeout.test.ts @@ -31,14 +31,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - import { analyzeWorkspaceSpace } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index d6fbc4bb2d7..98d4ead1090 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -5,16 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../shared/repo-types' import type { Store } from './persistence' -const { - listRepoWorktreesMock, - getLocalProjectWorktreeGitOptionsMock, - getSshFilesystemProviderMock, - getSshGitProviderMock -} = vi.hoisted(() => ({ +const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ listRepoWorktreesMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getLocalProjectWorktreeGitOptionsMock: vi.fn() })) vi.mock('./repo-worktrees', () => ({ @@ -28,14 +21,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -67,8 +52,6 @@ describe('analyzeWorkspaceSpace', () => { tempDir = await mkdtemp(join(tmpdir(), 'orca-space-')) listRepoWorktreesMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshFilesystemProviderMock.mockReset() - getSshGitProviderMock.mockReset() }) afterEach(async () => { @@ -349,164 +332,6 @@ describe('analyzeWorkspaceSpace', () => { expect(result.worktrees[0]?.sizeBytes).toBe(0) }) - it('scans SSH worktrees through the remote filesystem provider without following symlinks', async () => { - const repo: Repo = { - id: 'repo-remote', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/feature', - head: 'c', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) - - const statByPath = new Map([ - ['/remote/feature', { size: 10, type: 'directory' as const, mtime: 0 }], - ['/remote/feature/node_modules', { size: 5, type: 'directory' as const, mtime: 0 }], - ['/remote/feature/node_modules/pkg.js', { size: 1000, type: 'file' as const, mtime: 0 }], - ['/remote/feature/file.log', { size: 200, type: 'file' as const, mtime: 0 }] - ]) - const readDirMock = vi.fn(async (dirPath: string) => { - if (dirPath === '/remote/feature') { - return [ - { name: 'node_modules', isDirectory: true, isSymlink: false }, - { name: 'linked-cache', isDirectory: true, isSymlink: true }, - { name: 'file.log', isDirectory: false, isSymlink: false } - ] - } - if (dirPath === '/remote/feature/node_modules') { - return [{ name: 'pkg.js', isDirectory: false, isSymlink: false }] - } - return [] - }) - const statMock = vi.fn(async (filePath: string) => { - const stat = statByPath.get(filePath) - if (!stat) { - throw Object.assign(new Error(`missing ${filePath}`), { code: 'ENOENT' }) - } - return stat - }) - getSshFilesystemProviderMock.mockReturnValue({ - readDir: readDirMock, - stat: statMock - }) - - const result = await analyzeWorkspaceSpace(createStore([repo])) - - expect(result.worktrees[0]?.status).toBe('ok') - expect(result.worktrees[0]?.sizeBytes).toBe(1215) - expect(result.worktrees[0]?.topLevelItems.map((item) => item.name)).toEqual([ - 'node_modules', - 'file.log', - 'linked-cache' - ]) - expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache') - }) - - it('uses the SSH bulk Space scan provider when available', async () => { - const repo: Repo = { - id: 'repo-remote', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/feature', - head: 'c', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) - const scanWorkspaceSpace = vi.fn().mockResolvedValue({ - sizeBytes: 4096, - skippedEntryCount: 0, - topLevelItems: [ - { - name: 'node_modules', - path: '/remote/feature/node_modules', - kind: 'directory', - sizeBytes: 4096 - } - ], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - }) - const readDir = vi.fn().mockResolvedValue([]) - const stat = vi.fn() - getSshFilesystemProviderMock.mockReturnValue({ scanWorkspaceSpace, readDir, stat }) - - const result = await analyzeWorkspaceSpace(createStore([repo])) - - expect(scanWorkspaceSpace).toHaveBeenCalledWith( - '/remote/feature', - expect.objectContaining({ signal: undefined }) - ) - expect(readDir).not.toHaveBeenCalled() - expect(stat).not.toHaveBeenCalled() - expect(result.worktrees[0]?.sizeBytes).toBe(4096) - }) - - it('bounds concurrent SSH fallback traversals in the main process', async () => { - // Why: each fallback traversal holds its own admission budget, so repo × - // worktree concurrency alone would stack six of them on the desktop heap. - const repos: Repo[] = ['ssh-1', 'ssh-2'].map((connectionId) => ({ - id: `repo-${connectionId}`, - path: `/remote/${connectionId}`, - displayName: connectionId, - badgeColor: '#000', - addedAt: 0, - connectionId - })) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async (repoPath: string) => - [0, 1, 2].map((index) => ({ - path: `${repoPath}/worktree-${index}`, - head: 'c', - branch: `refs/heads/worktree-${index}`, - isBare: false, - isMainWorktree: false - })) - ) - }) - - const releases: (() => void)[] = [] - let active = 0 - let peak = 0 - const stat = vi.fn(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active -= 1 - return { size: 0, type: 'directory' as const, mtime: 0 } - }) - getSshFilesystemProviderMock.mockReturnValue({ readDir: vi.fn(async () => []), stat }) - - const scan = analyzeWorkspaceSpace(createStore(repos)) - for (let index = 0; index < 6; index += 1) { - await vi.waitFor(() => expect(releases.length).toBeGreaterThan(index)) - releases[index]!() - } - - await expect(scan).resolves.toMatchObject({ scannedWorktreeCount: 6 }) - expect(peak).toBe(2) - }) - it('reports disconnected SSH repos without failing the whole analysis', async () => { const repo: Repo = { id: 'repo-remote', @@ -516,7 +341,6 @@ describe('analyzeWorkspaceSpace', () => { addedAt: 0, connectionId: 'ssh-1' } - getSshGitProviderMock.mockReturnValue(undefined) const result = await analyzeWorkspaceSpace(createStore([repo])) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 6c7ad082958..08c8ab7a3bf 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -21,7 +21,6 @@ import { const REPO_SCAN_CONCURRENCY = 2 const LOCAL_WORKTREE_SCAN_CONCURRENCY = 1 -const REMOTE_FALLBACK_SCAN_CONCURRENCY = 2 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 @@ -129,11 +128,7 @@ export async function analyzeWorkspaceSpace( } options.onProgress?.({ ...progress }) const limiters: WorkspaceSpaceScanLimiters = { - localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal), - remoteFallbackTraversal: createWorkspaceSpaceScanLimiter( - REMOTE_FALLBACK_SCAN_CONCURRENCY, - options.signal - ) + localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal) } const repoResults = await mapWithConcurrency(reposToScan, REPO_SCAN_CONCURRENCY, (repo) => scanWorkspaceSpaceRepo({ diff --git a/src/main/workspace-space-remote-scan.ts b/src/main/workspace-space-remote-scan.ts deleted file mode 100644 index c7936127f81..00000000000 --- a/src/main/workspace-space-remote-scan.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { DirEntry } from '../shared/filesystem-entry-types' -import type { Repo } from '../shared/repo-types' -import type { Worktree } from '../shared/worktree/types' -import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' -import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' -import { scanWorkspaceSpaceEntryTree } from '../shared/workspace-space-entry-traversal' -import type { IFilesystemProvider } from './providers/types' -import { - WorkspaceSpaceScanCancelledError, - classifyWorkspaceSpaceError, - isWorkspaceSpaceAbortError, - isWorkspaceSpaceRelayMethodMissing, - throwIfWorkspaceSpaceScanAborted, - type AsyncLimiter -} from './workspace-space-scan-control' -import { - basenameWorkspaceFilesystemPath, - createScannedWorkspaceSpaceRow, - createUnavailableWorkspaceSpaceRow, - joinWorkspaceFilesystemPath, - toWorkspaceSpaceItem -} from './workspace-space-worktree-row' - -const REMOTE_FS_CONCURRENCY = 10 - -async function scanRemoteEntry( - entryPath: string, - name: string, - provider: IFilesystemProvider, - signal?: AbortSignal -) { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: REMOTE_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinWorkspaceFilesystemPath, - classifyEntry: async (path, sourceEntry) => { - if (sourceEntry?.isSymlink) { - return { kind: 'symlink', sizeBytes: 0 } - } - const stats = await provider.stat(path) - throwIfWorkspaceSpaceScanAborted(signal) - if (stats.type === 'symlink') { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.type === 'directory' - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => provider.readDir(path), - checkCancelled: () => throwIfWorkspaceSpaceScanAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) -} - -export async function scanRemoteWorkspaceSpaceWorktree( - repo: Repo, - worktree: Worktree, - scannedAt: number, - provider: IFilesystemProvider, - fallbackTraversalLimit: AsyncLimiter, - signal?: AbortSignal -): Promise { - try { - if (provider.scanWorkspaceSpace) { - try { - const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) - return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, scan) - } catch (error) { - if (isWorkspaceSpaceAbortError(error)) { - throw new WorkspaceSpaceScanCancelledError() - } - if (!isWorkspaceSpaceRelayMethodMissing(error)) { - throw error - } - } - } - const root = await fallbackTraversalLimit(() => - scanRemoteEntry( - worktree.path, - basenameWorkspaceFilesystemPath(worktree.path), - provider, - signal - ) - ) - return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) - }) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - const classified = classifyWorkspaceSpaceError(error) - return createUnavailableWorkspaceSpaceRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } -} diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index fe2edbcebd3..370b803f345 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -16,8 +16,6 @@ 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' @@ -28,7 +26,6 @@ import { type AsyncLimiter } from './workspace-space-scan-control' import { createUnavailableWorkspaceSpaceRow } from './workspace-space-worktree-row' -import { scanRemoteWorkspaceSpaceWorktree } from './workspace-space-remote-scan' import { scanLocalWorkspaceSpaceWorktree, type ReadLocalDuDepthOne @@ -48,7 +45,6 @@ export type WorkspaceSpaceAnalyzeOptions = { export type WorkspaceSpaceScanLimiters = { localWorktree: AsyncLimiter - remoteFallbackTraversal: AsyncLimiter } export type WorkspaceSpaceRepoScanResult = { @@ -104,17 +100,11 @@ async function listWorktreesForSpaceScan( } } if (route.kind === 'ssh') { - const provider = getSshGitProvider(route.connectionId) - if (!provider) { - return { - ok: false, - status: 'unavailable', - error: `SSH connection "${route.connectionId}" is not connected.` - } + return { + ok: false, + status: 'unavailable', + error: `SSH connection "${route.connectionId}" is not connected.` } - const worktrees = await provider.listWorktrees(repo.path, { signal }) - throwIfWorkspaceSpaceScanAborted(signal) - return { ok: true, worktrees } } const worktrees = await listRepoWorktrees(repo, { ...getLocalProjectWorktreeGitOptions(store, repo), @@ -208,8 +198,6 @@ 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( @@ -222,24 +210,15 @@ export async function scanWorkspaceSpaceRepo(args: { ) const row = filesystemRoute.kind !== 'local' - ? sshFilesystemProvider - ? await scanRemoteWorkspaceSpaceWorktree( - repo, - worktree, - scannedAt, - sshFilesystemProvider, - limiters.remoteFallbackTraversal, - options.signal - ) - : createUnavailableWorkspaceSpaceRow( - repo, - worktree, - scannedAt, - 'unavailable', - filesystemRoute.kind === 'ssh' - ? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.` - : `Host ${filesystemRoute.hostId} is not reachable from this process.` - ) + ? createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + 'unavailable', + filesystemRoute.kind === 'ssh' + ? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.` + : `Host ${filesystemRoute.hostId} is not reachable from this process.` + ) : await limiters.localWorktree(() => scanLocalWorkspaceSpaceWorktree( repo, diff --git a/src/main/workspace-space-scan-control.ts b/src/main/workspace-space-scan-control.ts index 3d8aa73bfb1..8235222acab 100644 --- a/src/main/workspace-space-scan-control.ts +++ b/src/main/workspace-space-scan-control.ts @@ -22,12 +22,6 @@ export function isWorkspaceSpaceAbortError(error: unknown): boolean { ) } -export function isWorkspaceSpaceRelayMethodMissing(error: unknown): boolean { - return Boolean( - error && typeof error === 'object' && (error as { code?: unknown }).code === -32601 - ) -} - export function classifyWorkspaceSpaceError(error: unknown): { status: Exclude message: string diff --git a/src/main/worktree-removal-execution-host-route.ts b/src/main/worktree-removal-execution-host-route.ts index 0de4d2a1f36..01c81c64e57 100644 --- a/src/main/worktree-removal-execution-host-route.ts +++ b/src/main/worktree-removal-execution-host-route.ts @@ -12,7 +12,7 @@ * reachable by migrating the cleanup scan, so those rows now surface as removable candidates. * * Routing is now one answer for the whole removal, taken from the host the prune already used, so - * list, remove and prune cannot disagree. The ambiguous `provider: SshGitProvider | null` carrier + * list, remove and prune cannot disagree. The ambiguous nullable SSH-provider carrier * is deleted from the callees rather than supplemented, which makes every remaining reader a * compile error in the typed modules that do the destructive work. * diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx deleted file mode 100644 index 7b2934b8e82..00000000000 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -// @vitest-environment happy-dom - -import { act, renderHook, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import type { GitStatusResult } from '../../../../shared/git-status-types' -import type { Repo } from '../../../../shared/repo-types' -import type { GitWorktreeInfo } from '../../../../shared/worktree/types' -import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity' -import type { - WorkspaceCleanupScanArgs, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' - -const { - listRepoWorktreesMock, - getStatusMock, - gitExecFileAsyncMock, - getLocalProjectWorktreeGitOptionsMock, - getSshGitProviderMock -} = vi.hoisted(() => ({ - listRepoWorktreesMock: vi.fn(), - getStatusMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshGitProviderMock: vi.fn() -})) - -vi.mock('../../../../main/repo-worktrees', () => ({ - listRepoWorktrees: listRepoWorktreesMock, - createFolderWorktree: vi.fn() -})) -vi.mock('../../../../main/git/status', () => ({ getStatus: getStatusMock })) -vi.mock('../../../../main/git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../../../../main/project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock -})) -vi.mock('../../../../main/providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) -vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })) - -import { useAppStore } from '@/store' -import { makeWorktree } from '../../store/slices/worktrees-slice-test-fixtures' -import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from '@/store/slices/worktrees' -import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal' - -const SSH_CONNECTION_ID = 'host-b' -const HOST_A_ID = 'local' -const HOST_B_ID = `ssh:${SSH_CONNECTION_ID}` as const -const REPO_ID = 'colliding-repo' -const DAY_MS = 24 * 60 * 60 * 1000 -const SCANNER_MODULE_PATH = ['../../../../main/ipc/', 'workspace-cleanup-scan'].join('') -const initialState = useAppStore.getInitialState() -const temporaryRoots: string[] = [] - -type ScanWorkspaceCleanup = ( - store: unknown, - args?: WorkspaceCleanupScanArgs -) => Promise - -function makeRepo(overrides: Partial): Repo { - return { - id: REPO_ID, - path: '/repo', - displayName: 'Colliding repo', - badgeColor: '#000', - addedAt: 0, - ...overrides - } -} - -function toHostRelativePath(worktreeId: string): string { - const workspacePath = worktreeId.slice(worktreeId.indexOf('::') + 2) - return workspacePath - .replace(/^[a-zA-Z]:/, '') - .split(/[/\\]+/) - .filter(Boolean) - .join(path.sep) -} - -function createMarker(root: string, worktreeId: string, name: string): string { - const worktreePath = path.join(root, toHostRelativePath(worktreeId)) - fs.mkdirSync(worktreePath, { recursive: true }) - const markerPath = path.join(worktreePath, name) - fs.writeFileSync(markerPath, name) - return markerPath -} - -describe('workspace cleanup scanned host confirmation removal', () => { - beforeEach(() => { - resetAuthoritativelyRemovedWorktreeMemoryForTests() - getStatusMock.mockReset().mockResolvedValue({ - entries: [], - conflictOperation: 'unknown', - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } - } satisfies GitStatusResult) - gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) - getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - }) - - afterEach(() => { - useAppStore.setState(initialState, true) - Reflect.deleteProperty(window, 'api') - vi.clearAllMocks() - for (const root of temporaryRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }) - } - }) - - it('deletes only host B after its real scanned candidate crosses the confirmation path', async () => { - const scanRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-scan-')) - const hostARoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-a-')) - const hostBRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'orca-cleanup-host-b-')) - temporaryRoots.push(scanRoot, hostARoot, hostBRoot) - const sharedPath = path.join(scanRoot, 'shared-workspace') - const gitPath = path.join(sharedPath, '.git') - fs.mkdirSync(gitPath, { recursive: true }) - const oldTime = new Date(Date.now() - 40 * DAY_MS) - fs.utimesSync(sharedPath, oldTime, oldTime) - fs.utimesSync(gitPath, oldTime, oldTime) - const worktreeId = `${REPO_ID}::${sharedPath}` - const hostAMarker = createMarker(hostARoot, worktreeId, 'HOST_A_MARKER') - const hostBMarker = createMarker(hostBRoot, worktreeId, 'HOST_B_MARKER') - const localRepo = makeRepo({ executionHostId: HOST_A_ID }) - const sshRepo = makeRepo({ - path: '/remote/repo', - connectionId: SSH_CONNECTION_ID, - executionHostId: HOST_B_ID - }) - const gitWorktree: GitWorktreeInfo = { - path: sharedPath, - head: 'abc123', - branch: 'refs/heads/shared-workspace', - isBare: false, - isMainWorktree: false - } - const sshProvider = { - listWorktrees: vi.fn().mockResolvedValue([gitWorktree]), - getStatus: vi.fn().mockResolvedValue({ - entries: [], - conflictOperation: 'unknown', - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } - } satisfies GitStatusResult), - exec: vi.fn().mockResolvedValue({ stdout: '0\n', stderr: '' }) - } - listRepoWorktreesMock.mockResolvedValue([gitWorktree]) - getSshGitProviderMock.mockReturnValue(sshProvider) - const scanStore = { - getRepos: () => [localRepo, sshRepo], - getWorktreeMeta: () => undefined, - getAllWorktreeMeta: () => ({}), - getGitHubCache: () => ({ pr: {}, issue: {} }) - } - const { scanWorkspaceCleanup } = await vi.importActual<{ - scanWorkspaceCleanup: ScanWorkspaceCleanup - }>(SCANNER_MODULE_PATH) - const scan = vi.fn((args) => scanWorkspaceCleanup(scanStore, args)) - const routedHostIds: string[] = [] - const remove = vi.fn(async (args: { worktreeId: string; hostId?: string }) => { - routedHostIds.push(args.hostId ?? '') - const root = - args.hostId === HOST_A_ID ? hostARoot : args.hostId === HOST_B_ID ? hostBRoot : null - if (root) { - fs.rmSync(path.join(root, toHostRelativePath(args.worktreeId)), { - recursive: true, - force: true - }) - } - return { ok: true as const } - }) - Object.assign(window, { - api: { - worktrees: { remove, forgetLocal: vi.fn() }, - hooks: { check: vi.fn().mockResolvedValue({ hasHooks: false, hooks: null }) }, - workspaceCleanup: { - scan, - cancelScan: vi.fn().mockResolvedValue(undefined), - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - recordRemovalSnapshotPrune: vi.fn().mockResolvedValue(undefined) - }, - pty: { kill: vi.fn().mockResolvedValue(undefined) }, - runtimeEnvironments: { call: vi.fn() }, - ephemeralVm: { listRuntimes: vi.fn().mockResolvedValue([]), cleanup: vi.fn() } - } - }) - useAppStore.setState({ - repos: [localRepo, sshRepo], - activeWorktreeId: worktreeId, - activeWorkspaceExecutionHostId: HOST_A_ID, - worktreesByRepo: { - [REPO_ID]: [ - makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_A_ID }), - makeWorktree({ id: worktreeId, repoId: REPO_ID, path: sharedPath, hostId: HOST_B_ID }) - ] - } - }) - - expect(fs.existsSync(hostAMarker)).toBe(true) - expect(fs.existsSync(hostBMarker)).toBe(true) - const scanned = await useAppStore.getState().scanWorkspaceCleanup() - const hostBCandidate = scanned.candidates.find( - (candidate) => candidate.executionHostId === HOST_B_ID - ) - expect(hostBCandidate).toMatchObject({ worktreeId, executionHostId: HOST_B_ID }) - expect( - scanned.candidates.filter((candidate) => candidate.worktreeId === worktreeId) - ).toHaveLength(2) - const onDeselect = vi.fn() - const removal = renderHook(() => - useWorkspaceCleanupRemoval({ onDeselect, closeModal: vi.fn() }) - ) - - act(() => removal.result.current.openConfirmRemove([hostBCandidate!])) - expect(removal.result.current.confirmCandidates[0]).toBe(hostBCandidate) - act(() => removal.result.current.confirmRemove()) - await waitFor(() => expect(removal.result.current.removalInFlight).toBe(false)) - - expect(fs.existsSync(hostAMarker), 'host A marker must survive host B confirmation').toBe(true) - expect(fs.existsSync(hostBMarker), 'host B marker must be deleted').toBe(false) - expect(routedHostIds).toEqual([HOST_B_ID]) - expect(remove).toHaveBeenCalledTimes(1) - expect(scan).toHaveBeenCalledTimes(2) - expect(scan.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ worktreeIds: [worktreeId], refreshActivity: true }) - ) - expect(onDeselect).toHaveBeenCalledWith([getWorkspaceCleanupCandidateIdentity(hostBCandidate!)]) - }) -}) diff --git a/src/shared/git-exec-mutation.test.ts b/src/shared/git-exec-mutation.test.ts deleted file mode 100644 index bca770c3d6c..00000000000 --- a/src/shared/git-exec-mutation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { gitExecMutatesRepository } from './git-exec-mutation' - -describe('gitExecMutatesRepository', () => { - it.each([ - [['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git']], - [['remote', 'remove', 'pr-contributor-orca']], - [['clone', '--', 'https://github.com/stablyai/orca.git', 'orca']], - [['commit', '--allow-empty', '-m', 'Initial commit']], - [['init']] - ])('treats %j as mutating', (args) => { - expect(gitExecMutatesRepository(args)).toBe(true) - }) - - it.each([ - // Why: these run on read-heavy paths; misclassifying them would flush the - // git read cache on every remote probe. - [['remote']], - [['remote', '-v']], - [['remote', 'get-url', 'origin']], - [['remote', 'show', 'origin']], - [['rev-parse', '--show-toplevel']], - [[]] - ])('treats %j as read-only', (args) => { - expect(gitExecMutatesRepository(args)).toBe(false) - }) -}) diff --git a/src/shared/git-exec-mutation.ts b/src/shared/git-exec-mutation.ts deleted file mode 100644 index 0c88401a682..00000000000 --- a/src/shared/git-exec-mutation.ts +++ /dev/null @@ -1,13 +0,0 @@ -const MUTATING_GIT_EXEC_SUBCOMMANDS = new Set(['clone', 'commit', 'init']) -// Why: `git remote` also serves the bare list and get-url reads, so only the -// permitted fork-remote writes may invalidate cached reads. -const MUTATING_GIT_REMOTE_ACTIONS = new Set(['add', 'remove']) - -// Why: relay git.exec permits these narrow write shapes alongside read-only -// probes, so cache invalidation must distinguish them before dispatch. -export function gitExecMutatesRepository(args: readonly string[]): boolean { - if (args[0] === 'remote') { - return MUTATING_GIT_REMOTE_ACTIONS.has(args[1] ?? '') - } - return MUTATING_GIT_EXEC_SUBCOMMANDS.has(args[0] ?? '') -}