refactor(providers): delete the SSH git and filesystem providers and relay-only wiring (#27644)

* refactor(providers): delete the SSH git and filesystem providers and relay-only wiring

* refactor(ssh): drop relay-protocol exports only the deleted stream readers used

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 17:48:43 -07:00
committed by GitHub
co-authored by m4air
parent c9097ac991
commit c5224f70d8
305 changed files with 862 additions and 14655 deletions
@@ -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 })
+4 -32
View File
@@ -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)
@@ -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: ''
+3 -28
View File
@@ -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()
})
})
@@ -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<typeof mintDocPreview
beforeEach(() => {
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: '<h1>hi</h1>', 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('<h1>hi</h1>', '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: '<h1>whole</h1>', 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()
})
})
+4 -27
View File
@@ -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(
+3 -1
View File
@@ -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}`,
+2 -1
View File
@@ -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
+4 -72
View File
@@ -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<T>(): { promise: Promise<T>; 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 () => {
+3 -19
View File
@@ -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<Ref>(
},
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<Ref>(
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<Ref>(
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) {
+1 -32
View File
@@ -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)
+3 -13
View File
@@ -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<string | null> {
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,
@@ -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 () => '')
}))
+2 -49
View File
@@ -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 })
+3 -29
View File
@@ -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()
})
})
+2 -80
View File
@@ -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({
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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()
}))
@@ -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 }
@@ -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)
)
-1
View File
@@ -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)
)
@@ -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)
)
@@ -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')
@@ -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)
)
-1
View File
@@ -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)
)
-1
View File
@@ -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()
+2 -16
View File
@@ -23,7 +23,6 @@ export type GitHubClientMocks = {
noteRateLimitSpendMock: Mock
ghRepoExecOptionsMock: Mock<typeof ghRepoExecOptions>
githubRepoContextMock: Mock<typeof githubRepoContext>
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 {
@@ -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)
)
@@ -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)
)
@@ -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)
)
@@ -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, () =>
@@ -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.
@@ -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,
@@ -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()
+3 -62
View File
@@ -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()
})
@@ -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'}`
}
@@ -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<typeof SshGitDispatch>()),
getSshGitProviderGeneration: getSshGitProviderGenerationMock
}))
vi.mock('./gh-utils', async (importOriginal) => ({
...(await importOriginal<typeof GhUtils>()),
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<typeof oldRepository>((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', () => {
@@ -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.
@@ -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<string, Promise<string | null | undefined>>()
// 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'}`
@@ -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
}))
@@ -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
}))
@@ -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')
@@ -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()
@@ -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<string | null> {
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
+9 -51
View File
@@ -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()
})
+21 -33
View File
@@ -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<void> {
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
}
@@ -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()
}))
@@ -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)
})
})
+16 -40
View File
@@ -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<string, readonly string[]>()
const knownHostsInFlightByExecutionContext: CoalescedProbes<readonly string[]> = new Map()
const unauthenticatedHostExpiries = new Map<string, number>()
@@ -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<readonly string[]> {
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<readonly string[]> {
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]
}
}
@@ -1,42 +1,16 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const { execute, generations } = vi.hoisted(() => ({
execute: vi.fn(),
generations: new Map<string, number>()
}))
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<ReturnType<typeof response>>()
async function collect(): Promise<void> {
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<void>((resolve) => setImmediate(resolve))
globalThis.gc()
}
await new Promise<void>((resolve) => setImmediate(resolve))
}
async function weakResult(connectionId: string): Promise<WeakRef<readonly string[]>> {
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<readonly string[]>[] = []
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<readonly string[]>[] = []
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)
})
@@ -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<ProjectRef | null> {
// 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) {
+2 -72
View File
@@ -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', () => {
+2 -48
View File
@@ -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'))
+1 -13
View File
@@ -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.
+8 -37
View File
@@ -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()
})
+16 -23
View File
@@ -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
}
@@ -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
@@ -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
@@ -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
@@ -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(
@@ -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<unknown>
@@ -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'])
})
})
-108
View File
@@ -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<void> {
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<void> {
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<DownloadFolderResult> => {
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)
}
}
)
}
@@ -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'
@@ -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<void>((_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)
})
})
@@ -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)
@@ -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']) {
@@ -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<ReturnType<typeof lstat>>
): Promise<string> {
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<boolean> {
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<void> {
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<void> {
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}`)
}
}
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown>>()
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<typeof SshTargetRegistry>('../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<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(null, {
...args,
...(typeof args.connectionId === 'string'
? {
expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId,
expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0
}
: {})
}) as Promise<{
results: Record<string, unknown>[]
}>
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' })
})
})
@@ -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<typeof SshTargetRegistry>('../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()
})
})
@@ -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<boolean> {
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
)
)
}
-282
View File
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown>>()
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<typeof SshTargetRegistry>('../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<string, unknown>) =>
handlers.get('fs:importExternalPaths')!(
null,
typeof args.connectionId === 'string'
? {
...args,
expectedSshTargetId: args.connectionId,
expectedSshConnectionGeneration: 0
}
: args
) as Promise<{
results: Record<string, unknown>[]
}>
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')
})
})
-195
View File
@@ -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<string>()
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<string>,
remotePathFlavor: RemotePathFlavor,
assertCurrent?: () => void
): Promise<ImportItemResult> {
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<void> {
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)
}
@@ -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
@@ -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
+28 -119
View File
@@ -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 () => {
+10 -54
View File
@@ -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)
}
)
}
@@ -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
+6 -29
View File
@@ -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))))
-8
View File
@@ -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
@@ -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<never> => {
validateRequiredString(args?.dirPath, 'dirPath')
const connectionId = validateRequiredString(args?.connectionId, 'connectionId')
throw new SshHostNotServedError(connectionId)
}
)
ipcMain.handle(
'fs:saveDownloadedFile',
@@ -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 = {
@@ -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()
})
})
@@ -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<void> {
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
)
})
}
@@ -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
}
}
@@ -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)
@@ -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()
@@ -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<unknown>,
@@ -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()
@@ -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
}))
@@ -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)
},
+4 -6
View File
@@ -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(
@@ -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<typeof FsPromises>()),
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<PtySpawnIpcArgs> = {}, 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 () => {
@@ -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'
-8
View File
@@ -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' }
@@ -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'

Some files were not shown because too many files have changed in this diff Show More