feat(ssh): support file transfers over system ssh (#7804)
* feat(ssh): support file transfers over system ssh * fix(ssh): harden system file transfers * test(runtime): stub getRepo in headless mobile tab cwd test The mobile-session selector validator (getValidatedExplicitWorktreeIdSelector, from main) calls this.store?.getRepo to reject repo ids passed as worktree ids. The store stub only implemented getWorkspaceSession, so the guarded call threw 'getRepo is not a function' once main merged into this branch. Add a getRepo that returns null (wt-1 is a worktree, not a repo). Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3472acfb22
commit
c2aa9c2ead
|
|
@ -1,5 +1,6 @@
|
|||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const {
|
||||
|
|
@ -9,11 +10,6 @@ const {
|
|||
realpathMock,
|
||||
copyFileMock,
|
||||
readdirMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
removeDirectorySftpMock,
|
||||
mkdirSftpMock,
|
||||
getConnMgrMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
|
|
@ -22,11 +18,6 @@ const {
|
|||
realpathMock: vi.fn(),
|
||||
copyFileMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
sftpExistsMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
uploadDirMock: vi.fn(),
|
||||
removeDirectorySftpMock: vi.fn(),
|
||||
mkdirSftpMock: vi.fn(),
|
||||
getConnMgrMock: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -40,16 +31,13 @@ vi.mock('fs/promises', () => ({
|
|||
copyFile: copyFileMock,
|
||||
readdir: readdirMock
|
||||
}))
|
||||
vi.mock('../ssh/sftp-upload', () => ({
|
||||
sftpPathExists: sftpExistsMock,
|
||||
uploadFile: uploadFileMock,
|
||||
uploadDirectory: uploadDirMock,
|
||||
removeDirectorySftp: removeDirectorySftpMock,
|
||||
mkdirSftp: mkdirSftpMock
|
||||
}))
|
||||
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
|
|
@ -65,13 +53,38 @@ const store = {
|
|||
}
|
||||
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'
|
||||
const mockSftp = { end: vi.fn() }
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
const makeConn = () => ({
|
||||
getState: () => ({ status: 'connected' }),
|
||||
sftp: vi.fn().mockResolvedValue(mockSftp)
|
||||
sftp: vi.fn()
|
||||
})
|
||||
const mockDir = (p: string): void => {
|
||||
const rp = path.resolve(p)
|
||||
|
|
@ -82,6 +95,22 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
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) as Promise<{
|
||||
results: Record<string, unknown>[]
|
||||
|
|
@ -96,37 +125,36 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
realpathMock,
|
||||
copyFileMock,
|
||||
readdirMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
removeDirectorySftpMock,
|
||||
mkdirSftpMock,
|
||||
getConnMgrMock
|
||||
].forEach((m) => m.mockReset())
|
||||
mockSftp.end.mockReset()
|
||||
handleMock.mockImplementation((ch: string, h: never) => {
|
||||
handlers.set(ch, h)
|
||||
})
|
||||
realpathMock.mockImplementation(async (p: string) => p)
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
sftpExistsMock.mockResolvedValue(false)
|
||||
uploadFileMock.mockResolvedValue(undefined)
|
||||
uploadDirMock.mockResolvedValue(undefined)
|
||||
removeDirectorySftpMock.mockResolvedValue(undefined)
|
||||
mkdirSftpMock.mockResolvedValue(undefined)
|
||||
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)
|
||||
})
|
||||
|
||||
it('deconflicts file names via SFTP lstat', async () => {
|
||||
const rp = path.resolve('/tmp/dropped/logo.png')
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
afterEach(() => {
|
||||
unregisterSshFilesystemProvider(connId)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/logo.png`)
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/logo.png'],
|
||||
destDir,
|
||||
|
|
@ -157,66 +185,112 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
|
||||
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 (sources.map((s) => path.resolve(s)).includes(p)) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
if (resolved.has(p)) {
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
uploadFileMock.mockImplementation(async (_s: unknown, lp: string) => {
|
||||
if (lp === path.resolve('/tmp/dropped/bad.txt')) {
|
||||
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('uploads directories via mkdirSftp + uploadDirectory', async () => {
|
||||
mockDir('/tmp/dropped/assets')
|
||||
readdirMock.mockResolvedValue([])
|
||||
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(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, {
|
||||
allowExisting: false
|
||||
expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`)
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, {
|
||||
exclusive: true
|
||||
})
|
||||
expect(uploadDirMock).toHaveBeenCalledWith(
|
||||
mockSftp,
|
||||
path.resolve('/tmp/dropped/assets'),
|
||||
`${destDir}/assets`,
|
||||
path.resolve('/tmp/dropped/assets'),
|
||||
{ exclusive: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('reports per-item failure when deconfliction throws', async () => {
|
||||
const rp = path.resolve('/tmp/dropped/file.txt')
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
sftpExistsMock.mockRejectedValue(new Error('SFTP channel closed'))
|
||||
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: 'SFTP channel closed' })
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' })
|
||||
})
|
||||
|
||||
it('reports failure when mkdirSftp rejects', async () => {
|
||||
it('reports failure when creating a directory rejects', async () => {
|
||||
mockDir('/tmp/dropped/mydir')
|
||||
readdirMock.mockResolvedValue([])
|
||||
mkdirSftpMock.mockRejectedValue(new Error('permission denied'))
|
||||
vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied'))
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/mydir'],
|
||||
destDir,
|
||||
|
|
@ -225,10 +299,38 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' })
|
||||
})
|
||||
|
||||
it('removes a created SSH directory import root when uploadDirectory fails', async () => {
|
||||
mockDir('/tmp/dropped/assets')
|
||||
readdirMock.mockResolvedValue([])
|
||||
uploadDirMock.mockRejectedValue(new Error('disk full'))
|
||||
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'],
|
||||
|
|
@ -237,16 +339,18 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
})
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' })
|
||||
expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, {
|
||||
allowExisting: false
|
||||
})
|
||||
expect(removeDirectorySftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`)
|
||||
expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`)
|
||||
expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
|
||||
})
|
||||
|
||||
it('deconflicts directory names via SFTP lstat', async () => {
|
||||
it('deconflicts directory names via provider stat', async () => {
|
||||
mockDir('/tmp/dropped/assets')
|
||||
readdirMock.mockResolvedValue([])
|
||||
sftpExistsMock.mockImplementation(async (_s: unknown, p: string) => p === `${destDir}/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,
|
||||
|
|
@ -281,7 +385,7 @@ describe('fs:importExternalPaths — SSH operations', () => {
|
|||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' })
|
||||
expect(uploadDirMock).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports skipped when source lstat returns EACCES', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import path from 'node:path'
|
||||
import { constants } from 'node:fs'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
const {
|
||||
|
|
@ -14,11 +14,6 @@ const {
|
|||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
removeDirectorySftpMock,
|
||||
mkdirSftpMock,
|
||||
getConnMgrMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
|
|
@ -29,11 +24,6 @@ const {
|
|||
openMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
unlinkMock: vi.fn(),
|
||||
sftpExistsMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
uploadDirMock: vi.fn(),
|
||||
removeDirectorySftpMock: vi.fn(),
|
||||
mkdirSftpMock: vi.fn(),
|
||||
getConnMgrMock: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -50,16 +40,13 @@ vi.mock('fs/promises', () => ({
|
|||
unlink: unlinkMock,
|
||||
rm: vi.fn()
|
||||
}))
|
||||
vi.mock('../ssh/sftp-upload', () => ({
|
||||
sftpPathExists: sftpExistsMock,
|
||||
uploadFile: uploadFileMock,
|
||||
uploadDirectory: uploadDirMock,
|
||||
removeDirectorySftp: removeDirectorySftpMock,
|
||||
mkdirSftp: mkdirSftpMock
|
||||
}))
|
||||
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
|
|
@ -75,39 +62,52 @@ const store = {
|
|||
}
|
||||
const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
|
||||
type MockSftpWriteStream = EventEmitter & {
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
end: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createMockSftpWriteStream(): MockSftpWriteStream {
|
||||
const stream = new EventEmitter() as MockSftpWriteStream
|
||||
stream.destroy = vi.fn()
|
||||
stream.end = vi.fn(() => stream.emit('close'))
|
||||
return stream
|
||||
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'
|
||||
const sftpWriteStreams: MockSftpWriteStream[] = []
|
||||
const mockSftp = {
|
||||
end: vi.fn(),
|
||||
createWriteStream: vi.fn(() => {
|
||||
const stream = createMockSftpWriteStream()
|
||||
sftpWriteStreams.push(stream)
|
||||
return stream
|
||||
})
|
||||
}
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
|
||||
const makeConn = (status = 'connected') => ({
|
||||
getState: () => ({ status }),
|
||||
sftp: vi.fn().mockResolvedValue(mockSftp)
|
||||
sftp: vi.fn()
|
||||
})
|
||||
const mockFile = (p: string): void => {
|
||||
const rp = path.resolve(p)
|
||||
lstatMock.mockImplementation(async (x: string) => {
|
||||
if (x === rp) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
return {
|
||||
size: 12,
|
||||
ino: 1,
|
||||
dev: 1,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
|
|
@ -128,16 +128,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
openMock,
|
||||
readdirMock,
|
||||
unlinkMock,
|
||||
sftpExistsMock,
|
||||
uploadFileMock,
|
||||
uploadDirMock,
|
||||
removeDirectorySftpMock,
|
||||
mkdirSftpMock,
|
||||
getConnMgrMock
|
||||
].forEach((m) => m.mockReset())
|
||||
mockSftp.end.mockReset()
|
||||
mockSftp.createWriteStream.mockClear()
|
||||
sftpWriteStreams.length = 0
|
||||
handleMock.mockImplementation((ch: string, h: never) => {
|
||||
handlers.set(ch, h)
|
||||
})
|
||||
|
|
@ -167,29 +159,36 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
}
|
||||
})
|
||||
unlinkMock.mockResolvedValue(undefined)
|
||||
sftpExistsMock.mockResolvedValue(false)
|
||||
uploadFileMock.mockResolvedValue(undefined)
|
||||
uploadDirMock.mockResolvedValue(undefined)
|
||||
removeDirectorySftpMock.mockResolvedValue(undefined)
|
||||
mkdirSftpMock.mockResolvedValue(undefined)
|
||||
uploadSession = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
}
|
||||
provider = createProvider(uploadSession)
|
||||
registerSshFilesystemProvider(connId, provider)
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
|
||||
it('routes to SFTP when connectionId is present', async () => {
|
||||
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(uploadFileMock).toHaveBeenCalledWith(
|
||||
mockSftp,
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
path.resolve('/tmp/dropped/file.txt'),
|
||||
`${destDir}/file.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
expect(uploadSession.close).toHaveBeenCalledOnce()
|
||||
expect(copyFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -206,7 +205,8 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('returns empty results without opening SFTP', async () => {
|
||||
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 })
|
||||
|
|
@ -235,36 +235,15 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
).rejects.toThrow('not active')
|
||||
})
|
||||
|
||||
it('throws when conn.sftp() rejects', async () => {
|
||||
const conn = makeConn()
|
||||
conn.sftp.mockRejectedValue(new Error('SFTP subsystem not available'))
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => conn })
|
||||
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('SFTP subsystem')
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
})
|
||||
|
||||
it('closes SFTP channel after success', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
await invoke({ sourcePaths: ['/tmp/dropped/file.txt'], destDir, connectionId: connId })
|
||||
expect(mockSftp.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes SFTP channel after upload error', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
uploadFileMock.mockRejectedValue(new Error('disk full'))
|
||||
const { results } = await invoke({
|
||||
sourcePaths: ['/tmp/dropped/file.txt'],
|
||||
destDir,
|
||||
connectionId: connId
|
||||
})
|
||||
expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' })
|
||||
expect(mockSftp.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('removes staging marker write listeners after remote stream close', async () => {
|
||||
it('uploads terminal-drop staging marker through provider writes', async () => {
|
||||
getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() })
|
||||
mockFile('/tmp/dropped/file.txt')
|
||||
|
||||
|
|
@ -275,9 +254,11 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
|
|||
ensureDir: true
|
||||
})
|
||||
|
||||
expect(mockSftp.createWriteStream).toHaveBeenCalledWith('/home/user/project/.orca/.gitignore')
|
||||
expect(sftpWriteStreams[0]!.listenerCount('close')).toBe(0)
|
||||
expect(sftpWriteStreams[0]!.listenerCount('error')).toBe(0)
|
||||
expect(sftpWriteStreams[0]!.destroy).toHaveBeenCalledOnce()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import { lstat, readdir, realpath } from 'node:fs/promises'
|
||||
import { basename, join, posix, resolve } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
|
||||
import { getSshConnectionManager } from './ssh'
|
||||
import {
|
||||
uploadFile,
|
||||
uploadDirectory,
|
||||
mkdirSftp,
|
||||
sftpPathExists,
|
||||
removeDirectorySftp
|
||||
} from '../ssh/sftp-upload'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type { ImportItemResult } from './filesystem-mutations'
|
||||
|
||||
// Why: the SSH import path bypasses SshFilesystemProvider and uses
|
||||
// SshConnection.sftp() directly because the relay's JSON-RPC fs.writeFile
|
||||
// is text-only and cannot carry binary data without base64 overhead.
|
||||
// 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,
|
||||
|
|
@ -39,25 +32,30 @@ export async function importExternalPathsSsh(
|
|||
throw new Error('SSH connection is not active — please reconnect and try again')
|
||||
}
|
||||
|
||||
const sftp = await conn.sftp()
|
||||
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)
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
const uploadSession = await provider.openFileUploadSession()
|
||||
try {
|
||||
if (options?.ensureDir) {
|
||||
// Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist
|
||||
// before the first upload. Upload primitives do not create parent dirs,
|
||||
// and mkdirSftp is not recursive — so walk the parent chain here on the
|
||||
// same SFTP session to avoid doubling the handshake cost. Writing the
|
||||
// .orca/.gitignore marker only when absent prevents clobbering user-
|
||||
// authored patterns. .orca/ is reserved as Orca-owned remote state;
|
||||
// see docs/terminal-drop-ssh.md.
|
||||
await ensureDropStagingDir(sftp, destDir)
|
||||
}
|
||||
|
||||
const results: ImportItemResult[] = []
|
||||
const reservedNames = new Set<string>()
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const result = await importOneSourceSsh(sftp, sourcePath, destDir, reservedNames)
|
||||
const result = await importOneSourceSsh(
|
||||
provider,
|
||||
uploadSession,
|
||||
sourcePath,
|
||||
destDir,
|
||||
reservedNames
|
||||
)
|
||||
results.push(result)
|
||||
if (result.status === 'imported') {
|
||||
// Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt).
|
||||
|
|
@ -66,15 +64,16 @@ export async function importExternalPathsSsh(
|
|||
reservedNames.add(posix.basename(result.destPath))
|
||||
}
|
||||
}
|
||||
|
||||
return { results }
|
||||
} finally {
|
||||
sftp.end()
|
||||
uploadSession.close()
|
||||
}
|
||||
|
||||
return { results }
|
||||
}
|
||||
|
||||
async function importOneSourceSsh(
|
||||
sftp: SFTPWrapper,
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
sourcePath: string,
|
||||
destDir: string,
|
||||
reservedNames: Set<string>
|
||||
|
|
@ -126,18 +125,22 @@ async function importOneSourceSsh(
|
|||
|
||||
let createdDestDir: string | null = null
|
||||
try {
|
||||
const finalName = await deconflictNameSftp(sftp, destDir, originalName, reservedNames)
|
||||
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
|
||||
const destPath = `${destDir}/${finalName}`
|
||||
const renamed = finalName !== originalName
|
||||
|
||||
if (isDir) {
|
||||
await mkdirSftp(sftp, destPath, { allowExisting: false })
|
||||
await provider.createDirNoClobber(destPath)
|
||||
createdDestDir = destPath
|
||||
await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), {
|
||||
exclusive: true
|
||||
})
|
||||
await uploadDirectoryViaProvider(
|
||||
provider,
|
||||
uploadSession,
|
||||
resolvedSource,
|
||||
destPath,
|
||||
await realpath(resolvedSource)
|
||||
)
|
||||
} else {
|
||||
await uploadFile(sftp, resolvedSource, destPath, { exclusive: true })
|
||||
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -151,7 +154,7 @@ async function importOneSourceSsh(
|
|||
if (createdDestDir) {
|
||||
// Why: local directory imports roll back partial output; SSH imports
|
||||
// should not leave the no-clobber root after a nested upload failure.
|
||||
await removeDirectorySftp(sftp, createdDestDir).catch(() => {})
|
||||
await provider.deletePath(createdDestDir, true).catch(() => {})
|
||||
}
|
||||
return {
|
||||
sourcePath,
|
||||
|
|
@ -161,14 +164,14 @@ async function importOneSourceSsh(
|
|||
}
|
||||
}
|
||||
|
||||
async function deconflictNameSftp(
|
||||
sftp: SFTPWrapper,
|
||||
async function deconflictName(
|
||||
provider: IFilesystemProvider,
|
||||
destDir: string,
|
||||
originalName: string,
|
||||
reservedNames: Set<string>
|
||||
): Promise<string> {
|
||||
if (
|
||||
!(await sftpPathExists(sftp, `${destDir}/${originalName}`)) &&
|
||||
!(await remotePathExists(provider, `${destDir}/${originalName}`)) &&
|
||||
!reservedNames.has(originalName)
|
||||
) {
|
||||
return originalName
|
||||
|
|
@ -180,14 +183,20 @@ async function deconflictNameSftp(
|
|||
const ext = hasMeaningfulExt ? originalName.slice(dotIndex) : ''
|
||||
|
||||
let candidate = `${stem} copy${ext}`
|
||||
if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) {
|
||||
if (
|
||||
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
|
||||
!reservedNames.has(candidate)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
let counter = 2
|
||||
while (counter < 10000) {
|
||||
candidate = `${stem} copy ${counter}${ext}`
|
||||
if (!(await sftpPathExists(sftp, `${destDir}/${candidate}`)) && !reservedNames.has(candidate)) {
|
||||
if (
|
||||
!(await remotePathExists(provider, `${destDir}/${candidate}`)) &&
|
||||
!reservedNames.has(candidate)
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
|
|
@ -198,44 +207,72 @@ async function deconflictNameSftp(
|
|||
)
|
||||
}
|
||||
|
||||
async function ensureDropStagingDir(sftp: SFTPWrapper, destDir: string): Promise<void> {
|
||||
// destDir is a posix remote path, expected to be `${worktreePath}/.orca/drops`.
|
||||
async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: string): Promise<void> {
|
||||
const parent = posix.dirname(destDir)
|
||||
await mkdirSftp(sftp, parent)
|
||||
await provider.createDir(parent)
|
||||
const gitignorePath = `${parent}/.gitignore`
|
||||
if (!(await sftpPathExists(sftp, gitignorePath))) {
|
||||
// Why: negate the marker so .orca/.gitignore itself is trackable if we
|
||||
// ever want to, without dirtying `git status` today. Only write when
|
||||
// absent to avoid clobbering user-authored patterns.
|
||||
await writeSftpFile(sftp, gitignorePath, '*\n!.gitignore\n')
|
||||
if (!(await remotePathExists(provider, gitignorePath))) {
|
||||
await provider.writeFile(gitignorePath, '*\n!.gitignore\n')
|
||||
}
|
||||
await mkdirSftp(sftp, destDir)
|
||||
await provider.createDir(destDir)
|
||||
}
|
||||
|
||||
function writeSftpFile(sftp: SFTPWrapper, remotePath: string, contents: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const writeStream = sftp.createWriteStream(remotePath)
|
||||
const cleanup = (): void => {
|
||||
writeStream.off('close', onClose)
|
||||
writeStream.off('error', onError)
|
||||
}
|
||||
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
writeStream.destroy()
|
||||
fn(val as never)
|
||||
}
|
||||
const onClose = (): void => settle(resolve)
|
||||
const onError = (err: Error): void => settle(reject, err)
|
||||
async function uploadDirectoryViaProvider(
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
rootRealPath: string
|
||||
): Promise<void> {
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
|
||||
const entries = await readdir(localDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const localPath = join(localDir, entry.name)
|
||||
const remotePath = `${remoteDir}/${entry.name}`
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
|
||||
const statResult = await lstat(localPath)
|
||||
|
||||
writeStream.on('close', onClose)
|
||||
writeStream.on('error', onError)
|
||||
writeStream.end(contents)
|
||||
})
|
||||
// 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()) {
|
||||
await provider.createDirNoClobber(remotePath)
|
||||
await uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath)
|
||||
continue
|
||||
}
|
||||
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return (
|
||||
code === 'ENOENT' ||
|
||||
/\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test(
|
||||
error.message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function preScanForSymlinks(dirPath: string): Promise<boolean> {
|
||||
|
|
@ -253,3 +290,21 @@ async function preScanForSymlinks(dirPath: string): Promise<boolean> {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function assertLocalUploadPathInsideRoot(
|
||||
rootRealPath: string,
|
||||
candidatePath: string
|
||||
): Promise<void> {
|
||||
const candidateRealPath = await realpath(candidatePath)
|
||||
const root = rootRealPath.replace(/[\\/]+$/g, '')
|
||||
const candidate = candidateRealPath.replace(/[\\/]+$/g, '')
|
||||
const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root
|
||||
const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate
|
||||
if (candidateComparable === rootComparable) {
|
||||
return
|
||||
}
|
||||
const boundary = process.platform === 'win32' ? '\\' : '/'
|
||||
if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) {
|
||||
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { uploadFile as uploadFileViaSftp } from '../ssh/sftp-upload'
|
||||
import type { FileUploadSession } from './types'
|
||||
|
||||
export type SftpFactory = () => Promise<SFTPWrapper>
|
||||
|
||||
export type SshRawTransferOptions = {
|
||||
downloadFile?: (sourcePath: string, destinationPath: string) => Promise<void>
|
||||
openFileUploadSession?: () => Promise<FileUploadSession>
|
||||
writeBuffer?: (
|
||||
remotePath: string,
|
||||
contents: Buffer,
|
||||
options: { append: boolean; exclusive: boolean }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export async function openSshFileUploadSession(
|
||||
createSftp?: SftpFactory,
|
||||
rawTransfer?: SshRawTransferOptions
|
||||
): Promise<FileUploadSession> {
|
||||
if (rawTransfer?.openFileUploadSession) {
|
||||
return rawTransfer.openFileUploadSession()
|
||||
}
|
||||
if (!createSftp) {
|
||||
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
const sftp = await createSftp()
|
||||
return {
|
||||
// Why: one session covers the whole import so normal SSH keeps its prior
|
||||
// channel count even when a directory contains many files.
|
||||
uploadFile: (sourcePath, destinationPath, options) =>
|
||||
uploadFileViaSftp(sftp, sourcePath, destinationPath, options),
|
||||
close: () => sftp.end()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { SshFilesystemProvider } from './ssh-filesystem-provider'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
|
||||
|
|
@ -201,6 +205,23 @@ describe('SshFilesystemProvider', () => {
|
|||
expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything())
|
||||
})
|
||||
|
||||
it('writes decoded bytes through raw transfer when provided', async () => {
|
||||
const writeBuffer = vi.fn().mockResolvedValue(undefined)
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { writeBuffer })
|
||||
|
||||
await provider.writeFileBase64('/home/user/logo.png', 'cG5n')
|
||||
await provider.writeFileBase64Chunk('/home/user/logo.png', 'bW9yZQ==', true)
|
||||
|
||||
expect(writeBuffer).toHaveBeenNthCalledWith(1, '/home/user/logo.png', Buffer.from('png'), {
|
||||
append: false,
|
||||
exclusive: true
|
||||
})
|
||||
expect(writeBuffer).toHaveBeenNthCalledWith(2, '/home/user/logo.png', Buffer.from('more'), {
|
||||
append: true,
|
||||
exclusive: false
|
||||
})
|
||||
})
|
||||
|
||||
it('can append decoded chunks through SFTP', async () => {
|
||||
const writeStream = {
|
||||
on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
|
||||
|
|
@ -225,6 +246,15 @@ describe('SshFilesystemProvider', () => {
|
|||
})
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('downloads raw bytes through raw transfer when provided', async () => {
|
||||
const downloadFile = vi.fn().mockResolvedValue(undefined)
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, undefined, { downloadFile })
|
||||
|
||||
await provider.downloadFile('/home/user/archive.zip', '/tmp/archive.zip')
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith('/home/user/archive.zip', '/tmp/archive.zip')
|
||||
})
|
||||
|
||||
it('downloads raw bytes through SFTP and closes the session', async () => {
|
||||
const sftp = {
|
||||
fastGet: vi.fn(
|
||||
|
|
@ -263,6 +293,48 @@ describe('SshFilesystemProvider', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('openFileUploadSession', () => {
|
||||
it('uses one SFTP session for multiple files and closes it once', async () => {
|
||||
const createWriteStream = vi.fn(() => {
|
||||
const stream = new PassThrough()
|
||||
stream.resume()
|
||||
return stream
|
||||
})
|
||||
const sftp = { createWriteStream, end: vi.fn() }
|
||||
const createSftp = vi.fn().mockResolvedValue(sftp)
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, createSftp as never)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-sftp-upload-session-'))
|
||||
const first = join(dir, 'first.txt')
|
||||
const second = join(dir, 'second.txt')
|
||||
writeFileSync(first, 'first')
|
||||
writeFileSync(second, 'second')
|
||||
|
||||
try {
|
||||
const session = await provider.openFileUploadSession()
|
||||
await session.uploadFile(first, '/remote/first.txt', { exclusive: true })
|
||||
await session.uploadFile(second, '/remote/second.txt', { exclusive: true })
|
||||
session.close()
|
||||
|
||||
expect(createSftp).toHaveBeenCalledOnce()
|
||||
expect(createWriteStream).toHaveBeenCalledTimes(2)
|
||||
expect(sftp.end).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the transport-owned upload session when provided', async () => {
|
||||
const uploadSession = { uploadFile: vi.fn(), close: vi.fn() }
|
||||
const openFileUploadSession = vi.fn().mockResolvedValue(uploadSession)
|
||||
provider = new SshFilesystemProvider('conn-1', mux as never, undefined, {
|
||||
openFileUploadSession
|
||||
})
|
||||
|
||||
await expect(provider.openFileUploadSession()).resolves.toBe(uploadSession)
|
||||
expect(openFileUploadSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDirNoClobber', () => {
|
||||
it('sends fs.createDirNoClobber request', async () => {
|
||||
await provider.createDirNoClobber('/home/user/new-dir')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
|||
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { uploadBuffer } from '../ssh/sftp-upload'
|
||||
import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp'
|
||||
import {
|
||||
openSshFileUploadSession,
|
||||
type SftpFactory,
|
||||
type SshRawTransferOptions
|
||||
} from './ssh-filesystem-file-upload'
|
||||
import {
|
||||
notifySshFilesystemUnwatch,
|
||||
registerSshFilesystemWatch,
|
||||
|
|
@ -11,14 +16,12 @@ import type {
|
|||
IFilesystemProvider,
|
||||
FileStat,
|
||||
FileReadResult,
|
||||
FileUploadSession,
|
||||
TerminalArtifactAccessOptions
|
||||
} from './types'
|
||||
import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
type SftpFactory = () => Promise<SFTPWrapper>
|
||||
const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000
|
||||
|
||||
export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
|
|
@ -33,7 +36,8 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
|||
constructor(
|
||||
connectionId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
private readonly createSftp?: SftpFactory
|
||||
private readonly createSftp?: SftpFactory,
|
||||
private readonly rawTransfer?: SshRawTransferOptions
|
||||
) {
|
||||
this.connectionId = connectionId
|
||||
this.mux = mux
|
||||
|
|
@ -120,6 +124,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
|||
}
|
||||
|
||||
async downloadFile(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
if (this.rawTransfer?.downloadFile) {
|
||||
await this.rawTransfer.downloadFile(sourcePath, destinationPath)
|
||||
return
|
||||
}
|
||||
if (!this.createSftp) {
|
||||
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
|
|
@ -131,6 +139,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
|||
}
|
||||
}
|
||||
|
||||
async openFileUploadSession(): Promise<FileUploadSession> {
|
||||
return openSshFileUploadSession(this.createSftp, this.rawTransfer)
|
||||
}
|
||||
|
||||
async getTempDir(): Promise<string> {
|
||||
this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then(
|
||||
(result) => result as string,
|
||||
|
|
@ -186,6 +198,11 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
|||
contentBase64: string,
|
||||
append: boolean
|
||||
): Promise<void> {
|
||||
const contents = Buffer.from(contentBase64, 'base64')
|
||||
if (this.rawTransfer?.writeBuffer) {
|
||||
await this.rawTransfer.writeBuffer(filePath, contents, { append, exclusive: !append })
|
||||
return
|
||||
}
|
||||
if (!this.createSftp) {
|
||||
throw new Error('remote_binary_upload_unavailable')
|
||||
}
|
||||
|
|
@ -193,7 +210,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
|||
try {
|
||||
// Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes
|
||||
// directly so runtime uploads do not corrupt images, PDFs, or archives.
|
||||
await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, {
|
||||
await uploadBuffer(sftp, contents, filePath, {
|
||||
append,
|
||||
exclusive: !append
|
||||
})
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ export type IFilesystemProvider = {
|
|||
options: TerminalArtifactAccessOptions
|
||||
): Promise<FileReadResult>
|
||||
downloadFile?(sourcePath: string, destinationPath: string): Promise<void>
|
||||
openFileUploadSession?(): Promise<FileUploadSession>
|
||||
getTempDir?(): Promise<string>
|
||||
writeFile(filePath: string, content: string): Promise<void>
|
||||
writeTerminalArtifact?(
|
||||
|
|
@ -281,6 +282,15 @@ export type IFilesystemProvider = {
|
|||
watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void>
|
||||
}
|
||||
|
||||
export type FileUploadSession = {
|
||||
uploadFile(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
options?: { exclusive?: boolean }
|
||||
): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type TerminalArtifactAccessOptions = {
|
||||
expectedRealPath: string
|
||||
expectedStatIdentity: string | null
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ describe('OrcaRuntimeService terminal startup cwd', () => {
|
|||
|
||||
it('materializes restored headless mobile tabs in the persisted startup cwd', async () => {
|
||||
const store = {
|
||||
// wt-1 is a worktree id, not a registered repo, so getRepo returns null;
|
||||
// the selector validator calls it to reject repo ids passed as worktree ids.
|
||||
getRepo: () => null,
|
||||
getWorkspaceSession: () => ({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'wt-1',
|
||||
|
|
|
|||
|
|
@ -133,7 +133,10 @@ vi.mock('./ssh-system-fallback', () => ({
|
|||
getOrcaControlSocketPath: getOrcaControlSocketPathMock,
|
||||
spawnSystemSsh: spawnSystemSshMock,
|
||||
spawnSystemSshCommand: spawnSystemSshCommandMock,
|
||||
downloadFileViaSystemSsh: vi.fn(),
|
||||
uploadDirectoryViaSystemSsh: vi.fn(),
|
||||
uploadFileViaSystemSsh: vi.fn(),
|
||||
writeBufferViaSystemSsh: vi.fn(),
|
||||
writeFileViaSystemSsh: vi.fn()
|
||||
}))
|
||||
|
||||
|
|
@ -152,7 +155,13 @@ import {
|
|||
type SshConnectionCallbacks
|
||||
} from './ssh-connection'
|
||||
import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser'
|
||||
import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback'
|
||||
import {
|
||||
downloadFileViaSystemSsh,
|
||||
uploadDirectoryViaSystemSsh,
|
||||
uploadFileViaSystemSsh,
|
||||
writeBufferViaSystemSsh,
|
||||
writeFileViaSystemSsh
|
||||
} from './ssh-system-fallback'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
|
||||
|
|
@ -273,8 +282,14 @@ describe('SshConnection', () => {
|
|||
spawnSystemSshMock.mockImplementation(() => createSystemSshProcess())
|
||||
spawnSystemSshCommandMock.mockReset()
|
||||
spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel())
|
||||
vi.mocked(downloadFileViaSystemSsh).mockReset()
|
||||
vi.mocked(downloadFileViaSystemSsh).mockResolvedValue(undefined)
|
||||
vi.mocked(uploadDirectoryViaSystemSsh).mockReset()
|
||||
vi.mocked(uploadDirectoryViaSystemSsh).mockResolvedValue(undefined)
|
||||
vi.mocked(uploadFileViaSystemSsh).mockReset()
|
||||
vi.mocked(uploadFileViaSystemSsh).mockResolvedValue(undefined)
|
||||
vi.mocked(writeBufferViaSystemSsh).mockReset()
|
||||
vi.mocked(writeBufferViaSystemSsh).mockResolvedValue(undefined)
|
||||
vi.mocked(writeFileViaSystemSsh).mockReset()
|
||||
vi.mocked(writeFileViaSystemSsh).mockResolvedValue(undefined)
|
||||
vi.mocked(resolveWithSshG).mockReset()
|
||||
|
|
@ -1057,6 +1072,18 @@ describe('SshConnection', () => {
|
|||
await conn.writeFile('C:/Users/me/.orca-remote/relay/.version', '0.1.0', {
|
||||
hostPlatform
|
||||
})
|
||||
await conn.writeBuffer('C:/Users/me/.orca-remote/relay/logo.png', Buffer.from('png'), {
|
||||
hostPlatform,
|
||||
exclusive: true
|
||||
})
|
||||
await conn.downloadFile('C:/Users/me/.orca-remote/relay/logo.png', '/tmp/logo.png', {
|
||||
hostPlatform
|
||||
})
|
||||
const uploadSession = await conn.openFileUploadSession({ hostPlatform })
|
||||
await uploadSession.uploadFile('/tmp/logo.png', 'C:/Users/me/project/logo.png', {
|
||||
exclusive: true
|
||||
})
|
||||
uploadSession.close()
|
||||
|
||||
expect(uploadDirectoryViaSystemSsh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'fdpass-host' }),
|
||||
|
|
@ -1076,6 +1103,65 @@ describe('SshConnection', () => {
|
|||
resolvedConfig: expect.objectContaining({ proxyUseFdpass: true })
|
||||
})
|
||||
)
|
||||
expect(writeBufferViaSystemSsh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'fdpass-host' }),
|
||||
'C:/Users/me/.orca-remote/relay/logo.png',
|
||||
Buffer.from('png'),
|
||||
expect.objectContaining({
|
||||
hostPlatform,
|
||||
exclusive: true,
|
||||
resolvedConfig: expect.objectContaining({ proxyUseFdpass: true })
|
||||
})
|
||||
)
|
||||
expect(downloadFileViaSystemSsh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'fdpass-host' }),
|
||||
'C:/Users/me/.orca-remote/relay/logo.png',
|
||||
'/tmp/logo.png',
|
||||
expect.objectContaining({
|
||||
hostPlatform,
|
||||
resolvedConfig: expect.objectContaining({ proxyUseFdpass: true })
|
||||
})
|
||||
)
|
||||
expect(uploadFileViaSystemSsh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configHost: 'fdpass-host' }),
|
||||
'/tmp/logo.png',
|
||||
'C:/Users/me/project/logo.png',
|
||||
expect.objectContaining({
|
||||
hostPlatform,
|
||||
exclusive: true,
|
||||
resolvedConfig: expect.objectContaining({ proxyUseFdpass: true })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an upload session cancelled after the connection disconnects', async () => {
|
||||
const conn = new SshConnection(
|
||||
createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
|
||||
createCallbacks()
|
||||
)
|
||||
vi.mocked(uploadFileViaSystemSsh).mockImplementation(
|
||||
async (_target, _localPath, _remotePath, options) => {
|
||||
if (options?.signal?.aborted) {
|
||||
const error = new Error('System SSH operation was cancelled')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await conn.connect()
|
||||
const uploadSession = await conn.openFileUploadSession()
|
||||
await conn.disconnect()
|
||||
|
||||
await expect(
|
||||
uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt')
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(uploadFileViaSystemSsh).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/tmp/late.txt',
|
||||
'/remote/late.txt',
|
||||
expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('removes system SSH probe listeners after timeout', async () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import {
|
|||
getOrcaControlSocketPath,
|
||||
spawnSystemSsh,
|
||||
spawnSystemSshCommand,
|
||||
downloadFileViaSystemSsh,
|
||||
uploadDirectoryViaSystemSsh,
|
||||
uploadFileViaSystemSsh,
|
||||
writeBufferViaSystemSsh,
|
||||
writeFileViaSystemSsh,
|
||||
type SystemSshBuildArgsOptions,
|
||||
type SystemSshProcess
|
||||
|
|
@ -35,6 +38,7 @@ import {
|
|||
type SshConnectionCallbacks
|
||||
} from './ssh-connection-utils'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { FileUploadSession } from '../providers/types'
|
||||
import { isSshSessionLimitError } from './ssh-session-limit-error'
|
||||
export type { SshConnectionCallbacks } from './ssh-connection-utils'
|
||||
|
||||
|
|
@ -347,6 +351,54 @@ export class SshConnection {
|
|||
})
|
||||
}
|
||||
|
||||
async downloadFile(
|
||||
remotePath: string,
|
||||
localPath: string,
|
||||
options?: SshRemoteFileOptions
|
||||
): Promise<void> {
|
||||
if (!this.useSystemSshTransport) {
|
||||
const sftp = await this.sftp()
|
||||
try {
|
||||
const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp')
|
||||
await fastGetViaSftp(sftp, remotePath, localPath)
|
||||
} finally {
|
||||
sftp.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
await downloadFileViaSystemSsh(this.target, remotePath, localPath, {
|
||||
signal: this.systemOperationAbortController.signal,
|
||||
hostPlatform: options?.hostPlatform,
|
||||
...this.getSystemSshBuildArgsOptions()
|
||||
})
|
||||
}
|
||||
|
||||
async openFileUploadSession(options?: SshRemoteFileOptions): Promise<FileUploadSession> {
|
||||
if (!this.useSystemSshTransport) {
|
||||
const sftp = await this.sftp()
|
||||
const { uploadFile } = await import('./sftp-upload')
|
||||
return {
|
||||
uploadFile: (localPath, remotePath, uploadOptions) =>
|
||||
uploadFile(sftp, localPath, remotePath, uploadOptions),
|
||||
close: () => sftp.end()
|
||||
}
|
||||
}
|
||||
// Why: disconnect replaces the connection controller; an existing import
|
||||
// session must stay bound to the signal and SSH config it opened with.
|
||||
const signal = this.systemOperationAbortController.signal
|
||||
const buildArgsOptions = this.getSystemSshBuildArgsOptions()
|
||||
return {
|
||||
uploadFile: (localPath, remotePath, uploadOptions) =>
|
||||
uploadFileViaSystemSsh(this.target, localPath, remotePath, {
|
||||
signal,
|
||||
hostPlatform: options?.hostPlatform,
|
||||
exclusive: uploadOptions?.exclusive,
|
||||
...buildArgsOptions
|
||||
}),
|
||||
close: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
remotePath: string,
|
||||
contents: string,
|
||||
|
|
@ -401,6 +453,30 @@ export class SshConnection {
|
|||
})
|
||||
}
|
||||
|
||||
async writeBuffer(
|
||||
remotePath: string,
|
||||
contents: Buffer,
|
||||
options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.useSystemSshTransport) {
|
||||
const sftp = await this.sftp()
|
||||
try {
|
||||
const { uploadBuffer } = await import('./sftp-upload')
|
||||
await uploadBuffer(sftp, contents, remotePath, options)
|
||||
} finally {
|
||||
sftp.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
await writeBufferViaSystemSsh(this.target, remotePath, contents, {
|
||||
signal: this.systemOperationAbortController.signal,
|
||||
hostPlatform: options?.hostPlatform,
|
||||
append: options?.append,
|
||||
exclusive: options?.exclusive,
|
||||
...this.getSystemSshBuildArgsOptions()
|
||||
})
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
throw new Error('Connection disposed')
|
||||
|
|
|
|||
|
|
@ -674,8 +674,26 @@ export class SshRelaySession {
|
|||
const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined)
|
||||
registerSshPtyProvider(this.targetId, ptyProvider)
|
||||
|
||||
const fsProvider = new SshFilesystemProvider(this.targetId, mux, () =>
|
||||
this.requireReadyConnection().sftp()
|
||||
const fsProvider = new SshFilesystemProvider(
|
||||
this.targetId,
|
||||
mux,
|
||||
() => this.requireReadyConnection().sftp(),
|
||||
{
|
||||
downloadFile: (sourcePath, destinationPath) =>
|
||||
this.requireReadyConnection().downloadFile(sourcePath, destinationPath, {
|
||||
hostPlatform: this.remoteCliBridgeEnv?.hostPlatform
|
||||
}),
|
||||
openFileUploadSession: () =>
|
||||
this.requireReadyConnection().openFileUploadSession({
|
||||
hostPlatform: this.remoteCliBridgeEnv?.hostPlatform
|
||||
}),
|
||||
writeBuffer: (remotePath, contents, options) =>
|
||||
this.requireReadyConnection().writeBuffer(remotePath, contents, {
|
||||
hostPlatform: this.remoteCliBridgeEnv?.hostPlatform,
|
||||
append: options.append,
|
||||
exclusive: options.exclusive
|
||||
})
|
||||
}
|
||||
)
|
||||
registerSshFilesystemProvider(this.targetId, fsProvider)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
|
@ -25,9 +25,12 @@ vi.mock('child_process', () => ({
|
|||
import {
|
||||
buildSshArgs,
|
||||
findSystemSsh,
|
||||
downloadFileViaSystemSsh,
|
||||
spawnSystemSsh,
|
||||
spawnSystemSshCommand,
|
||||
uploadDirectoryViaSystemSsh,
|
||||
uploadFileViaSystemSsh,
|
||||
writeBufferViaSystemSsh,
|
||||
writeFileViaSystemSsh
|
||||
} from './ssh-system-fallback'
|
||||
import { spawnSystemSshPortForward } from './system-ssh-forward-process'
|
||||
|
|
@ -38,6 +41,11 @@ import type { SystemSshResolvedConfig } from './ssh-control-socket'
|
|||
const SYSTEM_SSH_PATH =
|
||||
process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh'
|
||||
|
||||
function decodePowerShellCommand(command: string): string {
|
||||
const encoded = command.match(/-EncodedCommand\s+(\S+)/)?.[1]
|
||||
return encoded ? Buffer.from(encoded, 'base64').toString('utf16le') : command
|
||||
}
|
||||
|
||||
function mockSystemSshExists(): void {
|
||||
existsSyncMock.mockImplementation((p: string) => p === SYSTEM_SSH_PATH)
|
||||
}
|
||||
|
|
@ -462,6 +470,23 @@ describe('spawnSystemSsh', () => {
|
|||
expect(proc.listenerCount('error')).toBe(0)
|
||||
})
|
||||
|
||||
it('pauses command stdout under backpressure and resumes when the channel reads', async () => {
|
||||
const proc = createMockChildProcess()
|
||||
const pause = vi.spyOn(proc.stdout, 'pause')
|
||||
const resume = vi.spyOn(proc.stdout, 'resume')
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const channel = spawnSystemSshCommand(createTarget(), 'cat /tmp/large-file')
|
||||
resume.mockClear()
|
||||
proc.stdout.write(Buffer.alloc(128 * 1024))
|
||||
|
||||
expect(pause).toHaveBeenCalled()
|
||||
resume.mockClear()
|
||||
channel.read()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
expect(resume).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes write command wait listeners after close', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
|
@ -470,10 +495,90 @@ describe('spawnSystemSsh', () => {
|
|||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
expect(proc.stdin.end).toHaveBeenCalledWith('contents')
|
||||
expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('contents'))
|
||||
expect(proc.stderr.listenerCount('data')).toBe(0)
|
||||
})
|
||||
|
||||
it('writes binary buffers to POSIX system SSH targets with exclusive create', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('png'), {
|
||||
exclusive: true
|
||||
})
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args.at(-1)).toContain('set -C; cat >')
|
||||
expect(args.at(-1)).toContain('/tmp/file')
|
||||
expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png'))
|
||||
})
|
||||
|
||||
it('streams a local file through one POSIX system SSH command', async () => {
|
||||
const proc = createMockChildProcess()
|
||||
const received: Buffer[] = []
|
||||
proc.stdin.on('data', (chunk: Buffer) => received.push(chunk))
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-'))
|
||||
const source = join(dir, 'payload.bin')
|
||||
writeFileSync(source, Buffer.from('payload'))
|
||||
|
||||
try {
|
||||
const promise = uploadFileViaSystemSsh(createTarget(), source, '/remote/payload.bin', {
|
||||
exclusive: true
|
||||
})
|
||||
await new Promise<void>((resolve) => proc.stdin.once('finish', resolve))
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
expect(Buffer.concat(received)).toEqual(Buffer.from('payload'))
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args.at(-1)).toContain('set -C; cat >')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('appends binary buffers to POSIX system SSH targets', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
const promise = writeBufferViaSystemSsh(createTarget(), '/tmp/file', Buffer.from('more'), {
|
||||
append: true
|
||||
})
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args.at(-1)).toContain('cat >>')
|
||||
expect(args.at(-1)).toContain('/tmp/file')
|
||||
expect(args.at(-1)).not.toContain('set -C')
|
||||
})
|
||||
|
||||
it('downloads files from POSIX system SSH targets', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-'))
|
||||
const dest = join(dir, 'payload.bin')
|
||||
|
||||
try {
|
||||
const promise = downloadFileViaSystemSsh(createTarget(), '/remote/payload.bin', dest)
|
||||
proc.stdout.emit('data', Buffer.from('payload'))
|
||||
proc.stdout.emit('end')
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
expect(readFileSync(dest)).toEqual(Buffer.from('payload'))
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args.at(-1)).toContain('cat')
|
||||
expect(args.at(-1)).toContain('/remote/payload.bin')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('forces standalone SSH for POSIX file writes when requested', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
|
@ -511,6 +616,56 @@ describe('spawnSystemSsh', () => {
|
|||
expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8'))
|
||||
})
|
||||
|
||||
it('writes binary buffers to Windows system SSH targets with CreateNew mode', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const hostPlatform = getRemoteHostPlatform('win32-x64')
|
||||
|
||||
const promise = writeBufferViaSystemSsh(
|
||||
createTarget(),
|
||||
'C:/Users/me/logo.png',
|
||||
Buffer.from('png'),
|
||||
{ hostPlatform, exclusive: true }
|
||||
)
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
const remoteCommand = args.at(-1) ?? ''
|
||||
expect(remoteCommand).toContain('powershell.exe')
|
||||
expect(decodePowerShellCommand(remoteCommand)).toContain('CreateNew')
|
||||
expect(remoteCommand).not.toContain('/bin/sh')
|
||||
expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('png'))
|
||||
})
|
||||
|
||||
it('downloads files from Windows system SSH targets with PowerShell stdout bytes', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const hostPlatform = getRemoteHostPlatform('win32-x64')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-download-'))
|
||||
const dest = join(dir, 'payload.bin')
|
||||
|
||||
try {
|
||||
const promise = downloadFileViaSystemSsh(createTarget(), 'C:/Users/me/payload.bin', dest, {
|
||||
hostPlatform
|
||||
})
|
||||
proc.stdout.emit('data', Buffer.from('payload'))
|
||||
proc.stdout.emit('end')
|
||||
proc.emit('close', 0, null)
|
||||
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
expect(readFileSync(dest)).toEqual(Buffer.from('payload'))
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
const remoteCommand = args.at(-1) ?? ''
|
||||
expect(remoteCommand).toContain('powershell.exe')
|
||||
expect(decodePowerShellCommand(remoteCommand)).toContain('OpenRead')
|
||||
expect(decodePowerShellCommand(remoteCommand)).toContain('CopyTo')
|
||||
expect(remoteCommand).not.toContain('/bin/sh')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('forces standalone SSH for Windows file writes when requested', async () => {
|
||||
const proc = createEventedProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
|
|
|||
|
|
@ -5,4 +5,9 @@ export {
|
|||
type SystemSshBuildArgsOptions
|
||||
} from './system-ssh-args'
|
||||
export { spawnSystemSsh, spawnSystemSshCommand, type SystemSshProcess } from './system-ssh-command'
|
||||
export {
|
||||
downloadFileViaSystemSsh,
|
||||
uploadFileViaSystemSsh,
|
||||
writeBufferViaSystemSsh
|
||||
} from './system-ssh-file-binary-transfer'
|
||||
export { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './system-ssh-file-transfer'
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ function wrapChildProcess(proc: ChildProcess): SystemSshProcess {
|
|||
|
||||
function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel {
|
||||
const duplex = new Duplex({
|
||||
read() {},
|
||||
read() {
|
||||
proc.stdout?.resume()
|
||||
},
|
||||
write(chunk, encoding, cb) {
|
||||
proc.stdin!.write(chunk, encoding, cb)
|
||||
}
|
||||
|
|
@ -128,7 +130,11 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel {
|
|||
duplex.destroy(err)
|
||||
}
|
||||
const onStdoutData = (data: Buffer): void => {
|
||||
duplex.push(data)
|
||||
// Why: file downloads can outpace the local destination; pause OpenSSH
|
||||
// instead of buffering the producer-consumer lag in the main process.
|
||||
if (!duplex.push(data)) {
|
||||
proc.stdout!.pause()
|
||||
}
|
||||
}
|
||||
const onStdoutEnd = (): void => {
|
||||
duplex.push(null)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import { constants, createWriteStream } from 'node:fs'
|
||||
import { lstat, open } from 'node:fs/promises'
|
||||
import type { Writable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import {
|
||||
getSystemSshBuildArgsFromOperationOptions,
|
||||
type SystemSshBuildArgsOptions
|
||||
} from './system-ssh-args'
|
||||
import { spawnSystemSshCommand } from './system-ssh-command'
|
||||
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
|
||||
import {
|
||||
awaitWithSystemSshAbort,
|
||||
throwIfAborted,
|
||||
waitForChannelClose
|
||||
} from './system-ssh-operation-lifecycle'
|
||||
|
||||
type SystemSshOperationOptions = SystemSshBuildArgsOptions & {
|
||||
signal?: AbortSignal
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
}
|
||||
|
||||
type SystemSshWriteBufferOptions = SystemSshOperationOptions & {
|
||||
append?: boolean
|
||||
exclusive?: boolean
|
||||
}
|
||||
|
||||
type SystemSshUploadFileOptions = SystemSshOperationOptions & {
|
||||
exclusive?: boolean
|
||||
}
|
||||
|
||||
export async function downloadFileViaSystemSsh(
|
||||
target: SshTarget,
|
||||
remotePath: string,
|
||||
localPath: string,
|
||||
options?: SystemSshOperationOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options?.signal)
|
||||
const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)
|
||||
const command = isWindows
|
||||
? makeWindowsReadFileCommand(remotePath)
|
||||
: `cat ${shellEscape(remotePath)}`
|
||||
const channel = spawnSystemSshCommand(target, command, {
|
||||
wrapCommand: !isWindows,
|
||||
...getSystemSshBuildArgsFromOperationOptions(options)
|
||||
})
|
||||
const output = createWriteStream(localPath, { flags: 'wx' })
|
||||
try {
|
||||
await awaitWithSystemSshAbort(
|
||||
options?.signal,
|
||||
() => {
|
||||
channel.close()
|
||||
output.destroy()
|
||||
},
|
||||
Promise.all([
|
||||
waitForChannelClose(channel, `download ${remotePath}`),
|
||||
pipeline(channel, output)
|
||||
])
|
||||
)
|
||||
} catch (error) {
|
||||
channel.close()
|
||||
output.destroy()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeBufferViaSystemSsh(
|
||||
target: SshTarget,
|
||||
remotePath: string,
|
||||
contents: Buffer,
|
||||
options?: SystemSshWriteBufferOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options?.signal)
|
||||
if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) {
|
||||
await writeBufferViaSystemSshWindows(target, remotePath, contents, options)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = spawnSystemSshCommand(
|
||||
target,
|
||||
makePosixWriteFileCommand(remotePath, options),
|
||||
getSystemSshBuildArgsFromOperationOptions(options)
|
||||
)
|
||||
const closePromise = awaitWithSystemSshAbort(
|
||||
options?.signal,
|
||||
() => channel.close(),
|
||||
waitForChannelClose(channel, `write ${remotePath}`)
|
||||
)
|
||||
if (!options?.signal?.aborted) {
|
||||
channel.stdin.end(contents)
|
||||
}
|
||||
await closePromise
|
||||
}
|
||||
|
||||
export async function uploadFileViaSystemSsh(
|
||||
target: SshTarget,
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
options?: SystemSshUploadFileOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options?.signal)
|
||||
const sourceStat = await lstat(localPath)
|
||||
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
||||
throw new Error(`Unsupported upload source: ${localPath}`)
|
||||
}
|
||||
|
||||
const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
||||
try {
|
||||
const openedStat = await handle.stat()
|
||||
if (
|
||||
!openedStat.isFile() ||
|
||||
openedStat.size !== sourceStat.size ||
|
||||
(sourceStat.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== sourceStat.ino) ||
|
||||
(sourceStat.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== sourceStat.dev)
|
||||
) {
|
||||
throw new Error(`File changed during upload: ${localPath}`)
|
||||
}
|
||||
throwIfAborted(options?.signal)
|
||||
|
||||
const isWindows = options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)
|
||||
const channel = spawnSystemSshCommand(
|
||||
target,
|
||||
isWindows
|
||||
? makeWindowsWriteFileCommand(remotePath, options)
|
||||
: makePosixWriteFileCommand(remotePath, options),
|
||||
{
|
||||
wrapCommand: !isWindows,
|
||||
...getSystemSshBuildArgsFromOperationOptions(options)
|
||||
}
|
||||
)
|
||||
const input = handle.createReadStream({ autoClose: false })
|
||||
try {
|
||||
await awaitWithSystemSshAbort(
|
||||
options?.signal,
|
||||
() => {
|
||||
input.destroy()
|
||||
channel.close()
|
||||
},
|
||||
Promise.all([
|
||||
waitForChannelClose(channel, `upload ${remotePath}`),
|
||||
pipeline(input, channel.stdin as Writable)
|
||||
])
|
||||
)
|
||||
} catch (error) {
|
||||
input.destroy()
|
||||
channel.close()
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function writeBufferViaSystemSshWindows(
|
||||
target: SshTarget,
|
||||
remotePath: string,
|
||||
contents: Buffer,
|
||||
options: SystemSshWriteBufferOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options.signal)
|
||||
const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath, options), {
|
||||
wrapCommand: false,
|
||||
...getSystemSshBuildArgsFromOperationOptions(options)
|
||||
})
|
||||
const closePromise = awaitWithSystemSshAbort(
|
||||
options.signal,
|
||||
() => channel.close(),
|
||||
waitForChannelClose(channel, `write ${remotePath}`)
|
||||
)
|
||||
if (!options.signal?.aborted) {
|
||||
channel.stdin.end(contents)
|
||||
}
|
||||
await closePromise
|
||||
}
|
||||
|
||||
function makeWindowsWriteFileCommand(
|
||||
remotePath: string,
|
||||
options?: { append?: boolean; exclusive?: boolean }
|
||||
): string {
|
||||
const fileMode = options?.append ? 'Append' : options?.exclusive ? 'CreateNew' : 'Create'
|
||||
return powerShellCommand(
|
||||
[
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$path = ${powerShellLiteral(remotePath)}`,
|
||||
'$parent = [System.IO.Path]::GetDirectoryName($path)',
|
||||
'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }',
|
||||
'$inputStream = [Console]::OpenStandardInput()',
|
||||
`$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::${fileMode}, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)`,
|
||||
'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }'
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
||||
function makePosixWriteFileCommand(
|
||||
remotePath: string,
|
||||
options?: { append?: boolean; exclusive?: boolean }
|
||||
): string {
|
||||
const redirection = options?.append ? '>>' : '>'
|
||||
const noclobber = !options?.append && options?.exclusive ? 'set -C; ' : ''
|
||||
return `${noclobber}cat ${redirection} ${shellEscape(remotePath)}`
|
||||
}
|
||||
|
||||
function makeWindowsReadFileCommand(remotePath: string): string {
|
||||
return powerShellCommand(
|
||||
[
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$path = ${powerShellLiteral(remotePath)}`,
|
||||
'$src = [System.IO.File]::OpenRead($path)',
|
||||
'$dst = [Console]::OpenStandardOutput()',
|
||||
'try { $src.CopyTo($dst) } finally { $src.Dispose() }'
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from './system-ssh-args'
|
||||
import { spawnSystemSshCommand } from './system-ssh-command'
|
||||
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
|
||||
import { powerShellCommand } from './ssh-remote-powershell'
|
||||
import {
|
||||
awaitWithSystemSshAbort,
|
||||
killProcess,
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
waitForProcess,
|
||||
type ProcessResult
|
||||
} from './system-ssh-operation-lifecycle'
|
||||
import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer'
|
||||
|
||||
type SystemSshOperationOptions = SystemSshBuildArgsOptions & {
|
||||
signal?: AbortSignal
|
||||
|
|
@ -95,30 +96,7 @@ export async function writeFileViaSystemSsh(
|
|||
options?: SystemSshOperationOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options?.signal)
|
||||
if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) {
|
||||
await writeBufferViaSystemSshWindows(
|
||||
target,
|
||||
remotePath,
|
||||
Buffer.from(contents, 'utf-8'),
|
||||
options
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = spawnSystemSshCommand(
|
||||
target,
|
||||
`cat > ${shellEscape(remotePath)}`,
|
||||
getSystemSshBuildArgsFromOperationOptions(options)
|
||||
)
|
||||
const closePromise = awaitWithSystemSshAbort(
|
||||
options?.signal,
|
||||
() => channel.close(),
|
||||
waitForChannelClose(channel, `write ${remotePath}`)
|
||||
)
|
||||
if (!options?.signal?.aborted) {
|
||||
channel.stdin.end(contents)
|
||||
}
|
||||
await closePromise
|
||||
await writeBufferViaSystemSsh(target, remotePath, Buffer.from(contents, 'utf-8'), options)
|
||||
}
|
||||
|
||||
async function uploadDirectoryViaSystemSshWindows(
|
||||
|
|
@ -221,42 +199,6 @@ async function readLocalUploadFile(
|
|||
}
|
||||
}
|
||||
|
||||
async function writeBufferViaSystemSshWindows(
|
||||
target: SshTarget,
|
||||
remotePath: string,
|
||||
contents: Buffer,
|
||||
options: SystemSshOperationOptions
|
||||
): Promise<void> {
|
||||
throwIfAborted(options.signal)
|
||||
const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), {
|
||||
wrapCommand: false,
|
||||
...getSystemSshBuildArgsFromOperationOptions(options)
|
||||
})
|
||||
const closePromise = awaitWithSystemSshAbort(
|
||||
options.signal,
|
||||
() => channel.close(),
|
||||
waitForChannelClose(channel, `write ${remotePath}`)
|
||||
)
|
||||
if (!options.signal?.aborted) {
|
||||
channel.stdin.end(contents)
|
||||
}
|
||||
await closePromise
|
||||
}
|
||||
|
||||
function makeWindowsWriteFileCommand(remotePath: string): string {
|
||||
return powerShellCommand(
|
||||
[
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$path = ${powerShellLiteral(remotePath)}`,
|
||||
'$parent = [System.IO.Path]::GetDirectoryName($path)',
|
||||
'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }',
|
||||
'$inputStream = [Console]::OpenStandardInput()',
|
||||
'$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)',
|
||||
'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }'
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
||||
function makeWindowsUploadPackageCommand(): string {
|
||||
return powerShellCommand(
|
||||
[
|
||||
|
|
|
|||
Loading…
Reference in New Issue