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:
gatsby74 2026-07-12 10:29:55 +02:00 committed by GitHub
parent 3472acfb22
commit c2aa9c2ead
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1092 additions and 312 deletions

View File

@ -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 () => {

View File

@ -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')
})
})

View File

@ -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}`)
}
}

View File

@ -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()
}
}

View File

@ -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')

View File

@ -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
})

View File

@ -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

View File

@ -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',

View File

@ -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 () => {

View File

@ -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')

View File

@ -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)

View File

@ -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)

View File

@ -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'

View File

@ -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)

View File

@ -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('; ')
)
}

View File

@ -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(
[