feat: add managed SSH execution spaces

Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI.

Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts.

Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
This commit is contained in:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
+379
View File
@@ -0,0 +1,379 @@
import { EventEmitter } from 'node:events'
import type { FileEntryWithStats } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import {
listBoundedSftpDirectories,
type DirectorySftp,
type DirectorySftpOpener
} from './bounded-directory-sftp'
type TestSftp = DirectorySftp
function entry(
filename: string,
type: 'directory' | 'file' | 'symbolic-link' = 'directory'
): FileEntryWithStats {
return {
filename,
longname: filename,
attrs: {
size: 0,
uid: 1,
gid: 1,
mode: 0o755,
atime: 0,
mtime: 0,
isDirectory: () => type === 'directory',
isFile: () => type === 'file',
isSymbolicLink: () => type === 'symbolic-link',
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false
}
}
}
function createSftp(options: {
home?: string
canonicalPath?: string
batches?: FileEntryWithStats[][]
} = {}): TestSftp {
const emitter = new EventEmitter()
const batches = [...(options.batches ?? [[]])]
const sftp = Object.assign(emitter, {
realpath: vi.fn(
(
path: string,
callback: (
error: Error | undefined,
result: string
) => void
) => {
callback(
undefined,
path === '.'
? (options.home ?? '/home/builder')
: (options.canonicalPath ?? path)
)
}
),
opendir: vi.fn(
(
_path: string,
callback: (
error: Error | undefined,
handle: Buffer
) => void
) => callback(undefined, Buffer.from('directory-handle'))
),
readdir: vi.fn(
(
_handle: Buffer,
callback: (
error: Error | undefined,
entries: FileEntryWithStats[]
) => void
) => callback(undefined, batches.shift() ?? [])
),
close: vi.fn(
(
_handle: Buffer,
callback: (error?: Error | null) => void
) => callback()
),
end: vi.fn()
})
return sftp as unknown as TestSftp
}
function opener(sftp: TestSftp): DirectorySftpOpener {
return (callback) => callback(undefined, sftp)
}
describe('bounded directory SFTP', () => {
it('resolves home and requested paths, filters entries, and closes every resource', async () => {
const sftp = createSftp({
home: '/home/builder',
canonicalPath: '/srv/projects',
batches: [
[
entry('zeta'),
entry('alpha'),
entry('file.txt', 'file'),
entry('link', 'symbolic-link'),
entry('.'),
entry('..'),
entry('nested/name'),
entry('back\\slash'),
entry('control\u0001'),
entry('\ufffd'),
entry('x'.repeat(256)),
entry('alpha')
],
[]
]
})
await expect(
listBoundedSftpDirectories(
opener(sftp),
'/srv/requested'
)
).resolves.toEqual({
path: '/srv/projects',
homeDirectory: '/home/builder',
parentPath: '/srv',
entries: [
{ name: 'alpha', path: '/srv/projects/alpha' },
{ name: 'zeta', path: '/srv/projects/zeta' }
],
truncated: false
})
expect(sftp.realpath).toHaveBeenNthCalledWith(
1,
'.',
expect.any(Function)
)
expect(sftp.realpath).toHaveBeenNthCalledWith(
2,
'/srv/requested',
expect.any(Function)
)
expect(sftp.opendir).toHaveBeenCalledWith(
'/srv/projects',
expect.any(Function)
)
expect(sftp.readdir).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('uses canonical home by default and reports root without a parent', async () => {
const sftp = createSftp({
home: '/',
canonicalPath: '/',
batches: [[entry('tmp')], []]
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).resolves.toMatchObject({
path: '/',
homeDirectory: '/',
parentPath: null,
entries: [{ name: 'tmp', path: '/tmp' }]
})
expect(sftp.realpath).toHaveBeenNthCalledWith(
2,
'/',
expect.any(Function)
)
})
it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => {
const sftp = createSftp({
batches: [[entry('\u{10000}'), entry('\ue000')], []]
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(result.entries.map(({ name }) => name)).toEqual([
'\ue000',
'\u{10000}'
])
})
it('returns at most 500 directories after scanning at most 2000 entries', async () => {
const entries = Array.from({ length: 2_100 }, (_, index) =>
entry(`dir-${index.toString().padStart(4, '0')}`)
)
const sftp = createSftp({ batches: [entries] })
const result = await listBoundedSftpDirectories(opener(sftp))
expect(result.entries).toHaveLength(500)
expect(result.entries[0]?.name).toBe('dir-0000')
expect(result.entries[499]?.name).toBe('dir-0499')
expect(result.truncated).toBe(true)
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('stops after 64 handle-based READDIR callbacks', async () => {
const sftp = createSftp({
batches: Array.from({ length: 65 }, (_, index) => [
entry(`dir-${index.toString().padStart(2, '0')}`)
])
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(sftp.readdir).toHaveBeenCalledTimes(64)
expect(result.entries).toHaveLength(64)
expect(result.truncated).toBe(true)
expect(sftp.readdir).toHaveBeenCalledWith(
Buffer.from('directory-handle'),
expect.any(Function)
)
})
it('times out the whole operation and ends a pending channel', async () => {
vi.useFakeTimers()
try {
const sftp = createSftp()
sftp.realpath = vi.fn()
const pending = listBoundedSftpDirectories(opener(sftp))
const expectation = expect(pending).rejects.toThrow(
'目录浏览超时'
)
await vi.advanceTimersByTimeAsync(30_000)
await expectation
expect(sftp.close).not.toHaveBeenCalled()
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('cancels a pending read and ignores its late callback', async () => {
const sftp = createSftp()
let callback:
| ((
error: Error | undefined,
entries: FileEntryWithStats[]
) => void)
| undefined
sftp.readdir = vi.fn((_handle, next) => {
callback = next
})
const controller = new AbortController()
const pending = listBoundedSftpDirectories(
opener(sftp),
undefined,
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
callback?.(undefined, [entry('late')])
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('closes the handle and channel when READDIR fails', async () => {
const sftp = createSftp()
sftp.readdir = vi.fn((_handle, callback) => {
callback(new Error('READDIR failed'), [])
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).rejects.toThrow('READDIR failed')
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('treats SFTP status code 1 as the end of the directory', async () => {
const sftp = createSftp({
batches: [[entry('project')]]
})
let reads = 0
sftp.readdir = vi.fn((_handle, callback) => {
reads += 1
if (reads === 1) {
callback(undefined, [entry('project')])
return
}
callback(
Object.assign(new Error('End of file'), { code: 1 }),
[]
)
})
await expect(
listBoundedSftpDirectories(opener(sftp))
).resolves.toMatchObject({
entries: [{
name: 'project',
path: '/home/builder/project'
}],
truncated: false
})
expect(sftp.readdir).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('closes a handle returned after cancellation', async () => {
const sftp = createSftp()
let callback:
| ((error: Error | undefined, handle: Buffer) => void)
| undefined
sftp.opendir = vi.fn((_path, next) => {
callback = next
})
const controller = new AbortController()
const pending = listBoundedSftpDirectories(
opener(sftp),
undefined,
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
callback?.(undefined, Buffer.from('late-handle'))
expect(sftp.close).toHaveBeenCalledWith(
Buffer.from('late-handle'),
expect.any(Function)
)
expect(sftp.end).toHaveBeenCalledOnce()
})
it('rejects non-canonical inputs and provider paths without opening directories', async () => {
const sftp = createSftp({ canonicalPath: '/bad/../path' })
expect(() =>
listBoundedSftpDirectories(opener(sftp), 'relative')
).toThrow('目录路径无效')
await expect(
listBoundedSftpDirectories(opener(sftp), '/requested')
).rejects.toThrow('目录路径无效')
expect(sftp.opendir).not.toHaveBeenCalled()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('ends an SFTP channel delivered after an open timeout', async () => {
vi.useFakeTimers()
try {
let callback:
| Parameters<DirectorySftpOpener>[0]
| undefined
const pending = listBoundedSftpDirectories((next) => {
callback = next
})
const expectation = expect(pending).rejects.toThrow(
'目录浏览超时'
)
await vi.advanceTimersByTimeAsync(30_000)
await expectation
const late = createSftp()
callback?.(undefined, late)
expect(late.end).toHaveBeenCalledOnce()
expect(late.realpath).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+383
View File
@@ -0,0 +1,383 @@
import type {
FileEntryWithStats,
SFTPWrapper
} from 'ssh2'
import type {
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
const MAXIMUM_RETURNED_DIRECTORIES = 500
const MAXIMUM_SCANNED_ENTRIES = 2_000
const MAXIMUM_READDIR_CALLBACKS = 64
const OPERATION_TIMEOUT_MS = 30_000
const MAXIMUM_REMOTE_PATH_BYTES = 4_096
const MAXIMUM_NAME_BYTES = 255
export type DirectorySftp = Pick<
SFTPWrapper,
| 'realpath'
| 'opendir'
| 'readdir'
| 'close'
| 'end'
| 'on'
| 'removeListener'
>
export type DirectorySftpOpener = (
callback: (
error: Error | undefined,
sftp?: DirectorySftp
) => void
) => void
function abortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function isCanonicalAbsolutePosixPath(value: unknown): value is string {
return (
typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAXIMUM_REMOTE_PATH_BYTES &&
value.startsWith('/') &&
(value === '/' || !value.endsWith('/')) &&
!value.includes('//') &&
!value.includes('\\') &&
!value
.split('/')
.some((part) => part === '.' || part === '..') &&
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value)
)
}
function validateRequestedPath(path: string | undefined): void {
if (path !== undefined && !isCanonicalAbsolutePosixPath(path)) {
throw new Error('SSH 目录路径无效')
}
}
function parentPath(path: string): string | null {
if (path === '/') {
return null
}
const separator = path.lastIndexOf('/')
return separator === 0 ? '/' : path.slice(0, separator)
}
function validDirectoryName(entry: FileEntryWithStats): boolean {
const name = entry?.filename
if (
typeof name !== 'string' ||
name === '.' ||
name === '..' ||
name.length === 0 ||
Buffer.byteLength(name, 'utf8') > MAXIMUM_NAME_BYTES ||
name.includes('/') ||
name.includes('\\') ||
/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(name)
) {
return false
}
try {
return (
typeof entry.attrs?.isDirectory === 'function' &&
entry.attrs.isDirectory() === true &&
typeof entry.attrs.isSymbolicLink === 'function' &&
entry.attrs.isSymbolicLink() === false
)
} catch {
return false
}
}
function joinPath(parent: string, name: string): string {
return parent === '/' ? `/${name}` : `${parent}/${name}`
}
function compareUtf8(left: string, right: string): number {
return Buffer.compare(
Buffer.from(left, 'utf8'),
Buffer.from(right, 'utf8')
)
}
function isSftpEndOfDirectory(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
error.code === 1
)
}
export function listBoundedSftpDirectories(
openSftp: DirectorySftpOpener,
requestedPath?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
validateRequestedPath(requestedPath)
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
return new Promise((resolve, reject) => {
let sftp: DirectorySftp | undefined
let handle: Buffer | undefined
let handleCloseRequested = false
let channelEnded = false
let settled = false
let scannedEntries = 0
let readdirCallbacks = 0
let truncated = false
let homeDirectory: string | undefined
let listingPath: string | undefined
const directories = new Set<string>()
const endChannel = (): void => {
if (channelEnded || !sftp) {
return
}
channelEnded = true
sftp.removeListener('error', onChannelError)
sftp.end()
}
const closeHandleBestEffort = (
candidate: Buffer | undefined = handle,
afterClose?: (error?: Error | null) => void
): void => {
if (!candidate || !sftp) {
afterClose?.()
return
}
if (candidate === handle) {
if (handleCloseRequested) {
afterClose?.()
return
}
handleCloseRequested = true
}
try {
sftp.close(candidate, (error) => {
afterClose?.(error)
})
} catch (error) {
afterClose?.(
error instanceof Error
? error
: new Error('SFTP 目录句柄关闭失败')
)
}
}
const finishError = (error: unknown): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
closeHandleBestEffort()
endChannel()
reject(error)
}
const finishSuccess = (): void => {
if (settled || !listingPath) {
return
}
const sorted = [...directories].sort(compareUtf8)
const result: SshHostDirectoryBrowseResult = {
path: listingPath,
homeDirectory: homeDirectory!,
parentPath: parentPath(listingPath),
entries: sorted
.slice(0, MAXIMUM_RETURNED_DIRECTORIES)
.map((name) => ({
name,
path: joinPath(listingPath!, name)
})),
truncated:
truncated ||
sorted.length > MAXIMUM_RETURNED_DIRECTORIES
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
closeHandleBestEffort()
endChannel()
resolve(result)
}
const onAbort = (): void => {
finishError(abortError(signal))
}
const onChannelError = (error: Error): void => {
finishError(error)
}
const timeout = setTimeout(() => {
finishError(new Error('SSH 目录浏览超时'))
}, OPERATION_TIMEOUT_MS)
const readNextBatch = (): void => {
if (settled || !sftp || !handle) {
return
}
if (
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
return
}
readdirCallbacks += 1
try {
sftp.readdir(handle, (error, rawList) => {
if (settled) {
return
}
if (isSftpEndOfDirectory(error)) {
finishSuccess()
return
}
if (error) {
finishError(error)
return
}
const list = rawList as
| FileEntryWithStats[]
| false
| undefined
if (list === false || list === undefined) {
finishSuccess()
return
}
if (!Array.isArray(list)) {
finishError(new Error('SFTP 目录列表无效'))
return
}
if (list.length === 0) {
finishSuccess()
return
}
const remaining =
MAXIMUM_SCANNED_ENTRIES - scannedEntries
const inspected = list.slice(0, remaining)
scannedEntries += inspected.length
for (const entry of inspected) {
if (
validDirectoryName(entry) &&
isCanonicalAbsolutePosixPath(
joinPath(listingPath!, entry.filename)
)
) {
directories.add(entry.filename)
}
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
})
} catch (error) {
finishError(error)
}
}
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
return
}
try {
openSftp((openError, openedSftp) => {
if (settled) {
openedSftp?.end()
return
}
if (openError || !openedSftp) {
finishError(openError ?? new Error('无法打开 SFTP 通道'))
return
}
sftp = openedSftp
sftp.on('error', onChannelError)
try {
sftp.realpath('.', (homeError, homePath) => {
if (settled) {
return
}
if (
homeError ||
!isCanonicalAbsolutePosixPath(homePath)
) {
finishError(
homeError ?? new Error('SSH 主目录路径无效')
)
return
}
homeDirectory = homePath
try {
sftp!.realpath(
requestedPath ?? homePath,
(pathError, canonicalPath) => {
if (settled) {
return
}
if (
pathError ||
!isCanonicalAbsolutePosixPath(canonicalPath)
) {
finishError(
pathError ?? new Error('SSH 目录路径无效')
)
return
}
listingPath = canonicalPath
try {
sftp!.opendir(
canonicalPath,
(directoryError, openedHandle) => {
if (settled) {
closeHandleBestEffort(openedHandle)
endChannel()
return
}
if (directoryError || !openedHandle) {
finishError(
directoryError ??
new Error('无法打开 SSH 目录')
)
return
}
handle = openedHandle
readNextBatch()
}
)
} catch (error) {
finishError(error)
}
}
)
} catch (error) {
finishError(error)
}
})
} catch (error) {
finishError(error)
}
})
} catch (error) {
finishError(error)
}
})
}
+900
View File
@@ -0,0 +1,900 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { BoundedStagedSftp } from './bounded-sftp'
type Entry = {
type: 'file' | 'directory' | 'symbolic-link'
contents?: Buffer
reportedSize?: number
}
function stats(entry: Entry) {
return {
mode:
entry.type === 'directory'
? 0o40700
: entry.type === 'symbolic-link'
? 0o120777
: 0o100600,
uid: 1000,
gid: 1000,
size: entry.reportedSize ?? entry.contents?.byteLength ?? 0,
atime: 1,
mtime: 2,
isDirectory: () => entry.type === 'directory',
isFile: () => entry.type === 'file',
isSymbolicLink: () => entry.type === 'symbolic-link',
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false
}
}
function missingError(): Error & { code: number } {
return Object.assign(new Error('not found'), { code: 2 })
}
function createSftp(root = '/safe/staging') {
const emitter = new EventEmitter()
const entries = new Map<string, Entry>([
[root, { type: 'directory' }]
])
const handles = new Map<string, Entry>()
let nextHandle = 0
const lookup = (
path: string,
callback: (error?: Error, value?: ReturnType<typeof stats>) => void
): void => {
const entry = entries.get(path)
if (!entry) {
callback(missingError())
return
}
callback(undefined, stats(entry))
}
const sftp = Object.assign(emitter, {
mkdir: vi.fn(
(
path: string,
_attributes: unknown,
callback: (error?: Error) => void
) => {
entries.set(path, { type: 'directory' })
callback()
}
),
writeFile: vi.fn(
(
path: string,
contents: Buffer,
_options: unknown,
callback: (error?: Error) => void
) => {
entries.set(path, {
type: 'file',
contents: Buffer.from(contents)
})
callback()
}
),
chmod: vi.fn(
(
_path: string,
_mode: number,
callback: (error?: Error) => void
) => callback()
),
rename: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
entries.delete(source)
entries.set(destination, entry)
callback()
}
),
ext_openssh_rename: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
entries.delete(source)
entries.set(destination, entry)
callback()
}
),
ext_openssh_hardlink: vi.fn(
(
source: string,
destination: string,
callback: (error?: Error) => void
) => {
const entry = entries.get(source)
if (!entry) {
callback(missingError())
return
}
if (entries.has(destination)) {
callback(new Error('destination exists'))
return
}
entries.set(destination, entry)
callback()
}
),
unlink: vi.fn(
(path: string, callback: (error?: Error) => void) => {
entries.delete(path)
callback()
}
),
rmdir: vi.fn(
(path: string, callback: (error?: Error) => void) => {
entries.delete(path)
callback()
}
),
lstat: vi.fn(lookup),
stat: vi.fn(lookup),
open: vi.fn(
(
path: string,
_mode: string,
callback: (error?: Error, handle?: Buffer) => void
) => {
const entry = entries.get(path)
if (!entry) {
callback(missingError())
return
}
const handle = Buffer.from(`handle-${nextHandle++}`)
handles.set(handle.toString(), entry)
callback(undefined, handle)
}
),
fstat: vi.fn(
(
handle: Buffer,
callback: (
error?: Error,
value?: ReturnType<typeof stats>
) => void
) => {
const entry = handles.get(handle.toString())
if (!entry) {
callback(missingError())
return
}
callback(undefined, stats(entry))
}
),
read: vi.fn(
(
handle: Buffer,
buffer: Buffer,
offset: number,
length: number,
position: number,
callback: (
error: Error | undefined,
bytesRead: number
) => void
) => {
const contents =
handles.get(handle.toString())?.contents ?? Buffer.alloc(0)
const bytesRead = Math.min(
length,
Math.max(0, contents.byteLength - position)
)
contents.copy(
buffer,
offset,
position,
position + bytesRead
)
callback(undefined, bytesRead)
}
),
close: vi.fn(
(handle: Buffer, callback: (error?: Error) => void) => {
handles.delete(handle.toString())
callback()
}
),
end: vi.fn()
})
return { sftp, entries }
}
describe('bounded staged SFTP', () => {
it('keeps writes inside staging and supports bounded metadata and reads', async () => {
const { sftp } = createSftp(
'/home/builder/.goodbuddy/staging/random'
)
const staged = new BoundedStagedSftp(
sftp as never,
'/home/builder/.goodbuddy/staging/random'
)
await staged.mkdir('bin')
await staged.writeFile(
'bin/goodbuddy-agent',
Buffer.from('agent')
)
await expect(
staged.readFile('bin/goodbuddy-agent')
).resolves.toEqual(Buffer.from('agent'))
await expect(
staged.lstat('bin/goodbuddy-agent')
).resolves.toMatchObject({
type: 'file',
size: 5,
mode: 0o600
})
await expect(
staged.stat('bin/goodbuddy-agent')
).resolves.toMatchObject({ type: 'file', size: 5 })
await staged.setExecutable('bin/goodbuddy-agent')
await staged.rename(
'bin/goodbuddy-agent',
'bin/agent-ready'
)
expect(sftp.mkdir).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin',
{ mode: 0o700 },
expect.any(Function)
)
expect(sftp.writeFile).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent',
Buffer.from('agent'),
{ mode: 0o600, flag: 'wx' },
expect.any(Function)
)
expect(sftp.chmod).toHaveBeenCalledWith(
'/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent',
0o700,
expect.any(Function)
)
})
it('pipelines bounded reads for large files', async () => {
const { sftp, entries } = createSftp()
const contents = Buffer.alloc(4 * 64 * 1024, 0x5a)
entries.set('/safe/staging/large', {
type: 'file',
contents
})
const pending: Array<{
buffer: Buffer
offset: number
length: number
position: number
callback: (
error: Error | undefined,
bytesRead: number
) => void
}> = []
sftp.read.mockImplementation(
(
_handle,
buffer,
offset,
length,
position,
callback
) => {
pending.push({
buffer,
offset,
length,
position,
callback
})
}
)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: contents.byteLength,
maximumTotalBytes: contents.byteLength
}
)
const read = staged.readFile('large')
await vi.waitFor(() => expect(pending).toHaveLength(4))
for (const request of pending.splice(0)) {
contents.copy(
request.buffer,
request.offset,
request.position,
request.position + request.length
)
request.callback(undefined, request.length)
}
await vi.waitFor(() => expect(pending).toHaveLength(1))
pending[0]!.callback(undefined, 0)
await expect(read).resolves.toEqual(contents)
})
it('allows only exact safe modes and removes only real directories', async () => {
const { sftp } = createSftp()
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await staged.mkdir('empty')
await staged.chmod('empty', 0o755)
await staged.writeFile('public-file', Buffer.from('ok'))
await staged.chmod('public-file', 0o644)
await expect(
staged.chmod('public-file', 0o666 as never)
).rejects.toThrow('允许列表')
await expect(
staged.chmod('empty', 0o600)
).rejects.toThrow('目录权限')
await staged.rmdir('empty')
expect(sftp.chmod).toHaveBeenCalledWith(
'/safe/staging/empty',
0o755,
expect.any(Function)
)
expect(sftp.rmdir).toHaveBeenCalledWith(
'/safe/staging/empty',
expect.any(Function)
)
})
it('atomically replaces only a regular managed file', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'file',
contents: Buffer.from('old')
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await staged.writeFile('replacement', Buffer.from('new'))
await staged.replaceFile('replacement', 'current')
expect(entries.get('/safe/staging/current')?.contents).toEqual(
Buffer.from('new')
)
expect(sftp.ext_openssh_rename).toHaveBeenCalledWith(
'/safe/staging/replacement',
'/safe/staging/current',
expect.any(Function)
)
expect(sftp.rename).not.toHaveBeenCalled()
entries.set('/safe/staging/link', {
type: 'symbolic-link'
})
await staged.writeFile('other', Buffer.from('other'))
await expect(
staged.replaceFile('other', 'link')
).rejects.toThrow('符号链接')
})
it('hard-links only regular files to absent managed destinations', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'directory'
})
entries.set('/safe/staging/current/node', {
type: 'file',
contents: Buffer.from('node')
})
entries.set('/safe/staging/current/link', {
type: 'symbolic-link'
})
entries.set('/safe/staging/linked-source', {
type: 'symbolic-link'
})
entries.set('/safe/staging/linked-source/node', {
type: 'file',
contents: Buffer.from('escaped')
})
entries.set('/safe/staging/candidate', {
type: 'directory'
})
entries.set('/safe/staging/linked-destination', {
type: 'symbolic-link'
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 1,
maximumTotalBytes: 1,
maximumOperations: 6
}
)
await staged.hardLink(
'current/node',
'candidate/node'
)
expect(entries.get('/safe/staging/candidate/node')).toBe(
entries.get('/safe/staging/current/node')
)
expect(sftp.ext_openssh_hardlink).toHaveBeenCalledWith(
'/safe/staging/current/node',
'/safe/staging/candidate/node',
expect.any(Function)
)
await expect(
staged.hardLink('current/link', 'candidate/other')
).rejects.toThrow('符号链接')
await expect(
staged.hardLink(
'linked-source/node',
'candidate/escaped-source'
)
).rejects.toThrow('符号链接')
await expect(
staged.hardLink(
'current/node',
'linked-destination/escaped-destination'
)
).rejects.toThrow('符号链接')
await expect(
staged.hardLink('current/node', 'candidate/node')
).rejects.toThrow('目标已存在')
await expect(
staged.hardLink('../current/node', 'candidate/escape')
).rejects.toThrow('相对路径无效')
expect(sftp.ext_openssh_hardlink).toHaveBeenCalledOnce()
})
it('propagates hard-link extension errors without transferring bytes', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/current', {
type: 'file',
contents: Buffer.alloc(8)
})
sftp.ext_openssh_hardlink.mockImplementation(
(
_source,
_destination,
callback
) => callback(new Error('unsupported extension'))
)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 1,
maximumTotalBytes: 1,
maximumOperations: 2
}
)
await expect(
staged.hardLink('current', 'candidate')
).rejects.toThrow('unsupported extension')
await expect(staged.mkdir('still-available')).resolves
.toBeUndefined()
await expect(staged.mkdir('operation-limit')).rejects.toThrow(
'操作数量'
)
expect(sftp.writeFile).not.toHaveBeenCalled()
expect(sftp.open).not.toHaveBeenCalled()
})
it('fails closed for symlinks and unsupported remote types', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/link', {
type: 'symbolic-link'
})
entries.set('/safe/staging/link/agent', {
type: 'file',
contents: Buffer.from('escaped')
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await expect(staged.stat('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.lstat('link')).resolves.toMatchObject({
type: 'symbolic-link'
})
await expect(staged.readFile('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.unlink('link')).rejects.toThrow(
'符号链接'
)
await expect(staged.readFile('link/agent')).rejects.toThrow(
'符号链接'
)
expect(sftp.open).not.toHaveBeenCalled()
expect(sftp.unlink).not.toHaveBeenCalled()
})
it('rejects reads that exceed metadata or grow during transfer', async () => {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/large', {
type: 'file',
contents: Buffer.alloc(5)
})
entries.set('/safe/staging/growing', {
type: 'file',
contents: Buffer.alloc(5),
reportedSize: 4
})
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 4,
maximumTotalBytes: 8
}
)
await expect(staged.readFile('large')).rejects.toThrow(
'读取大小'
)
await expect(staged.readFile('growing')).rejects.toThrow(
'读取大小'
)
expect(sftp.close).toHaveBeenCalledOnce()
})
it('rejects traversal, absolute relative paths, and unsafe staging roots', async () => {
const { sftp } = createSftp()
expect(
() =>
new BoundedStagedSftp(
sftp as never,
'relative/staging'
)
).toThrow('暂存目录无效')
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging'
)
await expect(staged.mkdir('../escape')).rejects.toThrow(
'相对路径无效'
)
await expect(
staged.writeFile('/escape', Buffer.alloc(1))
).rejects.toThrow('相对路径无效')
expect(sftp.mkdir).not.toHaveBeenCalled()
expect(sftp.writeFile).not.toHaveBeenCalled()
})
it('bounds total bytes, operation count, timeout, and cancellation', async () => {
const firstHarness = createSftp()
const staged = new BoundedStagedSftp(
firstHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 4,
maximumTotalBytes: 5,
maximumOperations: 2
}
)
await staged.writeFile('first', Buffer.alloc(3))
await expect(
staged.writeFile('total', Buffer.alloc(3))
).rejects.toThrow('传输总量')
await expect(staged.mkdir('third')).rejects.toThrow(
'操作数量'
)
const pendingHarness = createSftp()
pendingHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const cancellable = new BoundedStagedSftp(
pendingHarness.sftp as never,
'/safe/staging'
)
const controller = new AbortController()
const write = cancellable.writeFile(
'agent',
Buffer.alloc(1),
controller.signal
)
await vi.waitFor(() =>
expect(
pendingHarness.sftp.writeFile
).toHaveBeenCalledOnce()
)
controller.abort()
await expect(write).rejects.toMatchObject({
name: 'AbortError'
})
expect(pendingHarness.sftp.end).toHaveBeenCalledOnce()
vi.useFakeTimers()
try {
const timeoutHarness = createSftp()
timeoutHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const timed = new BoundedStagedSftp(
timeoutHarness.sftp as never,
'/safe/staging',
{ operationTimeoutMs: 1 }
)
const timedWrite = timed.writeFile(
'agent',
Buffer.alloc(1)
)
const timedRejection = expect(timedWrite).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(
timeoutHarness.sftp.writeFile
).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
await timedRejection
expect(timeoutHarness.sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('scales large transfer timeouts by the bounded file size', async () => {
vi.useFakeTimers()
try {
const writeHarness = createSftp()
writeHarness.sftp.writeFile.mockImplementation(
() => undefined
)
const writeSftp = new BoundedStagedSftp(
writeHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 64 * 1024,
operationTimeoutMs: 1
}
)
const write = writeSftp.writeFile(
'large',
Buffer.alloc(64 * 1024)
)
const writeRejection = expect(write).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(999)
expect(writeHarness.sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await writeRejection
expect(writeHarness.sftp.end).toHaveBeenCalledOnce()
const readHarness = createSftp()
readHarness.entries.set('/safe/staging/large', {
type: 'file',
contents: Buffer.alloc(64 * 1024)
})
readHarness.sftp.read.mockImplementation(
() => undefined
)
const readSftp = new BoundedStagedSftp(
readHarness.sftp as never,
'/safe/staging',
{
maximumFileBytes: 64 * 1024,
maximumTotalBytes: 64 * 1024,
operationTimeoutMs: 1
}
)
const read = readSftp.readFile('large')
const readRejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(999)
expect(readHarness.sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await readRejection
expect(readHarness.sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('starts operation deadlines only after queued work reaches the head', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
let finishWrite: ((error?: Error) => void) | undefined
sftp.writeFile.mockImplementation(
(
_path: string,
_contents: Buffer,
_options: unknown,
callback: (error?: Error) => void
) => {
finishWrite = callback
}
)
sftp.mkdir.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 10 }
)
const first = staged.writeFile('first', Buffer.alloc(1))
const second = staged.mkdir('second')
const secondRejection = expect(second).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.writeFile).toHaveBeenCalledOnce()
expect(sftp.mkdir).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(9)
finishWrite?.()
await expect(first).resolves.toBeUndefined()
await vi.advanceTimersByTimeAsync(0)
expect(sftp.mkdir).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(9)
await secondRejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('cancels queued work promptly without waiting for the active operation', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
sftp.writeFile.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 10 }
)
const active = staged.writeFile('active', Buffer.alloc(1))
const activeRejection = expect(active).rejects.toThrow(
'操作超时'
)
const controller = new AbortController()
const queued = staged.mkdir('queued', controller.signal)
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await expect(queued).rejects.toMatchObject({
name: 'AbortError'
})
expect(sftp.end).toHaveBeenCalledOnce()
expect(sftp.mkdir).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10)
await activeRejection
} finally {
vi.useRealTimers()
}
})
it('uses the opened file size instead of the configured read maximum', async () => {
vi.useFakeTimers()
try {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/small', {
type: 'file',
contents: Buffer.alloc(64 * 1024)
})
sftp.read.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 128 * 1024 * 1024,
operationTimeoutMs: 1
}
)
const read = staged.readFile('small')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.read).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(999)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('keeps stalled read metadata checks on the fixed operation timeout', async () => {
vi.useFakeTimers()
try {
const { sftp } = createSftp()
sftp.lstat.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{
maximumFileBytes: 128 * 1024 * 1024,
operationTimeoutMs: 1
}
)
const read = staged.readFile('small')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.lstat).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
expect(sftp.open).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('caps transfer deadlines at the revised 60-minute size boundary', async () => {
vi.useFakeTimers()
try {
const { sftp, entries } = createSftp()
entries.set('/safe/staging/capped', {
type: 'file',
reportedSize: 64 * 1024 * 60 * 60 + 1
})
sftp.read.mockImplementation(() => undefined)
const staged = new BoundedStagedSftp(
sftp as never,
'/safe/staging',
{ operationTimeoutMs: 1 }
)
const read = staged.readFile('capped')
const rejection = expect(read).rejects.toThrow(
'操作超时'
)
await vi.advanceTimersByTimeAsync(0)
expect(sftp.read).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60 * 60_000 - 1)
expect(sftp.end).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await rejection
expect(sftp.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+846
View File
@@ -0,0 +1,846 @@
import type { SFTPWrapper, Stats } from 'ssh2'
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000
const MAX_OPERATION_TIMEOUT_MS = 60_000
const MINIMUM_TRANSFER_BYTES_PER_SECOND = 64 * 1024
const MAX_TRANSFER_TIMEOUT_MS = 60 * 60_000
const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024
const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024
const DEFAULT_MAX_OPERATIONS = 2_048
const MAX_REMOTE_PATH_BYTES = 4_096
const MAX_REMOTE_PATH_SEGMENTS = 128
const READ_CHUNK_BYTES = 64 * 1024
const MAXIMUM_CONCURRENT_READS = 32
const ALLOWED_MODES = [0o600, 0o644, 0o700, 0o755] as const
export type BoundedSftpLimits = {
maximumFileBytes?: number
maximumTotalBytes?: number
maximumOperations?: number
operationTimeoutMs?: number
}
export type SftpEntryType =
| 'file'
| 'directory'
| 'symbolic-link'
export type SftpEntryMetadata = {
type: SftpEntryType
size: number
mode: number
uid: number
gid: number
atime: number
mtime: number
}
export type AllowedSftpMode = (typeof ALLOWED_MODES)[number]
export type StagedSftp = {
readonly stagingDirectory: string
mkdir(relativePath: string, signal?: AbortSignal): Promise<void>
writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void>
readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer>
lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata>
chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void>
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void>
rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
hardLink?(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void>
unlink(relativePath: string, signal?: AbortSignal): Promise<void>
rmdir(relativePath: string, signal?: AbortSignal): Promise<void>
close(): void
}
type SftpLike = Pick<
SFTPWrapper,
| 'mkdir'
| 'writeFile'
| 'chmod'
| 'rename'
| 'ext_openssh_rename'
| 'ext_openssh_hardlink'
| 'unlink'
| 'rmdir'
| 'lstat'
| 'stat'
| 'open'
| 'read'
| 'close'
| 'fstat'
| 'end'
| 'on'
>
function validatePositiveLimit(
value: number | undefined,
fallback: number,
maximum: number
): number {
const resolved = value ?? fallback
if (
!Number.isSafeInteger(resolved) ||
resolved <= 0 ||
resolved > maximum
) {
throw new Error('SFTP 安全限制无效')
}
return resolved
}
function validateStagingDirectory(value: string): string {
if (
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.includes('//') ||
!(value.startsWith('/') || /^[A-Za-z]:\//u.test(value))
) {
throw new Error('SFTP 暂存目录无效')
}
const normalized = value.replace(/\/+$/u, '')
const pathWithoutRoot = normalized.replace(
/^(?:\/|[A-Za-z]:\/)/u,
''
)
const parts = pathWithoutRoot.split('/')
if (
!pathWithoutRoot ||
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 暂存目录无效')
}
return normalized
}
function validateRelativePath(value: string): string {
if (
!value ||
Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES ||
value.includes('\0') ||
value.includes('\\') ||
value.startsWith('/') ||
/^[A-Za-z]:/u.test(value)
) {
throw new Error('SFTP 相对路径无效')
}
const parts = value.split('/')
if (
parts.length > MAX_REMOTE_PATH_SEGMENTS ||
parts.some((part) => !part || part === '.' || part === '..')
) {
throw new Error('SFTP 相对路径无效')
}
return value
}
function abortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
('code' in error &&
((error as { code?: unknown }).code === 2 ||
(error as { code?: unknown }).code === 'ENOENT'))
)
}
function metadataFromStats(stats: Stats): SftpEntryMetadata {
if (
!Number.isSafeInteger(stats.size) ||
stats.size < 0 ||
!Number.isSafeInteger(stats.mode) ||
!Number.isSafeInteger(stats.uid) ||
!Number.isSafeInteger(stats.gid) ||
!Number.isFinite(stats.atime) ||
!Number.isFinite(stats.mtime)
) {
throw new Error('SFTP 文件元数据无效')
}
const predicates = [
['file', stats.isFile],
['directory', stats.isDirectory],
['symbolic-link', stats.isSymbolicLink]
] as const
const types = predicates
.filter(([, predicate]) => {
if (typeof predicate !== 'function') {
throw new Error('SFTP 文件类型元数据无效')
}
return predicate.call(stats)
})
.map(([type]) => type)
if (types.length !== 1) {
throw new Error('SFTP 文件类型不受支持')
}
return {
type: types[0]!,
size: stats.size,
mode: stats.mode & 0o7777,
uid: stats.uid,
gid: stats.gid,
atime: stats.atime,
mtime: stats.mtime
}
}
export class BoundedStagedSftp implements StagedSftp {
readonly stagingDirectory: string
private readonly maximumFileBytes: number
private readonly maximumTotalBytes: number
private readonly maximumOperations: number
private readonly operationTimeoutMs: number
private totalBytes = 0
private operations = 0
private closed = false
private queue: Promise<void> = Promise.resolve()
constructor(
private readonly sftp: SftpLike,
stagingDirectory: string,
limits: BoundedSftpLimits = {}
) {
this.stagingDirectory =
validateStagingDirectory(stagingDirectory)
this.maximumFileBytes = validatePositiveLimit(
limits.maximumFileBytes,
DEFAULT_MAX_FILE_BYTES,
DEFAULT_MAX_FILE_BYTES
)
this.maximumTotalBytes = validatePositiveLimit(
limits.maximumTotalBytes,
DEFAULT_MAX_TOTAL_BYTES,
DEFAULT_MAX_TOTAL_BYTES
)
this.maximumOperations = validatePositiveLimit(
limits.maximumOperations,
DEFAULT_MAX_OPERATIONS,
DEFAULT_MAX_OPERATIONS
)
this.operationTimeoutMs = validatePositiveLimit(
limits.operationTimeoutMs,
DEFAULT_OPERATION_TIMEOUT_MS,
MAX_OPERATION_TIMEOUT_MS
)
this.sftp.on('error', () => {
this.closed = true
})
}
async mkdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.mkdir(remotePath, { mode: 0o700 }, done)
})
})
}
async writeFile(
relativePath: string,
contents: Buffer,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (contents.byteLength > this.maximumFileBytes) {
throw new Error('SFTP 文件大小超过安全限制')
}
return this.enqueue(
signal,
async () => {
if (
this.totalBytes + contents.byteLength >
this.maximumTotalBytes
) {
throw new Error('SFTP 传输总量超过安全限制')
}
await this.assertSafeAncestors(validated)
await this.call<void>((done) => {
this.sftp.writeFile(
remotePath,
contents,
{ mode: 0o600, flag: 'wx' },
done
)
})
this.totalBytes += contents.byteLength
},
this.transferTimeoutMs(contents.byteLength)
)
}
async readFile(
relativePath: string,
signal?: AbortSignal
): Promise<Buffer> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(
signal,
async (resetTimeout) => {
const metadata = await this.safeStat(validated)
this.assertType(metadata, 'file')
const remainingTotal =
this.maximumTotalBytes - this.totalBytes
const maximumReadBytes = Math.min(
this.maximumFileBytes,
remainingTotal
)
if (
maximumReadBytes <= 0 ||
metadata.size > maximumReadBytes
) {
throw new Error('SFTP 读取大小超过安全限制')
}
const handle = await this.call<Buffer>((done) => {
this.sftp.open(remotePath, 'r', done)
})
try {
const openedMetadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.fstat(handle, done)
})
)
this.assertType(openedMetadata, 'file')
if (openedMetadata.size > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
resetTimeout(
this.transferTimeoutMs(openedMetadata.size)
)
const contents = await this.readHandle(
handle,
openedMetadata.size,
maximumReadBytes
)
this.totalBytes += contents.byteLength
return contents
} finally {
await this.closeHandle(handle)
}
}
)
}
async lstat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
return this.getLstat(this.resolveValidated(validated))
})
}
async stat(
relativePath: string,
signal?: AbortSignal
): Promise<SftpEntryMetadata> {
const validated = validateRelativePath(relativePath)
return this.enqueue(signal, () => this.safeStat(validated))
}
async chmod(
relativePath: string,
mode: AllowedSftpMode,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
if (!(ALLOWED_MODES as readonly number[]).includes(mode)) {
throw new Error('SFTP 文件权限不在安全允许列表中')
}
return this.enqueue(signal, async () => {
const metadata = await this.safeStat(validated)
if (
metadata.type === 'directory' &&
mode !== 0o700 &&
mode !== 0o755
) {
throw new Error('SFTP 目录权限必须保留执行权限')
}
await this.call<void>((done) => {
this.sftp.chmod(remotePath, mode, done)
})
})
}
setExecutable(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
return this.chmod(relativePath, 0o700, signal)
}
async rename(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
const sourceMetadata = await this.getLstat(source)
this.assertNotLink(sourceMetadata)
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 重命名目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.rename(source, destination, done)
})
})
}
async replaceFile(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
this.assertType(await this.getLstat(destination), 'file')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
try {
await this.call<void>((done) => {
this.sftp.ext_openssh_rename(
source,
destination,
done
)
})
} catch (error) {
throw new Error('SFTP 原子文件替换失败', {
cause: error
})
}
})
}
async hardLink(
sourceRelativePath: string,
destinationRelativePath: string,
signal?: AbortSignal
): Promise<void> {
const sourceValidated = validateRelativePath(sourceRelativePath)
const destinationValidated = validateRelativePath(
destinationRelativePath
)
const source = this.resolveValidated(sourceValidated)
const destination = this.resolveValidated(destinationValidated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(sourceValidated)
this.assertType(await this.getLstat(source), 'file')
await this.assertSafeAncestors(destinationValidated)
try {
await this.getLstat(destination)
throw new Error('SFTP 硬链接目标已存在')
} catch (error) {
if (!isMissingPathError(error)) {
throw error
}
}
await this.call<void>((done) => {
this.sftp.ext_openssh_hardlink(
source,
destination,
done
)
})
this.assertType(await this.getLstat(destination), 'file')
})
}
async unlink(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(await this.getLstat(remotePath), 'file')
await this.call<void>((done) => {
this.sftp.unlink(remotePath, done)
})
})
}
async rmdir(
relativePath: string,
signal?: AbortSignal
): Promise<void> {
const validated = validateRelativePath(relativePath)
const remotePath = this.resolveValidated(validated)
return this.enqueue(signal, async () => {
await this.assertSafeAncestors(validated)
this.assertType(
await this.getLstat(remotePath),
'directory'
)
await this.call<void>((done) => {
this.sftp.rmdir(remotePath, done)
})
})
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.sftp.end()
}
private resolveValidated(relativePath: string): string {
return `${this.stagingDirectory}/${relativePath}`
}
private async assertSafeAncestors(
relativePath: string
): Promise<void> {
const parts = relativePath.split('/')
const paths = [this.stagingDirectory]
let current = this.stagingDirectory
for (const part of parts.slice(0, -1)) {
current = `${current}/${part}`
paths.push(current)
}
for (const path of paths) {
this.assertType(await this.getLstat(path), 'directory')
}
}
private async safeStat(
relativePath: string
): Promise<SftpEntryMetadata> {
await this.assertSafeAncestors(relativePath)
const remotePath = this.resolveValidated(relativePath)
const linkMetadata = await this.getLstat(remotePath)
this.assertNotLink(linkMetadata)
const metadata = metadataFromStats(
await this.call<Stats>((done) => {
this.sftp.stat(remotePath, done)
})
)
this.assertNotLink(metadata)
if (metadata.type !== linkMetadata.type) {
throw new Error('SFTP 文件类型在操作期间发生变化')
}
return metadata
}
private getLstat(remotePath: string): Promise<SftpEntryMetadata> {
return this.call<Stats>((done) => {
this.sftp.lstat(remotePath, done)
}).then(metadataFromStats)
}
private assertNotLink(metadata: SftpEntryMetadata): void {
if (metadata.type === 'symbolic-link') {
throw new Error('SFTP 不允许操作符号链接')
}
}
private assertType(
metadata: SftpEntryMetadata,
expected: Exclude<SftpEntryType, 'symbolic-link'>
): void {
this.assertNotLink(metadata)
if (metadata.type !== expected) {
throw new Error(
expected === 'file'
? 'SFTP 目标不是普通文件'
: 'SFTP 目标不是目录'
)
}
}
private async readHandle(
handle: Buffer,
expectedSize: number,
maximumReadBytes: number
): Promise<Buffer> {
const contents = Buffer.allocUnsafe(expectedSize)
let nextPosition = 0
let discoveredSize = expectedSize
const readSegment = async (): Promise<void> => {
while (true) {
const position = nextPosition
nextPosition += READ_CHUNK_BYTES
if (
position >= expectedSize ||
position >= discoveredSize
) {
return
}
const segmentLength = Math.min(
READ_CHUNK_BYTES,
expectedSize - position
)
let segmentOffset = 0
while (
segmentOffset < segmentLength &&
position + segmentOffset < discoveredSize
) {
const length = segmentLength - segmentOffset
const bytesRead = await this.call<number>((done) => {
this.sftp.read(
handle,
contents,
position + segmentOffset,
length,
position + segmentOffset,
(error, count) => done(error, count)
)
})
if (
!Number.isSafeInteger(bytesRead) ||
bytesRead < 0 ||
bytesRead > length
) {
throw new Error('SFTP 读取结果无效')
}
if (bytesRead === 0) {
discoveredSize = Math.min(
discoveredSize,
position + segmentOffset
)
return
}
segmentOffset += bytesRead
}
}
}
const workerCount = Math.min(
MAXIMUM_CONCURRENT_READS,
Math.ceil(expectedSize / READ_CHUNK_BYTES)
)
await Promise.all(
Array.from({ length: workerCount }, readSegment)
)
const extra = Buffer.allocUnsafe(1)
const extraBytes = await this.call<number>((done) => {
this.sftp.read(
handle,
extra,
0,
1,
expectedSize,
(error, count) => done(error, count)
)
})
if (extraBytes !== 0) {
throw new Error('SFTP 读取大小超过安全限制')
}
const actualSize = Math.min(expectedSize, discoveredSize)
if (actualSize > maximumReadBytes) {
throw new Error('SFTP 读取大小超过安全限制')
}
return actualSize === contents.byteLength
? contents
: contents.subarray(0, actualSize)
}
private async closeHandle(handle: Buffer): Promise<void> {
try {
await this.call<void>((done) => {
this.sftp.close(handle, done)
})
} catch {
this.close()
}
}
private call<T>(
begin: (
callback: (error?: Error | null, value?: T) => void
) => void
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
return new Promise((resolve, reject) => {
try {
begin((error, value) => {
if (error) {
reject(error)
} else {
resolve(value as T)
}
})
} catch (error) {
reject(error)
}
})
}
private transferTimeoutMs(byteLength: number): number {
const scaled = Math.ceil(
(byteLength / MINIMUM_TRANSFER_BYTES_PER_SECOND) * 1_000
)
return Math.min(
MAX_TRANSFER_TIMEOUT_MS,
Math.max(this.operationTimeoutMs, scaled)
)
}
private enqueue<T>(
signal: AbortSignal | undefined,
operation: (
resetTimeout: (timeoutMs: number) => void
) => Promise<T>,
timeoutMs = this.operationTimeoutMs
): Promise<T> {
if (this.closed) {
return Promise.reject(new Error('SFTP 通道已关闭'))
}
if (signal?.aborted) {
return Promise.reject(abortError(signal))
}
this.operations += 1
if (this.operations > this.maximumOperations) {
return Promise.reject(
new Error('SFTP 操作数量超过安全限制')
)
}
const previous = this.queue
let releaseQueue!: () => void
this.queue = new Promise<void>((resolve) => {
releaseQueue = resolve
})
return new Promise<T>((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | undefined
const finish = (value?: T, error?: unknown): void => {
if (settled) {
return
}
settled = true
if (timeout !== undefined) {
clearTimeout(timeout)
}
signal?.removeEventListener('abort', abort)
releaseQueue()
if (error) {
reject(error)
} else {
resolve(value as T)
}
}
const abort = (): void => {
this.close()
finish(undefined, abortError(signal))
}
const resetTimeout = (nextTimeoutMs: number): void => {
if (settled) {
return
}
if (timeout !== undefined) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
this.close()
finish(undefined, new Error('SFTP 操作超时'))
}, nextTimeoutMs)
}
const start = (): void => {
if (settled) {
return
}
if (this.closed) {
finish(undefined, new Error('SFTP 通道已关闭'))
return
}
if (signal?.aborted) {
abort()
return
}
resetTimeout(timeoutMs)
Promise.resolve()
.then(() => operation(resetTimeout))
.then(
(value) => finish(value),
(error: unknown) => finish(undefined, error)
)
}
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) {
abort()
return
}
previous.then(start, start)
})
}
}
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
buildFixedAgentCliArgv,
buildFixedAgentSshCommand,
parseAgentBootstrapProbeOutput,
verifyAgentInstallationId
} from './ssh-agent-command'
function bootstrapOutput(
overrides: Partial<Record<string, string>> = {}
): string {
const values = {
home: '/home/builder',
uid: '1001',
os: 'Linux',
arch: 'x86_64',
shell: '/bin/bash',
procfs: 'ready',
...overrides
}
return [
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1',
...Object.entries(values).map(
([key, value]) => `${key}=${value}`
),
''
].join('\n')
}
describe('fixed Agent SSH commands', () => {
it('uses one exact, fixed, non-mutating bootstrap probe command', () => {
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).toBe(
`printf 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1\\n'; ` +
`if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi; ` +
`if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi; ` +
`if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi; ` +
`if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi; ` +
`printf 'shell=%s\\n' "\${SHELL:-unknown}"; ` +
`if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi`
)
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch(
/\b(?:sudo|env|printenv|set|export|install|mkdir|touch|rm)\b/u
)
expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch(
/\b(?:systemctl|loginctl)\b|Linger/u
)
})
it('builds only fixed commands from a separately verified installation ID', () => {
const installationId = verifyAgentInstallationId(
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, { kind: 'attach' })
).toBe(
'exec "$HOME/.goodbuddy/agent/installations/' +
'agent_2026-08-20_abcdef012345/goodbuddy-agent" ' +
'attach-or-bootstrap --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, { kind: 'doctor' })
).toContain(
'/goodbuddy-agent" doctor --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'bootstrap'
})
).toContain(
'/goodbuddy-agent" bootstrap --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
expect(
buildFixedAgentCliArgv(installationId, {
kind: 'lifecycle',
action: 'bootstrap'
})
).toEqual([
'bootstrap',
'--installation-id',
'agent_2026-08-20_abcdef012345'
])
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'health'
})
).toContain(
'/goodbuddy-agent" health --installation-id ' +
'agent_2026-08-20_abcdef012345'
)
})
it('rejects shell syntax and path traversal in installation IDs', () => {
for (const value of [
'../current',
'agent/latest',
'agent; shutdown',
'$(whoami)',
'agent.v1',
'',
'a'.repeat(129)
]) {
expect(() => verifyAgentInstallationId(value)).toThrow(
'installation ID 无效'
)
}
})
it('builds a closed digest-addressed OpenCode activation command', () => {
const installationId = verifyAgentInstallationId('agent-v1')
const bundleDigest = `sha256:${'a'.repeat(64)}`
expect(
buildFixedAgentCliArgv(installationId, {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'arm64'
})
).toEqual([
'runtime',
'activate',
'--installation-id',
'agent-v1',
'--runtime-id',
'opencode',
'--bundle-digest',
bundleDigest,
'--architecture',
'arm64'
])
expect(
buildFixedAgentSshCommand(installationId, {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'arm64'
})
).toBe(
'exec "$HOME/.goodbuddy/agent/installations/agent-v1/' +
'goodbuddy-agent" runtime activate --installation-id agent-v1 ' +
`--runtime-id opencode --bundle-digest ${bundleDigest} ` +
'--architecture arm64'
)
})
it('rejects unchecked Runtime activation argv before command construction', () => {
const installationId = verifyAgentInstallationId('agent-v1')
const valid = {
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest: `sha256:${'a'.repeat(64)}`,
architecture: 'x64'
} as const
for (const action of [
{ ...valid, runtimeId: 'opencode; id' },
{ ...valid, bundleDigest: `sha256:${'a'.repeat(63)};id` },
{ ...valid, architecture: 'x64;id' }
]) {
expect(() =>
buildFixedAgentSshCommand(
installationId,
action as never
)
).toThrow(/Runtime/iu)
}
})
it('fails closed if an untyped caller supplies an unknown lifecycle action', () => {
const installationId = verifyAgentInstallationId('agent-v1')
expect(() =>
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action: 'arbitrary command'
} as never)
).toThrow('lifecycle action 无效')
expect(() =>
buildFixedAgentSshCommand(
'../unverified' as typeof installationId,
{ kind: 'attach' }
)
).toThrow('installation ID 无效')
})
it('parses supported Linux x64 and arm64 bootstrap results', () => {
expect(parseAgentBootstrapProbeOutput(bootstrapOutput())).toEqual({
ready: true,
platform: 'linux',
architecture: 'x64',
canonicalHomeDirectory: '/home/builder',
uid: 1_001,
shell: '/bin/bash',
procfs: 'ready'
})
expect(
parseAgentBootstrapProbeOutput(
bootstrapOutput({ arch: 'aarch64' })
)
).toMatchObject({
ready: true,
architecture: 'arm64'
})
})
it('returns explicit incompatible bootstrap reasons', () => {
const cases: Array<
[Partial<Record<string, string>>, string]
> = [
[{ os: 'Darwin' }, 'non-linux'],
[{ arch: 'riscv64' }, 'unsupported-architecture'],
[{ home: 'unknown' }, 'home-directory-unavailable'],
[{ uid: 'unknown' }, 'uid-unavailable'],
[{ shell: 'unknown' }, 'shell-unavailable'],
[{ procfs: 'unavailable' }, 'procfs-unavailable']
]
for (const [overrides, reason] of cases) {
expect(
parseAgentBootstrapProbeOutput(bootstrapOutput(overrides))
).toEqual({ ready: false, reason })
}
})
it('rejects malformed, reordered, incomplete, and unsafe output', () => {
const valid = bootstrapOutput()
for (const output of [
valid.slice(0, -1),
valid.replace('home=', 'uid='),
valid.replace('home=/home/builder', 'home=relative/path'),
valid.replace('/home/builder', '/home/../root'),
valid.replace('/bin/bash', '/bin/ba\u0000sh'),
valid.replace('/bin/bash', '/bin/ba\u0085sh'),
bootstrapOutput({ uid: '1001x' }),
bootstrapOutput({ procfs: 'maybe' }),
valid.replace('uid=1001', 'uid=1001\nuid=1001'),
`noise\n${valid}`,
valid.replace('\n', '\r\n'),
'x'.repeat(16 * 1024 + 1)
]) {
expect(() =>
parseAgentBootstrapProbeOutput(output)
).toThrow('无效')
}
})
})
+252
View File
@@ -0,0 +1,252 @@
import {
agentBootstrapProbeResultSchema,
canonicalRemotePathSchema,
type AgentBootstrapIncompatibleReason,
type AgentBootstrapProbeResult
} from '../../shared/ssh-host-contracts'
import { sha256DigestSchema } from '../../shared/agent-protocol/contracts'
const INSTALLATION_ID_PATTERN =
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u
const AGENT_BOOTSTRAP_PROBE_MARKER =
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1'
const MAX_PROBE_FIELD_LENGTH = 4_096
const MAX_LINUX_UID = 4_294_967_294
export const AGENT_BOOTSTRAP_PROBE_COMMAND = [
`printf '${AGENT_BOOTSTRAP_PROBE_MARKER}\\n'`,
`if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi`,
`if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi`,
`if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi`,
`if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi`,
`printf 'shell=%s\\n' "\${SHELL:-unknown}"`,
`if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi`
].join('; ')
const lifecycleActions = [
'bootstrap',
'health',
'status',
'retire',
'stop'
] as const
const runtimeArchitectures = ['x64', 'arm64'] as const
const RUNTIME_ID = 'opencode' as const
declare const verifiedInstallationIdBrand: unique symbol
export type VerifiedAgentInstallationId = string & {
readonly [verifiedInstallationIdBrand]: true
}
export type AgentLifecycleAction = (typeof lifecycleActions)[number]
export type AgentRuntimeArchitecture =
(typeof runtimeArchitectures)[number]
export type AgentRuntimeActivationAction = {
kind: 'runtime-activate'
runtimeId: typeof RUNTIME_ID
bundleDigest: string
architecture: AgentRuntimeArchitecture
}
export type FixedAgentSshAction =
| { kind: 'attach' }
| { kind: 'doctor' }
| { kind: 'lifecycle'; action: AgentLifecycleAction }
| AgentRuntimeActivationAction
export type FixedAgentCliArgv = readonly string[]
const probeFields = [
'home',
'uid',
'os',
'arch',
'shell',
'procfs'
] as const
function incompatible(
reason: AgentBootstrapIncompatibleReason
): AgentBootstrapProbeResult {
return agentBootstrapProbeResultSchema.parse({
ready: false,
reason
})
}
function isSafeProbeField(value: string): boolean {
return (
value.length > 0 &&
value.length <= MAX_PROBE_FIELD_LENGTH &&
!/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value)
)
}
function parseLinuxUid(value: string): number | undefined {
if (!/^(?:0|[1-9][0-9]{0,9})$/u.test(value)) {
return undefined
}
const uid = Number(value)
return Number.isSafeInteger(uid) && uid <= MAX_LINUX_UID
? uid
: undefined
}
export function parseAgentBootstrapProbeOutput(
stdout: string
): AgentBootstrapProbeResult {
if (
!stdout.endsWith('\n') ||
stdout.length > 16 * 1024 ||
stdout.includes('\r') ||
stdout.includes('\0')
) {
throw new Error('Agent 启动探针返回了无效结果')
}
const lines = stdout.slice(0, -1).split('\n')
if (
lines.length !== probeFields.length + 1 ||
lines[0] !== AGENT_BOOTSTRAP_PROBE_MARKER
) {
throw new Error('Agent 启动探针返回了无效结果')
}
const values = new Map<string, string>()
for (const [index, field] of probeFields.entries()) {
const prefix = `${field}=`
const line = lines[index + 1]
if (!line?.startsWith(prefix)) {
throw new Error('Agent 启动探针返回了无效结果')
}
const value = line.slice(prefix.length)
if (!isSafeProbeField(value)) {
throw new Error('Agent 启动探针返回了无效结果')
}
values.set(field, value)
}
const os = values.get('os')
if (os !== 'Linux') {
return incompatible('non-linux')
}
const rawArchitecture = values.get('arch')
const architecture =
rawArchitecture === 'x86_64'
? 'x64'
: rawArchitecture === 'aarch64'
? 'arm64'
: undefined
if (!architecture) {
return incompatible('unsupported-architecture')
}
const home = values.get('home') ?? ''
if (home === 'unknown') {
return incompatible('home-directory-unavailable')
}
if (!canonicalRemotePathSchema.safeParse(home).success) {
throw new Error('Agent 启动探针返回了无效路径')
}
const rawUid = values.get('uid')
if (rawUid === 'unknown') {
return incompatible('uid-unavailable')
}
const uid = parseLinuxUid(rawUid ?? '')
if (uid === undefined) {
throw new Error('Agent 启动探针返回了无效 UID')
}
const shell = values.get('shell') ?? ''
if (shell === 'unknown') {
return incompatible('shell-unavailable')
}
if (!canonicalRemotePathSchema.safeParse(shell).success) {
throw new Error('Agent 启动探针返回了无效路径')
}
const procfs = values.get('procfs')
if (procfs !== 'ready' && procfs !== 'unavailable') {
throw new Error('Agent 启动探针返回了无效结果')
}
if (procfs === 'unavailable') {
return incompatible('procfs-unavailable')
}
return agentBootstrapProbeResultSchema.parse({
ready: true,
platform: 'linux',
architecture,
canonicalHomeDirectory: home,
uid,
shell,
procfs: 'ready'
})
}
export function verifyAgentInstallationId(
value: string
): VerifiedAgentInstallationId {
if (
value.length > 128 ||
!INSTALLATION_ID_PATTERN.test(value)
) {
throw new Error('GoodBuddy Agent installation ID 无效')
}
return value as VerifiedAgentInstallationId
}
export function buildFixedAgentCliArgv(
installationId: VerifiedAgentInstallationId,
action: FixedAgentSshAction
): FixedAgentCliArgv {
const verifiedInstallationId =
verifyAgentInstallationId(installationId)
const installationOption = [
'--installation-id',
verifiedInstallationId
] as const
switch (action.kind) {
case 'attach':
return ['attach-or-bootstrap', ...installationOption]
case 'doctor':
return ['doctor', ...installationOption]
case 'lifecycle':
if (!lifecycleActions.includes(action.action)) {
throw new Error('GoodBuddy Agent lifecycle action 无效')
}
return [action.action, ...installationOption]
case 'runtime-activate': {
if (action.runtimeId !== RUNTIME_ID) {
throw new Error('GoodBuddy Agent Runtime ID 无效')
}
if (!sha256DigestSchema.safeParse(action.bundleDigest).success) {
throw new Error('GoodBuddy Agent Runtime bundle digest 无效')
}
if (!runtimeArchitectures.includes(action.architecture)) {
throw new Error('GoodBuddy Agent Runtime architecture 无效')
}
return [
'runtime',
'activate',
...installationOption,
'--runtime-id',
action.runtimeId,
'--bundle-digest',
action.bundleDigest,
'--architecture',
action.architecture
]
}
}
}
export function buildFixedAgentSshCommand(
installationId: VerifiedAgentInstallationId,
action: FixedAgentSshAction
): string {
const verifiedInstallationId =
verifyAgentInstallationId(installationId)
const argv = buildFixedAgentCliArgv(verifiedInstallationId, action)
const executable =
`"$HOME/.goodbuddy/agent/installations/${verifiedInstallationId}` +
'/goodbuddy-agent"'
return `exec ${executable} ${argv.join(' ')}`
}
+610
View File
@@ -0,0 +1,610 @@
import { EventEmitter } from 'node:events'
import type { ConnectConfig } from 'ssh2'
import { describe, expect, it, vi } from 'vitest'
import type { ResolvedSshHost } from './ssh-host-store'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
verifyAgentInstallationId
} from './ssh-agent-command'
import {
Ssh2AuthenticatedConnection,
SshConnectionPool,
type AuthenticatedSshConnection,
type SshConnectionFactory,
type SshConnectionPoolTarget,
type SshPoolConnectionIdentity
} from './ssh-connection-pool'
const sshEd25519Algorithm = Buffer.from('ssh-ed25519')
const pinnedHostKey = Buffer.concat([
Buffer.from([0, 0, 0, sshEd25519Algorithm.byteLength]),
sshEd25519Algorithm,
Buffer.from([0, 0, 0, 32]),
Buffer.alloc(32, 7)
])
const hostKey = {
algorithm: 'ssh-ed25519',
publicKeyBase64: pinnedHostKey.toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 3
}
function createTarget(
overrides: Partial<ResolvedSshHost> = {},
identityOverrides: Partial<
Pick<
SshConnectionPoolTarget,
'hostRevision' | 'hostKeyGeneration'
>
> = {}
): SshConnectionPoolTarget {
return {
host: {
id: '00000000-0000-4000-8000-000000000201',
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey,
...overrides
},
hostRevision: 7,
hostKeyGeneration: 3,
...identityOverrides
}
}
function createConnection(identity: SshPoolConnectionIdentity) {
return {
identity,
isUsable: vi.fn(() => true),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories: vi.fn(),
dispose: vi.fn()
} satisfies AuthenticatedSshConnection
}
describe('SSH connection pool', () => {
it('reuses one authenticated connection for matching identities and refcounts leases', async () => {
vi.useFakeTimers()
try {
const connections: ReturnType<typeof createConnection>[] = []
const factory: SshConnectionFactory = vi.fn(
async (_target, identity) => {
const connection = createConnection(identity)
connections.push(connection)
return connection
}
)
const pool = new SshConnectionPool(factory, {
idleTimeoutMs: 100
})
const [first, second] = await Promise.all([
pool.acquire(createTarget()),
pool.acquire(createTarget())
])
expect(factory).toHaveBeenCalledOnce()
expect(first.identity).toEqual(second.identity)
first.release()
await vi.advanceTimersByTimeAsync(100)
expect(connections[0]?.dispose).not.toHaveBeenCalled()
second.release()
await vi.advanceTimersByTimeAsync(99)
expect(connections[0]?.dispose).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(connections[0]?.dispose).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('isolates revision, host-key generation, credential, and host identities', async () => {
const factory: SshConnectionFactory = vi.fn(
async (_target, identity) => createConnection(identity)
)
const pool = new SshConnectionPool(factory)
const targets = [
createTarget(),
createTarget({}, { hostRevision: 8 }),
createTarget(
{ hostKey: { ...hostKey, generation: 4 } },
{ hostKeyGeneration: 4 }
),
createTarget({ password: 'rotated password' }),
createTarget({
id: '00000000-0000-4000-8000-000000000202'
})
]
const leases = await Promise.all(
targets.map((target) => pool.acquire(target))
)
expect(factory).toHaveBeenCalledTimes(5)
expect(
new Set(
leases.map(
(lease) =>
`${lease.identity.hostId}:${lease.identity.hostRevision}:` +
`${lease.identity.hostKeyGeneration}:` +
lease.identity.authenticationIdentity
)
).size
).toBe(5)
for (const lease of leases) {
lease.release()
}
pool.dispose()
})
it('lets one canceled waiter leave without canceling a shared connection', async () => {
let resolveConnection:
| ((connection: AuthenticatedSshConnection) => void)
| undefined
const factory: SshConnectionFactory = vi.fn(
(_target, identity) =>
new Promise<AuthenticatedSshConnection>((resolve) => {
resolveConnection = resolve
expect(identity.authenticationIdentity).toMatch(
/^[a-f0-9]{64}$/u
)
})
)
const pool = new SshConnectionPool(factory)
const controller = new AbortController()
const canceled = pool.acquire(createTarget(), controller.signal)
const surviving = pool.acquire(createTarget())
controller.abort()
await expect(canceled).rejects.toMatchObject({
name: 'AbortError'
})
const identity = (
factory as ReturnType<typeof vi.fn>
).mock.calls[0]?.[1] as SshPoolConnectionIdentity
resolveConnection?.(createConnection(identity))
const lease = await surviving
expect(lease.isUsable()).toBe(true)
lease.release()
pool.dispose()
})
it('disposes all host entries and prevents use after release', async () => {
const connection = createConnection({
hostId: createTarget().host.id,
hostRevision: 7,
hostKeyGeneration: 3,
authenticationIdentity: 'a'.repeat(64)
})
const pool = new SshConnectionPool(async () => connection)
const lease = await pool.acquire(createTarget())
const signal = new AbortController().signal
void lease.runAgentBootstrapProbe(signal)
expect(
connection.runAgentBootstrapProbe
).toHaveBeenCalledWith(signal)
void lease.listDirectories('/srv', signal)
expect(connection.listDirectories).toHaveBeenCalledWith(
'/srv',
signal
)
void lease.runAgentRuntimeAction(
verifyAgentInstallationId('agent-v1'),
{
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest: `sha256:${'a'.repeat(64)}`,
architecture: 'x64'
},
signal
)
expect(connection.runAgentRuntimeAction).toHaveBeenCalledWith(
'agent-v1',
expect.objectContaining({ kind: 'runtime-activate' }),
signal
)
lease.release()
expect(() =>
lease.openAgentAttach(
verifyAgentInstallationId('agent-v1')
)
).toThrow('租约已释放')
expect(() =>
lease.runAgentBootstrapProbe()
).toThrow('租约已释放')
expect(() =>
lease.listDirectories()
).toThrow('租约已释放')
expect(lease).not.toHaveProperty('exec')
expect(lease).not.toHaveProperty('sftp')
pool.disposeHost(createTarget().host.id)
expect(connection.dispose).toHaveBeenCalledOnce()
})
})
describe('authenticated ssh2 connection', () => {
it('uses pinned algorithms, keepalive, no forwarding, and only fixed Agent exec', async () => {
let connectConfig: ConnectConfig | undefined
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(
this: EventEmitter,
config: ConnectConfig
) {
connectConfig = config
expect(
(
config.hostVerifier as
| ((key: Buffer) => boolean)
| undefined
)?.(pinnedHostKey)
).toBe(true)
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => callback(undefined, channel)
),
sftp: vi.fn()
})
const target = createTarget()
const identity: SshPoolConnectionIdentity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
}
const connection = await Ssh2AuthenticatedConnection.connect(
target,
identity,
new AbortController().signal,
{
createClient: () => client as never,
supportedOpenSslCiphers: () => ['aes-256-gcm']
}
)
await connection.openAgentAttach(
verifyAgentInstallationId('agent-v1')
)
expect(connectConfig).toMatchObject({
host: 'build.example.com',
port: 22,
username: 'builder',
password: 'private password',
agentForward: false,
keepaliveInterval: 5_000,
keepaliveCountMax: 2,
algorithms: {
cipher: ['aes256-gcm@openssh.com']
}
})
expect(client.exec).toHaveBeenCalledWith(
'exec "$HOME/.goodbuddy/agent/installations/' +
'agent-v1/goodbuddy-agent" attach-or-bootstrap ' +
'--installation-id agent-v1',
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
expect.any(Function)
)
expect(connection).not.toHaveProperty('exec')
})
it('hard-fails a host-key mismatch and aborts pending authentication', async () => {
let authenticationAttempted = false
const mismatchedClient = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(
this: EventEmitter,
config: ConnectConfig
) {
const accepted = (
config.hostVerifier as
| ((key: Buffer) => boolean)
| undefined
)?.(Buffer.from('different-key'))
if (accepted) {
authenticationAttempted = true
}
this.emit(
'error',
Object.assign(new Error('key rejected'), {
level: 'handshake'
})
)
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
sftp: vi.fn()
})
const target = createTarget()
const identity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
}
expect(() =>
Ssh2AuthenticatedConnection.connect(
target,
{ ...identity, hostRevision: identity.hostRevision + 1 },
new AbortController().signal,
{ createClient: () => mismatchedClient as never }
)
).toThrow('认证目标不匹配')
await expect(
Ssh2AuthenticatedConnection.connect(
target,
identity,
new AbortController().signal,
{ createClient: () => mismatchedClient as never }
)
).rejects.toThrow('主机密钥已变化')
expect(authenticationAttempted).toBe(false)
expect(mismatchedClient.destroy).toHaveBeenCalledOnce()
const pendingClient = Object.assign(new EventEmitter(), {
connect: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
sftp: vi.fn()
})
const controller = new AbortController()
const pending = Ssh2AuthenticatedConnection.connect(
target,
identity,
controller.signal,
{ createClient: () => pendingClient as never }
)
controller.abort()
await expect(pending).rejects.toMatchObject({
name: 'AbortError'
})
expect(pendingClient.destroy).toHaveBeenCalledOnce()
})
it('bounds fixed diagnostic output and destroys an overflowing channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', Buffer.alloc(64 * 1024 + 1))
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(
connection.runAgentDoctor(
verifyAgentInstallationId('agent-v1')
)
).rejects.toThrow('输出超过安全限制')
expect(client.exec.mock.calls[0]?.[0]).toContain(
'/goodbuddy-agent" doctor'
)
expect(channel.destroy).toHaveBeenCalledOnce()
})
it('bounds bootstrap probe output and destroys an overflowing channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', Buffer.alloc(64 * 1024 + 1))
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(
connection.runAgentBootstrapProbe()
).rejects.toThrow('输出超过安全限制')
expect(client.exec.mock.calls[0]?.[0]).toBe(
AGENT_BOOTSTRAP_PROBE_COMMAND
)
expect(channel.destroy).toHaveBeenCalledOnce()
})
it('runs the fixed bootstrap probe and collects split output', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const output = [
'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1',
'home=/home/builder',
'uid=1001',
'os=Linux',
'arch=aarch64',
'shell=/bin/bash',
'procfs=ready',
''
].join('\n')
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => {
callback(undefined, channel)
setTimeout(() => {
channel.emit('data', output.slice(0, 17))
channel.emit('data', Buffer.from(output.slice(17)))
channel.emit('close', 0)
}, 0)
}
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
await expect(connection.runAgentBootstrapProbe()).resolves.toMatchObject({
ready: true,
architecture: 'arm64'
})
expect(client.exec).toHaveBeenCalledWith(
AGENT_BOOTSTRAP_PROBE_COMMAND,
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
expect.any(Function)
)
})
it('cancels a running bootstrap probe and destroys its channel', async () => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
_options: unknown,
callback: (
error: Error | undefined,
stream: typeof channel
) => void
) => callback(undefined, channel)
),
sftp: vi.fn()
})
const target = createTarget()
const connection = await Ssh2AuthenticatedConnection.connect(
target,
{
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: 'a'.repeat(64)
},
new AbortController().signal,
{ createClient: () => client as never }
)
const controller = new AbortController()
const result = connection.runAgentBootstrapProbe(controller.signal)
controller.abort()
await expect(result).rejects.toMatchObject({
name: 'AbortError'
})
expect(channel.destroy).toHaveBeenCalledOnce()
})
})
+878
View File
@@ -0,0 +1,878 @@
import { createHmac, getCiphers, randomBytes } from 'node:crypto'
import { Client } from 'ssh2'
import type {
ClientChannel,
ConnectConfig,
SFTPWrapper
} from 'ssh2'
import {
BoundedStagedSftp,
type BoundedSftpLimits,
type StagedSftp
} from './bounded-sftp'
import { listBoundedSftpDirectories } from './bounded-directory-sftp'
import type { ResolvedSshHost } from './ssh-host-store'
import {
AGENT_BOOTSTRAP_PROBE_COMMAND,
buildFixedAgentSshCommand,
parseAgentBootstrapProbeOutput,
type AgentLifecycleAction,
type AgentRuntimeActivationAction,
type VerifiedAgentInstallationId
} from './ssh-agent-command'
import type {
AgentBootstrapProbeResult,
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
import {
buildAuthenticatedSshConnectConfig,
createStrongSshAlgorithms,
defaultSystemAgent,
safeSshError,
SSH_CONNECTION_TIMEOUT_MS
} from './ssh-transport'
const FIXED_COMMAND_TIMEOUT_MS = 15_000
const CHANNEL_OPEN_TIMEOUT_MS = 10_000
const MAX_DIAGNOSTIC_OUTPUT_BYTES = 64 * 1024
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
const MAX_POOL_ENTRIES = 32
export type SshConnectionPoolTarget = {
host: ResolvedSshHost
hostRevision: number
hostKeyGeneration: number
}
export type SshPoolConnectionIdentity = {
hostId: string
hostRevision: number
hostKeyGeneration: number
authenticationIdentity: string
}
export type AgentDiagnosticResult = {
exitCode: number | null
stdout: string
stderr: string
}
export interface AuthenticatedSshConnection {
readonly identity: SshPoolConnectionIdentity
isUsable(): boolean
openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<ClientChannel>
runAgentDoctor(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
runAgentBootstrapProbe(
signal?: AbortSignal
): Promise<AgentBootstrapProbeResult>
runAgentLifecycleAction(
installationId: VerifiedAgentInstallationId,
action: AgentLifecycleAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
runAgentRuntimeAction(
installationId: VerifiedAgentInstallationId,
action: AgentRuntimeActivationAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult>
openStagedSftp(
stagingDirectory: string,
limits?: BoundedSftpLimits,
signal?: AbortSignal
): Promise<StagedSftp>
listDirectories(
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult>
dispose(): void
}
export interface SshConnectionLease
extends Omit<AuthenticatedSshConnection, 'dispose'> {
release(): void
}
type ClientLike = Pick<
Client,
| 'connect'
| 'end'
| 'destroy'
| 'exec'
| 'sftp'
| 'on'
| 'once'
>
type SshConnectionDependencies = {
createClient: () => ClientLike
supportedOpenSslCiphers: () => readonly string[]
systemAgent: () => string | undefined
}
export type SshConnectionFactory = (
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity,
signal: AbortSignal
) => Promise<AuthenticatedSshConnection>
type PoolEntry = {
key: string
identity: SshPoolConnectionIdentity
controller: AbortController
connectionPromise: Promise<AuthenticatedSshConnection>
connection?: AuthenticatedSshConnection
leases: number
waiters: number
idleTimer?: ReturnType<typeof setTimeout>
}
function createAbortError(signal?: AbortSignal): unknown {
return (
signal?.reason ??
new DOMException('The operation was aborted', 'AbortError')
)
}
function validatePoolTarget(target: SshConnectionPoolTarget): void {
if (
!target.host.id ||
!Number.isSafeInteger(target.hostRevision) ||
target.hostRevision <= 0 ||
!Number.isSafeInteger(target.hostKeyGeneration) ||
target.hostKeyGeneration <= 0
) {
throw new Error('SSH 连接身份无效')
}
if (!target.host.hostKey) {
throw new Error('请先验证并接受 SSH 主机密钥')
}
if (
target.host.hostKey.generation !==
target.hostKeyGeneration
) {
throw new Error('SSH 主机密钥代际不匹配')
}
}
function validateConnectionIdentity(
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity
): void {
if (
identity.hostId !== target.host.id ||
identity.hostRevision !== target.hostRevision ||
identity.hostKeyGeneration !== target.hostKeyGeneration ||
!/^[a-f0-9]{64}$/u.test(identity.authenticationIdentity)
) {
throw new Error('SSH 连接身份与认证目标不匹配')
}
}
function createSshAuthenticationIdentity(
host: ResolvedSshHost,
systemAgent: string | undefined,
identityKey: Buffer
): string {
const credentialIdentity =
host.authentication === 'password'
? host.password
: systemAgent
if (!credentialIdentity) {
throw new Error(
host.authentication === 'password'
? 'SSH 主机尚未配置密码'
: '当前系统未检测到可用的 SSH Agent'
)
}
return createHmac('sha256', identityKey)
.update(
JSON.stringify([
'goodbuddy-ssh-auth-v1',
host.id,
host.hostname,
host.port,
host.username,
host.authentication,
credentialIdentity
])
)
.digest('hex')
}
function poolKey(identity: SshPoolConnectionIdentity): string {
return [
identity.hostId,
identity.hostRevision,
identity.hostKeyGeneration,
identity.authenticationIdentity
].join('\0')
}
function waitForSharedConnection(
promise: Promise<AuthenticatedSshConnection>,
signal?: AbortSignal
): Promise<AuthenticatedSshConnection> {
if (!signal) {
return promise
}
if (signal.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
const abort = (): void => {
reject(createAbortError(signal))
}
signal.addEventListener('abort', abort, { once: true })
promise.then(
(connection) => {
signal.removeEventListener('abort', abort)
resolve(connection)
},
(error: unknown) => {
signal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
export class Ssh2AuthenticatedConnection
implements AuthenticatedSshConnection
{
private usable = true
private disposed = false
private constructor(
readonly identity: SshPoolConnectionIdentity,
private readonly client: ClientLike
) {
const disconnected = (): void => {
this.usable = false
}
this.client.on('close', disconnected)
this.client.on('end', disconnected)
this.client.on('error', disconnected)
}
static connect(
target: SshConnectionPoolTarget,
identity: SshPoolConnectionIdentity,
signal: AbortSignal,
dependencies: Partial<SshConnectionDependencies> = {}
): Promise<Ssh2AuthenticatedConnection> {
validatePoolTarget(target)
validateConnectionIdentity(target, identity)
const resolvedDependencies: SshConnectionDependencies = {
createClient: () => new Client(),
supportedOpenSslCiphers: getCiphers,
systemAgent: defaultSystemAgent,
...dependencies
}
const algorithms = createStrongSshAlgorithms(
resolvedDependencies.supportedOpenSslCiphers()
)
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(createAbortError(signal))
return
}
const client = resolvedDependencies.createClient()
let settled = false
let hostKeyMismatch = false
const settle = (
connection?: Ssh2AuthenticatedConnection,
error?: unknown
): void => {
if (settled) {
connection?.dispose()
return
}
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
if (error) {
client.destroy()
reject(error)
} else if (connection) {
resolve(connection)
}
}
const abort = (): void => {
settle(undefined, createAbortError(signal))
}
const timeout = setTimeout(() => {
settle(undefined, new Error('SSH 连接超时'))
}, SSH_CONNECTION_TIMEOUT_MS)
signal.addEventListener('abort', abort, { once: true })
client.once('error', (error) => {
settle(
undefined,
hostKeyMismatch
? new Error(
'SSH 主机密钥已变化,请重新验证主机身份'
)
: safeSshError(error)
)
})
client.once('ready', () => {
settle(new Ssh2AuthenticatedConnection(identity, client))
})
let config: ConnectConfig
try {
config = buildAuthenticatedSshConnectConfig(
target.host,
algorithms,
resolvedDependencies.systemAgent(),
() => {
hostKeyMismatch = true
}
)
} catch (error) {
settle(undefined, error)
return
}
client.connect(config)
})
}
isUsable(): boolean {
return this.usable
}
async openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<ClientChannel> {
const channel = await this.openFixedChannel(
buildFixedAgentSshCommand(installationId, {
kind: 'attach'
}),
signal
)
if (signal?.aborted) {
channel.destroy()
throw createAbortError(signal)
}
if (signal) {
const abort = (): void => {
channel.destroy()
}
signal.addEventListener('abort', abort, { once: true })
channel.once('close', () => {
signal.removeEventListener('abort', abort)
})
}
return channel
}
async runAgentBootstrapProbe(
signal?: AbortSignal
): Promise<AgentBootstrapProbeResult> {
const result = await this.runBoundedCommand(
AGENT_BOOTSTRAP_PROBE_COMMAND,
signal
)
if (
result.exitCode !== 0 ||
result.stderr.length !== 0
) {
throw new Error('Agent 启动探针执行失败')
}
return parseAgentBootstrapProbeOutput(result.stdout)
}
runAgentDoctor(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, {
kind: 'doctor'
}),
signal
)
}
runAgentLifecycleAction(
installationId: VerifiedAgentInstallationId,
action: AgentLifecycleAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, {
kind: 'lifecycle',
action
}),
signal
)
}
runAgentRuntimeAction(
installationId: VerifiedAgentInstallationId,
action: AgentRuntimeActivationAction,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
return this.runBoundedCommand(
buildFixedAgentSshCommand(installationId, action),
signal
)
}
openStagedSftp(
stagingDirectory: string,
limits?: BoundedSftpLimits,
signal?: AbortSignal
): Promise<StagedSftp> {
this.assertUsable()
if (signal?.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
let settled = false
const finish = (
error?: unknown,
sftp?: SFTPWrapper
): void => {
if (settled) {
sftp?.end()
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error || !sftp) {
reject(error ?? new Error('无法打开 SFTP 通道'))
return
}
try {
resolve(
new BoundedStagedSftp(
sftp,
stagingDirectory,
limits
)
)
} catch (validationError) {
sftp.end()
reject(validationError)
}
}
const abort = (): void => {
finish(createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(new Error('SFTP 通道打开超时'))
}, CHANNEL_OPEN_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
this.client.sftp((error, sftp) => {
finish(error, sftp)
})
})
}
listDirectories(
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
this.assertUsable()
return listBoundedSftpDirectories(
(callback) => {
this.client.sftp((error, sftp) => {
callback(error, sftp)
})
},
path,
signal
)
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
this.usable = false
this.client.end()
this.client.destroy()
}
private assertUsable(): void {
if (!this.usable) {
throw new Error('SSH 连接已关闭')
}
}
private openFixedChannel(
command: string,
signal?: AbortSignal
): Promise<ClientChannel> {
this.assertUsable()
if (signal?.aborted) {
return Promise.reject(createAbortError(signal))
}
return new Promise((resolve, reject) => {
let settled = false
const finish = (
error?: unknown,
channel?: ClientChannel
): void => {
if (settled) {
channel?.destroy()
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error || !channel) {
reject(error ?? new Error('无法打开 Agent SSH 通道'))
} else {
resolve(channel)
}
}
const abort = (): void => {
finish(createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(new Error('Agent SSH 通道打开超时'))
}, CHANNEL_OPEN_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
this.client.exec(
command,
{
env: {},
pty: false,
x11: false,
allowHalfOpen: false
},
(error, channel) => finish(error, channel)
)
})
}
private async runBoundedCommand(
command: string,
signal?: AbortSignal
): Promise<AgentDiagnosticResult> {
const channel = await this.openFixedChannel(command, signal)
if (signal?.aborted) {
channel.destroy()
throw createAbortError(signal)
}
return new Promise((resolve, reject) => {
const stdout: Buffer[] = []
const stderr: Buffer[] = []
let outputBytes = 0
let settled = false
const finish = (
result?: AgentDiagnosticResult,
error?: unknown
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', abort)
if (error) {
channel.destroy()
reject(error)
} else if (result) {
resolve(result)
}
}
const collect = (
destination: Buffer[],
chunk: Buffer | string
): void => {
const buffer = Buffer.from(chunk)
outputBytes += buffer.byteLength
if (outputBytes > MAX_DIAGNOSTIC_OUTPUT_BYTES) {
finish(
undefined,
new Error('Agent 诊断输出超过安全限制')
)
return
}
destination.push(buffer)
}
const abort = (): void => {
finish(undefined, createAbortError(signal))
}
const timeout = setTimeout(() => {
finish(undefined, new Error('Agent 诊断超时'))
}, FIXED_COMMAND_TIMEOUT_MS)
signal?.addEventListener('abort', abort, { once: true })
channel.on('data', (chunk: Buffer | string) => {
collect(stdout, chunk)
})
channel.stderr.on('data', (chunk: Buffer | string) => {
collect(stderr, chunk)
})
channel.once('error', (error: Error) => {
finish(undefined, error)
})
channel.once(
'close',
(code: number | null) => {
finish({
exitCode: typeof code === 'number' ? code : null,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
})
}
)
})
}
}
export class SshConnectionPool {
private readonly entries = new Map<string, PoolEntry>()
private readonly factory: SshConnectionFactory
private readonly systemAgent: () => string | undefined
private readonly idleTimeoutMs: number
private readonly authenticationIdentityKey = randomBytes(32)
private disposed = false
constructor(
factory?: SshConnectionFactory,
options: {
systemAgent?: () => string | undefined
idleTimeoutMs?: number
} = {}
) {
this.systemAgent = options.systemAgent ?? defaultSystemAgent
this.factory =
factory ??
((target, identity, signal) =>
Ssh2AuthenticatedConnection.connect(
target,
identity,
signal,
{ systemAgent: this.systemAgent }
))
this.idleTimeoutMs =
options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
if (
!Number.isSafeInteger(this.idleTimeoutMs) ||
this.idleTimeoutMs < 0
) {
throw new Error('SSH 连接池空闲超时无效')
}
}
async acquire(
target: SshConnectionPoolTarget,
signal?: AbortSignal
): Promise<SshConnectionLease> {
if (this.disposed) {
throw new Error('SSH 连接池已关闭')
}
signal?.throwIfAborted()
validatePoolTarget(target)
const identity: SshPoolConnectionIdentity = {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
authenticationIdentity: createSshAuthenticationIdentity(
target.host,
this.systemAgent(),
this.authenticationIdentityKey
)
}
const key = poolKey(identity)
let entry = this.entries.get(key)
if (entry?.connection && !entry.connection.isUsable()) {
this.disposeEntry(entry)
entry = undefined
}
if (!entry) {
if (this.entries.size >= MAX_POOL_ENTRIES) {
throw new Error('SSH 连接池已达到安全上限')
}
const controller = new AbortController()
const newEntry: PoolEntry = {
key,
identity,
controller,
connectionPromise: Promise.resolve(undefined as never),
leases: 0,
waiters: 0
}
newEntry.connectionPromise = this.factory(
target,
identity,
controller.signal
).then(
(connection) => {
if (
this.disposed ||
this.entries.get(key) !== newEntry
) {
connection.dispose()
throw new Error('SSH 连接请求已失效')
}
newEntry.connection = connection
return connection
},
(error: unknown) => {
if (this.entries.get(key) === newEntry) {
this.entries.delete(key)
}
throw error
}
)
this.entries.set(key, newEntry)
entry = newEntry
}
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
entry.idleTimer = undefined
}
entry.waiters += 1
try {
const connection = await waitForSharedConnection(
entry.connectionPromise,
signal
)
if (!connection.isUsable()) {
this.disposeEntry(entry)
throw new Error('SSH 连接已关闭')
}
entry.leases += 1
return this.createLease(entry, connection)
} finally {
entry.waiters -= 1
if (entry.waiters === 0 && entry.leases === 0) {
this.scheduleIdleDisposal(entry)
}
}
}
disposeHost(hostId: string): void {
for (const entry of this.entries.values()) {
if (entry.identity.hostId === hostId) {
this.disposeEntry(entry)
}
}
}
dispose(): void {
if (this.disposed) {
return
}
this.disposed = true
for (const entry of [...this.entries.values()]) {
this.disposeEntry(entry)
}
}
private createLease(
entry: PoolEntry,
connection: AuthenticatedSshConnection
): SshConnectionLease {
let released = false
const assertActive = (): void => {
if (released) {
throw new Error('SSH 连接租约已释放')
}
}
return {
identity: connection.identity,
isUsable: () => !released && connection.isUsable(),
openAgentAttach: (installationId, signal) => {
assertActive()
return connection.openAgentAttach(installationId, signal)
},
runAgentDoctor: (installationId, signal) => {
assertActive()
return connection.runAgentDoctor(installationId, signal)
},
runAgentBootstrapProbe: (signal) => {
assertActive()
return connection.runAgentBootstrapProbe(signal)
},
runAgentLifecycleAction: (
installationId,
action,
signal
) => {
assertActive()
return connection.runAgentLifecycleAction(
installationId,
action,
signal
)
},
runAgentRuntimeAction: (
installationId,
action,
signal
) => {
assertActive()
return connection.runAgentRuntimeAction(
installationId,
action,
signal
)
},
openStagedSftp: (stagingDirectory, limits, signal) => {
assertActive()
return connection.openStagedSftp(
stagingDirectory,
limits,
signal
)
},
listDirectories: (path, signal) => {
assertActive()
return connection.listDirectories(path, signal)
},
release: () => {
if (released) {
return
}
released = true
entry.leases -= 1
if (entry.leases === 0 && entry.waiters === 0) {
this.scheduleIdleDisposal(entry)
}
}
}
}
private scheduleIdleDisposal(entry: PoolEntry): void {
if (
this.entries.get(entry.key) !== entry ||
entry.idleTimer
) {
return
}
if (!entry.connection || this.idleTimeoutMs === 0) {
this.disposeEntry(entry)
return
}
entry.idleTimer = setTimeout(() => {
if (entry.leases === 0 && entry.waiters === 0) {
this.disposeEntry(entry)
}
}, this.idleTimeoutMs)
}
private disposeEntry(entry: PoolEntry): void {
if (this.entries.get(entry.key) === entry) {
this.entries.delete(entry.key)
}
if (entry.idleTimer) {
clearTimeout(entry.idleTimer)
entry.idleTimer = undefined
}
entry.controller.abort()
entry.connection?.dispose()
}
}
@@ -0,0 +1,264 @@
import { describe, expect, it, vi } from 'vitest'
import type { SshConnectionTarget } from './ssh-host-store'
import type {
SshConnectionLease,
SshPoolConnectionIdentity
} from './ssh-connection-pool'
import { SshHostDirectoryBrowser } from './ssh-host-directory-browser'
const hostId = '00000000-0000-4000-8000-000000000301'
function createTarget(
overrides: Partial<SshConnectionTarget> = {}
): SshConnectionTarget {
return {
host: {
id: hostId,
name: 'Browse host',
hostname: 'browse.example.com',
port: 22,
username: 'browser',
authentication: 'password',
password: 'secret',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'key',
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 4
}
},
hostRevision: 9,
hostKeyGeneration: 4,
...overrides
}
}
function createLease(
identity: Partial<SshPoolConnectionIdentity> = {},
listDirectories = vi.fn(async () => ({
path: '/home/browser',
homeDirectory: '/home/browser',
parentPath: '/home',
entries: [],
truncated: false
}))
): SshConnectionLease {
return {
identity: {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
authenticationIdentity: 'a'.repeat(64),
...identity
},
isUsable: vi.fn(() => true),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories,
release: vi.fn()
}
}
function createBrowser(options: {
target?: SshConnectionTarget
lease?: ReturnType<typeof createLease>
events?: string[]
}) {
const events = options.events ?? []
const target = options.target ?? createTarget()
const lease = options.lease ?? createLease()
const resolveConnectionTarget = vi.fn(async () => {
events.push('resolve')
return target
})
const assertConnectionTargetCurrent = vi.fn(() => {
events.push('current')
})
const acquire = vi.fn(async () => {
events.push('acquire')
return lease
})
const originalList = lease.listDirectories
lease.listDirectories = vi.fn(async (path, signal) => {
events.push('list')
return originalList(path, signal)
})
const originalRelease = lease.release
lease.release = vi.fn(() => {
events.push('release')
originalRelease()
})
const browser = new SshHostDirectoryBrowser({
sshHosts: {
resolveConnectionTarget,
assertConnectionTargetCurrent
},
sshPool: { acquire }
})
return {
browser,
events,
target,
lease,
resolveConnectionTarget,
assertConnectionTargetCurrent,
acquire
}
}
describe('SSH host directory browser', () => {
it('checks the current target before and after browsing and always releases', async () => {
const test = createBrowser({})
const signal = new AbortController().signal
await expect(
test.browser.listDirectories(
hostId,
'/srv/projects',
signal
)
).resolves.toMatchObject({
path: '/home/browser',
homeDirectory: '/home/browser'
})
expect(test.events).toEqual([
'resolve',
'current',
'acquire',
'list',
'current',
'release'
])
expect(
test.assertConnectionTargetCurrent
).toHaveBeenNthCalledWith(1, {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
username: 'browser'
})
expect(
test.assertConnectionTargetCurrent
).toHaveBeenNthCalledWith(2, {
hostId,
hostRevision: 9,
hostKeyGeneration: 4,
username: 'browser'
})
expect(test.acquire).toHaveBeenCalledWith(test.target, signal)
expect(test.lease.listDirectories).toHaveBeenCalledWith(
'/srv/projects',
signal
)
expect(test.lease.release).toHaveBeenCalledOnce()
})
it.each([
['host id', { hostId: 'other-host' }],
['host revision', { hostRevision: 10 }],
['host-key generation', { hostKeyGeneration: 5 }]
])(
'rejects a lease with a mismatched %s and releases it',
async (_label, identity) => {
const test = createBrowser({
lease: createLease(identity)
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('连接身份不匹配')
expect(test.lease.listDirectories).not.toHaveBeenCalled()
expect(test.lease.release).toHaveBeenCalledOnce()
expect(
test.assertConnectionTargetCurrent
).toHaveBeenCalledOnce()
}
)
it('rejects a target resolved for another host before acquiring', async () => {
const target = createTarget()
target.host.id = '00000000-0000-4000-8000-000000000302'
const test = createBrowser({ target })
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('连接目标无效')
expect(test.acquire).not.toHaveBeenCalled()
expect(
test.assertConnectionTargetCurrent
).not.toHaveBeenCalled()
})
it('rejects a host change after listing and releases the lease', async () => {
const test = createBrowser({})
test.assertConnectionTargetCurrent
.mockImplementationOnce(() => {
test.events.push('current')
})
.mockImplementationOnce(() => {
test.events.push('current')
throw new Error('SSH 主机配置已变化,请重新验证')
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('主机配置已变化')
expect(test.events.at(-1)).toBe('release')
expect(test.lease.release).toHaveBeenCalledOnce()
})
it('releases the lease when directory browsing fails', async () => {
const test = createBrowser({
lease: createLease(
{},
vi.fn(async () => {
throw new Error('SFTP unavailable')
})
)
})
await expect(
test.browser.listDirectories(hostId)
).rejects.toThrow('SFTP unavailable')
expect(test.lease.release).toHaveBeenCalledOnce()
expect(
test.assertConnectionTargetCurrent
).toHaveBeenCalledOnce()
})
it('honors cancellation before target resolution and after acquisition', async () => {
const alreadyCanceled = new AbortController()
alreadyCanceled.abort()
const early = createBrowser({})
await expect(
early.browser.listDirectories(
hostId,
undefined,
alreadyCanceled.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(early.resolveConnectionTarget).not.toHaveBeenCalled()
const controller = new AbortController()
const late = createBrowser({})
late.acquire.mockImplementationOnce(async () => {
late.events.push('acquire')
controller.abort()
return late.lease
})
await expect(
late.browser.listDirectories(
hostId,
undefined,
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
expect(late.lease.listDirectories).not.toHaveBeenCalled()
expect(late.lease.release).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,99 @@
import type {
SshHostDirectoryBrowseResult
} from '../../shared/ssh-host-contracts'
import type {
CurrentSshConnectionTarget,
SshConnectionTarget,
SshHostStore
} from './ssh-host-store'
import type {
SshConnectionLease,
SshConnectionPool
} from './ssh-connection-pool'
export type SshHostDirectoryBrowserOptions = {
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
sshPool: Pick<SshConnectionPool, 'acquire'>
}
function currentTarget(
target: SshConnectionTarget
): CurrentSshConnectionTarget {
return {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
}
function assertTarget(
requestedHostId: string,
target: SshConnectionTarget
): void {
if (
target.host.id !== requestedHostId ||
target.hostRevision < 1 ||
target.hostKeyGeneration < 1 ||
!target.host.hostKey ||
target.host.hostKey.generation !==
target.hostKeyGeneration
) {
throw new Error('SSH 主机连接目标无效')
}
}
function assertLeaseIdentity(
target: SshConnectionTarget,
lease: SshConnectionLease
): void {
if (
lease.identity.hostId !== target.host.id ||
lease.identity.hostRevision !== target.hostRevision ||
lease.identity.hostKeyGeneration !==
target.hostKeyGeneration
) {
throw new Error('SSH 目录浏览连接身份不匹配')
}
}
export class SshHostDirectoryBrowser {
readonly #sshHosts: SshHostDirectoryBrowserOptions['sshHosts']
readonly #sshPool: SshHostDirectoryBrowserOptions['sshPool']
constructor(options: SshHostDirectoryBrowserOptions) {
this.#sshHosts = options.sshHosts
this.#sshPool = options.sshPool
}
async listDirectories(
hostId: string,
path?: string,
signal?: AbortSignal
): Promise<SshHostDirectoryBrowseResult> {
signal?.throwIfAborted()
const target =
await this.#sshHosts.resolveConnectionTarget(hostId)
signal?.throwIfAborted()
assertTarget(hostId, target)
const expected = currentTarget(target)
this.#sshHosts.assertConnectionTargetCurrent(expected)
let lease: SshConnectionLease | undefined
try {
lease = await this.#sshPool.acquire(target, signal)
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
const listing = await lease.listDirectories(path, signal)
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
this.#sshHosts.assertConnectionTargetCurrent(expected)
return listing
} finally {
lease?.release()
}
}
}
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from 'vitest'
import type { StagedSftp } from './bounded-sftp'
import type {
SshConnectionLease,
SshConnectionPoolTarget
} from './ssh-connection-pool'
import {
SshHostRemoteEnvironmentInspector
} from './ssh-host-remote-environment'
const hostId = '00000000-0000-4000-8000-000000000401'
const digest = `sha256:${'a'.repeat(64)}`
function target(): SshConnectionPoolTarget {
return {
host: {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64: 'AAAA',
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
generation: 2
}
},
hostRevision: 3,
hostKeyGeneration: 2
}
}
function agentEntry(version = '0.11.1', digestCharacter = 'a') {
const manifestSha256 = digestCharacter.repeat(64)
return {
installationId: `agent-${manifestSha256}`,
agentVersion: version,
manifestSha256,
arch: 'x64' as const
}
}
function runtimeEntry(version = '1.18.9') {
return {
runtimeId: 'opencode',
runtimeVersion: version,
architecture: 'x64' as const,
bundleDigest: digest,
manifestDigest: digest,
acpCapabilitiesDigest: digest
}
}
function missing(): Error & { code: number } {
return Object.assign(new Error('missing'), { code: 2 })
}
function harness(registries: {
agent?: unknown
runtime?: unknown
} = {}) {
const release = vi.fn()
const close = vi.fn()
const readFile = vi.fn(async (path: string) => {
const value =
path === '.goodbuddy/agent/registry.json'
? registries.agent
: path === '.goodbuddy/runtimes/registry.json'
? registries.runtime
: undefined
if (value === undefined) {
throw missing()
}
return Buffer.from(JSON.stringify(value))
})
const sftp = { readFile, close } as unknown as StagedSftp
const currentTarget = target()
const lease = {
identity: {
hostId,
hostRevision: 3,
hostKeyGeneration: 2,
authenticationIdentity: 'b'.repeat(64)
},
runAgentBootstrapProbe: vi.fn(async () => ({
ready: true as const,
platform: 'linux' as const,
architecture: 'x64' as const,
canonicalHomeDirectory: '/home/builder',
uid: 1_000,
shell: '/bin/bash',
procfs: 'ready' as const
})),
openStagedSftp: vi.fn(async () => sftp),
release
} as unknown as SshConnectionLease
const assertConnectionTargetCurrent = vi.fn()
const inspector = new SshHostRemoteEnvironmentInspector({
sshHosts: {
resolveConnectionTarget: vi.fn(async () => currentTarget),
assertConnectionTargetCurrent
},
sshPool: {
acquire: vi.fn(async () => lease)
},
agentRuntimeLockPath: 'unused',
remoteRuntimeLockPath: 'unused',
loadExpectedCatalog: async () => ({
agent: { version: '0.11.1' },
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
version: '1.18.9'
}]
}),
now: () => new Date('2030-01-01T00:00:00.000Z')
})
return {
inspector,
lease,
close,
release,
assertConnectionTargetCurrent
}
}
describe('SshHostRemoteEnvironmentInspector', () => {
it('reports installed Agent and Runtime versions against the desktop catalog', async () => {
const value = harness({
agent: {
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current: {
...agentEntry(),
productVersion: '0.11.0',
releaseSequence: 4,
binaryDigest: `sha256:${'a'.repeat(64)}`,
protocol: { major: 1, minor: 0 },
signingKeyId: 'test-key',
previouslyVerified: true
},
draining: []
},
runtime: {
formatVersion: 1,
minimumTrustedReleaseSequence: 1,
current: [{
...runtimeEntry(),
provider: 'opencode',
releaseSequence: 3,
signingKeyId: 'test-key'
}]
}
})
await expect(value.inspector.inspect(hostId)).resolves.toEqual({
hostId,
checkedAt: '2030-01-01T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'current',
expected: {
version: '0.11.1',
architecture: 'x64'
},
installed: {
version: '0.11.1',
architecture: 'x64'
}
},
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: {
version: '1.18.9',
architecture: 'x64'
},
installed: {
version: '1.18.9',
architecture: 'x64'
}
}]
})
expect(value.close).toHaveBeenCalledOnce()
expect(value.release).toHaveBeenCalledOnce()
expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(2)
})
it('reports an older Agent and a missing Runtime without installing either', async () => {
const value = harness({
agent: {
formatVersion: 1,
current: agentEntry('0.11.0', 'b')
}
})
await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({
agent: {
state: 'update-available',
installed: { version: '0.11.0' }
},
runtimes: [{
runtimeId: 'opencode',
state: 'not-installed',
installed: null
}]
})
expect(value.lease.runAgentBootstrapProbe).toHaveBeenCalledOnce()
expect(value.lease.openStagedSftp).toHaveBeenCalledOnce()
})
it('rejects corrupt remote metadata and still closes every lease', async () => {
const value = harness({
agent: {
formatVersion: 1,
current: { command: 'untrusted' }
}
})
await expect(value.inspector.inspect(hostId)).rejects.toThrow(
'元数据已损坏'
)
expect(value.close).toHaveBeenCalledOnce()
expect(value.release).toHaveBeenCalledOnce()
})
})
+341
View File
@@ -0,0 +1,341 @@
import { readFile } from 'node:fs/promises'
import {
agentRuntimeLockSchema,
type AgentRuntimeLock
} from '../../shared/agent-installation-contracts'
import {
remoteRuntimeLockSchema,
type RemoteRuntimeLock
} from '../../shared/remote-runtime-launch-contracts'
import {
sshHostRemoteEnvironmentSchema,
type SshHostRemoteEnvironment
} from '../../shared/ssh-host-contracts'
import {
parseInstallationRegistryState,
parseRuntimeRegistryState,
type InstallationRegistryEntry,
type RuntimeRegistryEntry
} from '../../shared/remote-environment-registry-contracts'
import type { StagedSftp } from './bounded-sftp'
import type {
CurrentSshConnectionTarget,
SshConnectionTarget,
SshHostStore
} from './ssh-host-store'
import type {
SshConnectionLease,
SshConnectionPool
} from './ssh-connection-pool'
const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json'
const RUNTIME_REGISTRY_PATH = '.goodbuddy/runtimes/registry.json'
const MAXIMUM_REGISTRY_BYTES = 256 * 1024
type ExpectedCatalog = {
agent: {
version: string
}
runtimes: Array<{
runtimeId: 'opencode'
provider: 'opencode'
version: string
}>
}
export type SshHostRemoteEnvironmentInspectorOptions = {
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
sshPool: Pick<SshConnectionPool, 'acquire'>
agentRuntimeLockPath: string
remoteRuntimeLockPath: string
loadExpectedCatalog?: () => Promise<ExpectedCatalog>
now?: () => Date
}
export class SshHostRemoteEnvironmentInspector {
readonly #sshHosts: SshHostRemoteEnvironmentInspectorOptions['sshHosts']
readonly #sshPool: SshHostRemoteEnvironmentInspectorOptions['sshPool']
readonly #loadExpectedCatalog: () => Promise<ExpectedCatalog>
readonly #now: () => Date
#expectedCatalog?: Promise<ExpectedCatalog>
constructor(options: SshHostRemoteEnvironmentInspectorOptions) {
this.#sshHosts = options.sshHosts
this.#sshPool = options.sshPool
this.#loadExpectedCatalog =
options.loadExpectedCatalog ??
(() =>
loadExpectedCatalog(
options.agentRuntimeLockPath,
options.remoteRuntimeLockPath
))
this.#now = options.now ?? (() => new Date())
}
async inspect(
hostId: string,
signal?: AbortSignal
): Promise<SshHostRemoteEnvironment> {
signal?.throwIfAborted()
const target =
await this.#sshHosts.resolveConnectionTarget(hostId)
signal?.throwIfAborted()
assertTarget(hostId, target)
const expectedTarget = currentTarget(target)
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
let lease: SshConnectionLease | undefined
let sftp: StagedSftp | undefined
try {
lease = await this.#sshPool.acquire(target, signal)
assertLeaseIdentity(target, lease)
const probe = await lease.runAgentBootstrapProbe(signal)
signal?.throwIfAborted()
if (!probe.ready) {
throw new Error(
`远端系统不能运行 GoodBuddy Agent:${probe.reason}`
)
}
sftp = await lease.openStagedSftp(
probe.canonicalHomeDirectory,
{
maximumFileBytes: MAXIMUM_REGISTRY_BYTES,
maximumTotalBytes: MAXIMUM_REGISTRY_BYTES * 2,
maximumOperations: 8,
operationTimeoutMs: 15_000
},
signal
)
const [catalog, agentRegistry, runtimeRegistry] =
await Promise.all([
this.#expected(),
readOptionalRegistry(
sftp,
AGENT_REGISTRY_PATH,
{ parse: parseInstallationRegistryState },
signal
),
readOptionalRegistry(
sftp,
RUNTIME_REGISTRY_PATH,
{ parse: parseRuntimeRegistryState },
signal
)
])
signal?.throwIfAborted()
assertLeaseIdentity(target, lease)
this.#sshHosts.assertConnectionTargetCurrent(expectedTarget)
return sshHostRemoteEnvironmentSchema.parse({
hostId,
checkedAt: this.#now().toISOString(),
architecture: probe.architecture,
agent: agentStatus(
catalog.agent,
agentRegistry?.current,
probe.architecture
),
runtimes: catalog.runtimes.map((runtime) =>
runtimeStatus(
runtime,
runtimeRegistry?.current.find(
(installed) =>
installed.runtimeId === runtime.runtimeId &&
installed.architecture === probe.architecture
),
probe.architecture
)
)
})
} finally {
sftp?.close()
lease?.release()
}
}
#expected(): Promise<ExpectedCatalog> {
this.#expectedCatalog ??= this.#loadExpectedCatalog()
return this.#expectedCatalog
}
}
function currentTarget(
target: SshConnectionTarget
): CurrentSshConnectionTarget {
return {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
}
function assertTarget(
requestedHostId: string,
target: SshConnectionTarget
): void {
if (
target.host.id !== requestedHostId ||
target.hostRevision < 1 ||
target.hostKeyGeneration < 1 ||
!target.host.hostKey ||
target.host.hostKey.generation !== target.hostKeyGeneration
) {
throw new Error('SSH 主机连接目标无效')
}
}
function assertLeaseIdentity(
target: SshConnectionTarget,
lease: SshConnectionLease
): void {
if (
lease.identity.hostId !== target.host.id ||
lease.identity.hostRevision !== target.hostRevision ||
lease.identity.hostKeyGeneration !== target.hostKeyGeneration
) {
throw new Error('SSH 远端运行环境连接身份不匹配')
}
}
async function readOptionalRegistry<T>(
sftp: StagedSftp,
path: string,
schema: { parse(value: unknown): T },
signal?: AbortSignal
): Promise<T | undefined> {
let bytes: Buffer
try {
bytes = await sftp.readFile(path, signal)
} catch (error) {
if (isMissingPathError(error)) {
return undefined
}
throw error
}
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
} catch (error) {
throw new Error('远端 GoodBuddy 运行环境元数据无效', {
cause: error
})
}
try {
return schema.parse(value)
} catch (error) {
throw new Error('远端 GoodBuddy 运行环境元数据已损坏', {
cause: error
})
}
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(
(error as { code?: unknown }).code === 2 ||
(error as { code?: unknown }).code === 'ENOENT'
)
)
}
function environmentState(
installedVersion: string | undefined,
expectedVersion: string
): 'current' | 'update-available' | 'not-installed' {
if (!installedVersion) {
return 'not-installed'
}
return installedVersion === expectedVersion
? 'current'
: 'update-available'
}
function agentStatus(
expected: ExpectedCatalog['agent'],
installed: InstallationRegistryEntry | undefined,
architecture: 'x64' | 'arm64'
) {
if (installed && installed.arch !== architecture) {
throw new Error('远端 Agent registry 架构与主机不匹配')
}
const installedVersion = installed
? {
version: installed.agentVersion,
architecture: installed.arch
}
: null
return {
state: environmentState(installed?.agentVersion, expected.version),
expected: { ...expected, architecture },
installed: installedVersion
}
}
function runtimeStatus(
expected: ExpectedCatalog['runtimes'][number],
installed: RuntimeRegistryEntry | undefined,
architecture: 'x64' | 'arm64'
) {
const installedVersion = installed
? {
version: installed.runtimeVersion,
architecture: installed.architecture
}
: null
return {
runtimeId: expected.runtimeId,
provider: expected.provider,
state: environmentState(installed?.runtimeVersion, expected.version),
expected: {
version: expected.version,
architecture
},
installed: installedVersion
}
}
async function loadExpectedCatalog(
agentRuntimeLockPath: string,
remoteRuntimeLockPath: string
): Promise<ExpectedCatalog> {
const [agentBytes, runtimeBytes] = await Promise.all([
readFile(agentRuntimeLockPath),
readFile(remoteRuntimeLockPath)
])
let agentLock: AgentRuntimeLock
let runtimeLock: RemoteRuntimeLock
try {
agentLock = agentRuntimeLockSchema.parse(
JSON.parse(agentBytes.toString('utf8'))
)
runtimeLock = remoteRuntimeLockSchema.parse(
JSON.parse(runtimeBytes.toString('utf8'))
)
} catch (error) {
throw new Error('GoodBuddy 内置远端运行环境版本元数据无效', {
cause: error
})
}
return {
agent: {
version: agentLock.agentVersion
},
runtimes: Object.entries(runtimeLock.runtimes)
.map(([runtimeId, runtime]) => ({
runtimeId: runtimeId as 'opencode',
provider: runtime.provider,
version: runtime.version
}))
.sort((left, right) =>
left.runtimeId.localeCompare(right.runtimeId, 'en')
)
}
}
+736
View File
@@ -0,0 +1,736 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SettingsCredentialCipher } from '../settings-credential-cipher'
import {
SshHostService,
type SshHostLifecycleHooks
} from './ssh-host-service'
import { SshHostStore } from './ssh-host-store'
import type {
SshHostKeyCandidate,
SshTransport
} from './ssh-transport'
const roots: string[] = []
const keyA: SshHostKeyCandidate = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('key-a').toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`
}
const keyB: SshHostKeyCandidate = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('key-b').toString('base64'),
fingerprintSha256: `SHA256:${'B'.repeat(43)}`
}
const decrypt = vi.fn((value: Buffer) => value.toString())
const cipher: SettingsCredentialCipher = {
isAvailable: () => true,
encrypt: (value) => Buffer.from(value),
decrypt
}
const newPasswordHost = {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password' as const,
password: {
action: 'replace' as const,
value: 'private password'
}
}
async function createHarness(
now = 1_000,
lifecycleHooks: SshHostLifecycleHooks = {}
): Promise<{
service: SshHostService
store: SshHostStore
inspectHostKey: ReturnType<typeof vi.fn>
testConnection: ReturnType<typeof vi.fn>
setNow: (value: number) => void
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-service-'))
roots.push(root)
const store = new SshHostStore(join(root, 'ssh-hosts.json'), cipher)
const inspectHostKey = vi.fn(async () => keyA)
const testConnection = vi.fn(async () => ({
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
}))
const transport: SshTransport = {
inspectHostKey,
testConnection
}
return {
service: new SshHostService(
store,
transport,
() => now,
lifecycleHooks
),
store,
inspectHostKey,
testConnection,
setNow: (value) => {
now = value
}
}
}
afterEach(async () => {
vi.clearAllMocks()
await Promise.all(
roots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('SshHostService', () => {
it('inspects a new draft before authentication without persisting it', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
expect(inspection).toMatchObject({
state: 'unverified',
algorithm: keyA.algorithm,
fingerprintSha256: keyA.fingerprintSha256
})
expect(inspection.candidateId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
)
expect(harness.inspectHostKey).toHaveBeenCalledWith({
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
expect(harness.testConnection).not.toHaveBeenCalled()
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('指纹不匹配')
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
harness.setNow(301_000)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('已过期')
})
it('persists a host only after authentication and the probe succeed', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
harness.testConnection
.mockRejectedValueOnce(new Error('SSH 认证失败'))
.mockResolvedValueOnce({
connected: true,
latencyMs: 12,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('SSH 认证失败')
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: []
})
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
password: {
action: 'replace',
value: 'corrected password'
}
}
})
expect(result).toMatchObject({
host: {
name: 'Build host',
hostKey: {
state: 'verified',
fingerprintSha256: keyA.fingerprintSha256,
generation: 1
}
},
connection: {
connected: true,
platform: 'linux'
}
})
expect(result.connection.hostId).toBe(result.host.id)
expect(harness.testConnection).toHaveBeenLastCalledWith(
expect.objectContaining({
password: 'corrected password',
hostKey: keyA
})
)
expect(JSON.stringify(await harness.service.getSnapshot())).not.toContain(
'corrected password'
)
})
it('binds candidates to the inspected target and discards them on request', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
hostname: 'other.example.com'
}
})
).rejects.toThrow('配置已变化')
expect(harness.testConnection).not.toHaveBeenCalled()
harness.service.discardCandidate(inspection.candidateId)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('重新检查')
})
it('supersedes older candidates for the same draft target', async () => {
const harness = await createHarness()
const first = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
const second = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
await expect(
harness.service.validateAndSave({
candidateId: first.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).rejects.toThrow('重新检查')
await expect(
harness.service.validateAndSave({
candidateId: second.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
})
).resolves.toMatchObject({
host: { name: 'Build host' }
})
expect(harness.testConnection).toHaveBeenCalledOnce()
})
it('rejects duplicate in-flight inspections for the same target', async () => {
const harness = await createHarness()
let finishInspection:
| ((candidate: SshHostKeyCandidate) => void)
| undefined
harness.inspectHostKey.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishInspection = resolve
})
)
const request = {
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
}
const first = harness.service.inspectDraftHostKey(request)
await vi.waitFor(() =>
expect(harness.inspectHostKey).toHaveBeenCalledOnce()
)
await expect(
harness.service.inspectDraftHostKey(request)
).rejects.toThrow('正在检查')
finishInspection?.(keyA)
await expect(first).resolves.toMatchObject({
fingerprintSha256: keyA.fingerprintSha256
})
})
it('classifies changed keys and atomically replaces a validated edit', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'private password'
}
},
hostKey: keyA
})
const decryptionsBeforeInspection = decrypt.mock.calls.length
harness.inspectHostKey.mockResolvedValueOnce(keyB)
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: 'build.example.com',
port: 22,
username: 'builder'
})
expect(inspection).toMatchObject({
hostId: host.id,
state: 'changed',
fingerprintSha256: keyB.fingerprintSha256,
previousHostKey: {
algorithm: keyA.algorithm,
fingerprintSha256: keyA.fingerprintSha256
}
})
expect(decrypt).toHaveBeenCalledTimes(decryptionsBeforeInspection)
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
expect(result.host).toMatchObject({
id: host.id,
name: 'Renamed host',
hostKey: {
state: 'verified',
fingerprintSha256: keyB.fingerprintSha256,
generation: 2
}
})
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
id: host.id,
password: 'private password',
hostKey: keyB
})
)
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
})
it.each([
[
'hostname',
{
hostname: 'moved.example.com'
}
],
[
'port',
{
port: 2222
}
],
[
'username',
{
username: 'deployer'
}
],
[
'authentication',
{
authentication: 'system-agent' as const
}
]
])(
'rejects credential keep before resolution when %s changes',
async (_field, change) => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const draft = {
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username,
...change
}
const inspection =
await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: draft.hostname,
port: draft.port,
username: draft.username
})
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
...change,
password: { action: 'keep' }
}
})
).rejects.toThrow(
'地址、端口、用户名或认证方式已变化'
)
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
expect(harness.testConnection).not.toHaveBeenCalled()
}
)
it('allows credential keep when target and authentication are unchanged', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
const resolveConnectionTarget = vi.spyOn(
harness.store,
'resolveConnectionTarget'
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
).resolves.toMatchObject({
host: {
id: host.id,
name: 'Renamed host'
}
})
expect(resolveConnectionTarget).toHaveBeenCalledOnce()
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'password',
password: 'private password'
})
)
})
it('rejects an edit when the durable host changes after inspection', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
const identity = await harness.store.getHostIdentity(host.id)
await harness.store.commitValidated({
hostId: host.id,
expectedRevision: identity.revision,
input: {
name: 'Changed elsewhere',
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
name: 'Stale edit',
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: 'system-agent',
password: { action: 'clear' }
}
})
).rejects.toThrow('配置已变化')
expect(harness.testConnection).not.toHaveBeenCalled()
})
it('validates target edits against the draft before replacing the durable host', async () => {
const harness = await createHarness()
const host = await harness.store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'old password'
}
},
hostKey: keyA
})
harness.inspectHostKey.mockResolvedValueOnce(keyB)
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: 'moved.example.com',
port: 2222,
username: 'deployer'
})
expect(inspection).toMatchObject({
state: 'unverified',
fingerprintSha256: keyB.fingerprintSha256
})
const result = await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyB.fingerprintSha256,
input: {
name: 'Moved host',
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
authentication: 'password',
password: {
action: 'replace',
value: 'new password'
}
}
})
expect(result.host).toMatchObject({
id: host.id,
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
hostKey: {
fingerprintSha256: keyB.fingerprintSha256,
generation: 2
}
})
expect(harness.testConnection).toHaveBeenCalledWith(
expect.objectContaining({
hostname: 'moved.example.com',
port: 2222,
username: 'deployer',
password: 'new password'
})
)
})
it('fires credential-free lifecycle hooks only after successful edit and removal commits', async () => {
const onHostEdited = vi.fn()
const onHostRemoved = vi.fn()
const harness = await createHarness(1_000, {
onHostEdited,
onHostRemoved
})
const host = await harness.store.commitValidated({
input: newPasswordHost,
hostKey: keyA
})
const inspection = await harness.service.inspectDraftHostKey({
hostId: host.id,
hostname: host.hostname,
port: host.port,
username: host.username
})
harness.testConnection.mockRejectedValueOnce(
new Error('SSH 认证失败')
)
await expect(
harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
).rejects.toThrow('SSH 认证失败')
expect(onHostEdited).not.toHaveBeenCalled()
expect(onHostRemoved).not.toHaveBeenCalled()
await harness.service.validateAndSave({
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: {
...newPasswordHost,
name: 'Renamed host',
password: { action: 'keep' }
}
})
expect(onHostEdited).toHaveBeenCalledWith(host.id)
expect(onHostEdited.mock.calls[0]).toEqual([host.id])
expect(onHostRemoved).not.toHaveBeenCalled()
await harness.service.remove(host.id)
expect(onHostRemoved).toHaveBeenCalledWith(host.id)
expect(onHostRemoved.mock.calls[0]).toEqual([host.id])
await expect(
harness.service.remove(host.id)
).rejects.toThrow('不存在')
expect(onHostRemoved).toHaveBeenCalledOnce()
})
it('bounds the number of pending host-key candidates', async () => {
const harness = await createHarness()
for (let index = 0; index < 100; index += 1) {
await harness.service.inspectDraftHostKey({
hostname: `build-${index}.example.com`,
port: 22,
username: 'builder'
})
}
await expect(
harness.service.inspectDraftHostKey({
hostname: 'one-too-many.example.com',
port: 22,
username: 'builder'
})
).rejects.toThrow('检查过多')
expect(harness.inspectHostKey).toHaveBeenCalledTimes(100)
})
it('allows only one validation to consume a candidate', async () => {
const harness = await createHarness()
const inspection = await harness.service.inspectDraftHostKey({
hostname: newPasswordHost.hostname,
port: newPasswordHost.port,
username: newPasswordHost.username
})
let finishConnection:
| ((result: {
connected: true
latencyMs: number
platform: 'linux'
architecture: 'x64'
shell: string
homeDirectory: string
detail: string
}) => void)
| undefined
harness.testConnection.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishConnection = resolve
})
)
const request = {
candidateId: inspection.candidateId,
fingerprintSha256: keyA.fingerprintSha256,
input: newPasswordHost
}
const first = harness.service.validateAndSave(request)
await vi.waitFor(() =>
expect(harness.testConnection).toHaveBeenCalledOnce()
)
await expect(
harness.service.validateAndSave(request)
).rejects.toThrow('正在验证')
finishConnection?.({
connected: true,
latencyMs: 12,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
await expect(first).resolves.toMatchObject({
host: { name: 'Build host' }
})
await expect(harness.service.getSnapshot()).resolves.toMatchObject({
hosts: [{ name: 'Build host' }]
})
})
})
+346
View File
@@ -0,0 +1,346 @@
import { randomUUID } from 'node:crypto'
import type {
SshHostDraftInspectionRequest,
SshHostKeyInspection,
SshHostsSnapshot,
SshHostValidationRequest,
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import {
sshHostCreateInputSchema,
sshHostDraftInspectionRequestSchema,
sshHostUpdateInputSchema,
sshHostValidationRequestSchema
} from '../../shared/ssh-host-contracts'
import type { SshHostStore } from './ssh-host-store'
import type {
SshHostKeyCandidate,
SshTransport
} from './ssh-transport'
const CANDIDATE_TTL_MS = 5 * 60_000
const MAX_PENDING_CANDIDATES = 100
type PendingHostKey = {
candidate: SshHostKeyCandidate
hostId?: string
expectedRevision?: number
sourceTargetKey?: string
targetKey: string
expiresAt: number
}
function targetKey(host: {
hostname: string
port: number
username: string
}): string {
return `${host.hostname}\0${host.port}\0${host.username}`
}
export type SshHostLifecycleHooks = {
onHostEdited?: (hostId: string) => void
onHostRemoved?: (hostId: string) => void
}
export class SshHostService {
private readonly pendingHostKeys = new Map<
string,
PendingHostKey
>()
private activeInspections = 0
private readonly activeInspectionKeys = new Set<string>()
private readonly activeValidations = new Set<string>()
private readonly activeValidationKeys = new Set<string>()
private readonly now: () => number
private readonly lifecycleHooks: SshHostLifecycleHooks
constructor(
private readonly store: SshHostStore,
private readonly transport: SshTransport,
nowOrHooks: (() => number) | SshHostLifecycleHooks = Date.now,
lifecycleHooks: SshHostLifecycleHooks = {}
) {
this.now =
typeof nowOrHooks === 'function' ? nowOrHooks : Date.now
this.lifecycleHooks =
typeof nowOrHooks === 'function'
? lifecycleHooks
: nowOrHooks
}
getSnapshot(): Promise<SshHostsSnapshot> {
return this.store.getSnapshot()
}
async remove(hostId: string): Promise<void> {
await this.store.remove(hostId)
for (const [candidateId, pending] of this.pendingHostKeys) {
if (pending.hostId === hostId) {
this.pendingHostKeys.delete(candidateId)
}
}
this.lifecycleHooks.onHostRemoved?.(hostId)
}
async inspectDraftHostKey(
request: SshHostDraftInspectionRequest
): Promise<SshHostKeyInspection> {
const parsed = sshHostDraftInspectionRequestSchema.parse(request)
const requestedTargetKey = targetKey(parsed)
const inspectionKeys = [
`target:${requestedTargetKey}`,
...(parsed.hostId ? [`host:${parsed.hostId}`] : [])
]
if (
inspectionKeys.some((key) =>
this.activeValidationKeys.has(key)
)
) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
if (
inspectionKeys.some((key) =>
this.activeInspectionKeys.has(key)
)
) {
throw new Error('SSH 主机密钥正在检查,请等待当前操作完成')
}
for (const [candidateId, pending] of this.pendingHostKeys) {
if (pending.expiresAt <= this.now()) {
this.pendingHostKeys.delete(candidateId)
}
}
if (
this.pendingHostKeys.size + this.activeInspections >=
MAX_PENDING_CANDIDATES
) {
throw new Error('SSH 主机密钥检查过多,请稍后重试')
}
this.activeInspections += 1
for (const key of inspectionKeys) {
this.activeInspectionKeys.add(key)
}
let existing:
| Awaited<ReturnType<SshHostStore['getHostIdentity']>>
| undefined
let candidate: SshHostKeyCandidate
try {
existing = parsed.hostId
? await this.store.getHostIdentity(parsed.hostId)
: undefined
candidate = await this.transport.inspectHostKey({
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
})
} finally {
this.activeInspections -= 1
for (const key of inspectionKeys) {
this.activeInspectionKeys.delete(key)
}
}
const sameEndpoint =
existing !== undefined &&
existing.hostname === parsed.hostname &&
existing.port === parsed.port
const existingHostKey = sameEndpoint
? existing?.hostKey
: undefined
const state =
!existingHostKey
? 'unverified'
: existingHostKey.fingerprintSha256 ===
candidate.fingerprintSha256
? 'verified'
: 'changed'
const candidateId = randomUUID()
for (const [existingId, pending] of this.pendingHostKeys) {
if (
!this.activeValidations.has(existingId) &&
(pending.targetKey === requestedTargetKey ||
(existing && pending.hostId === existing.id))
) {
this.pendingHostKeys.delete(existingId)
}
}
this.pendingHostKeys.set(candidateId, {
candidate,
...(existing
? {
hostId: existing.id,
expectedRevision: existing.revision,
sourceTargetKey: targetKey(existing)
}
: {}),
targetKey: requestedTargetKey,
expiresAt: this.now() + CANDIDATE_TTL_MS
})
return {
candidateId,
...(existing ? { hostId: existing.id } : {}),
state,
algorithm: candidate.algorithm,
fingerprintSha256: candidate.fingerprintSha256,
...(state === 'changed' && existing?.hostKey
? {
previousHostKey: {
algorithm: existing.hostKey.algorithm,
fingerprintSha256:
existing.hostKey.fingerprintSha256
}
}
: {})
}
}
discardCandidate(candidateId: string): void {
if (!this.activeValidations.has(candidateId)) {
this.pendingHostKeys.delete(candidateId)
}
}
async validateAndSave(
request: SshHostValidationRequest
): Promise<SshHostValidationResult> {
const parsed = sshHostValidationRequestSchema.parse(request)
const pending = this.pendingHostKeys.get(parsed.candidateId)
if (!pending || pending.expiresAt <= this.now()) {
this.pendingHostKeys.delete(parsed.candidateId)
throw new Error('SSH 主机密钥检查已过期,请重新检查')
}
if (pending.targetKey !== targetKey(parsed.input)) {
throw new Error('SSH 主机配置已变化,请重新检查主机密钥')
}
if (
pending.candidate.fingerprintSha256 !==
parsed.fingerprintSha256
) {
throw new Error('SSH 主机密钥指纹不匹配')
}
if (this.activeValidations.has(parsed.candidateId)) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
const validationKeys = [
`target:${pending.targetKey}`,
...(pending.hostId ? [`host:${pending.hostId}`] : [])
]
if (
validationKeys.some((key) =>
this.activeValidationKeys.has(key)
)
) {
throw new Error('SSH 主机正在验证,请等待当前操作完成')
}
this.activeValidations.add(parsed.candidateId)
for (const key of validationKeys) {
this.activeValidationKeys.add(key)
}
try {
let resolvedExisting:
| Awaited<
ReturnType<SshHostStore['resolveConnectionTarget']>
>
| undefined
if (pending.hostId) {
resolvedExisting =
await this.store.resolveConnectionTarget(
pending.hostId
)
const existing = {
id: resolvedExisting.host.id,
hostname: resolvedExisting.host.hostname,
port: resolvedExisting.host.port,
username: resolvedExisting.host.username,
revision: resolvedExisting.hostRevision
}
if (
existing.revision !== pending.expectedRevision ||
targetKey(existing) !== pending.sourceTargetKey
) {
this.pendingHostKeys.delete(parsed.candidateId)
throw new Error(
'SSH 主机配置已变化,请重新检查主机密钥'
)
}
sshHostUpdateInputSchema.parse(parsed.input)
if (
parsed.input.password.action === 'keep' &&
(existing.hostname !== parsed.input.hostname ||
existing.port !== parsed.input.port ||
existing.username !== parsed.input.username ||
parsed.input.authentication !== 'password')
) {
throw new Error(
'主机地址、端口、用户名或认证方式已变化,请重新输入密码'
)
}
} else {
sshHostCreateInputSchema.parse(parsed.input)
}
let password: string | undefined
if (parsed.input.authentication === 'password') {
if (parsed.input.password.action === 'replace') {
if (!this.store.isSecureStorageAvailable()) {
throw new Error(
'系统安全存储不可用,无法保存 SSH 密码'
)
}
password = parsed.input.password.value
} else if (
parsed.input.password.action === 'keep' &&
pending.hostId
) {
password = resolvedExisting?.host.password
} else {
throw new Error('SSH 密码不能为空')
}
}
const connection = await this.transport.testConnection({
id: pending.hostId ?? parsed.candidateId,
name: parsed.input.name,
hostname: parsed.input.hostname,
port: parsed.input.port,
username: parsed.input.username,
authentication: parsed.input.authentication,
...(password ? { password } : {}),
hostKey: pending.candidate
})
const host = await this.store.commitValidated({
...(pending.hostId ? { hostId: pending.hostId } : {}),
...(pending.expectedRevision !== undefined
? { expectedRevision: pending.expectedRevision }
: {}),
input: parsed.input,
hostKey: pending.candidate
})
if (pending.hostId) {
this.lifecycleHooks.onHostEdited?.(host.id)
}
for (const [candidateId, candidate] of this.pendingHostKeys) {
if (
candidateId === parsed.candidateId ||
candidate.targetKey === pending.targetKey ||
(pending.hostId && candidate.hostId === pending.hostId)
) {
this.pendingHostKeys.delete(candidateId)
}
}
return {
host,
connection: {
hostId: host.id,
...connection
}
}
} finally {
this.activeValidations.delete(parsed.candidateId)
for (const key of validationKeys) {
this.activeValidationKeys.delete(key)
}
}
}
}
+554
View File
@@ -0,0 +1,554 @@
import {
mkdtemp,
readFile,
readdir,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
SshHost,
SshHostUpdateInput
} from '../../shared/ssh-host-contracts'
import type { SettingsCredentialCipher } from '../settings-credential-cipher'
import {
SshHostStore,
type ValidatedSshHostCommit
} from './ssh-host-store'
const roots: string[] = []
const fingerprintA = `SHA256:${'A'.repeat(43)}`
const fingerprintB = `SHA256:${'B'.repeat(43)}`
const keyA = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('first-key').toString('base64'),
fingerprintSha256: fingerprintA
}
const keyB = {
algorithm: 'ssh-ed25519',
publicKeyBase64: Buffer.from('second-key').toString('base64'),
fingerprintSha256: fingerprintB
}
function createCipher(available = true): SettingsCredentialCipher {
return {
isAvailable: () => available,
encrypt: (value) =>
Buffer.from(
`protected:${Buffer.from(value).toString('base64')}`
),
decrypt: (value) =>
Buffer.from(
value.toString().replace(/^protected:/u, ''),
'base64'
).toString()
}
}
async function createHarness(
cipher = createCipher()
): Promise<{
filePath: string
store: SshHostStore
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-hosts-'))
roots.push(root)
const filePath = join(root, 'ssh-hosts.json')
return {
filePath,
store: new SshHostStore(filePath, cipher)
}
}
function passwordHostInput(
overrides: Partial<SshHostUpdateInput> = {}
): SshHostUpdateInput {
return {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'password',
password: {
action: 'replace',
value: 'private password'
},
...overrides
}
}
async function updateValidated(
store: SshHostStore,
host: SshHost,
input: SshHostUpdateInput,
hostKey: ValidatedSshHostCommit['hostKey'] = keyA
): Promise<SshHost> {
const identity = await store.getHostIdentity(host.id)
return store.commitValidated({
hostId: host.id,
expectedRevision: identity.revision,
input,
hostKey
})
}
afterEach(async () => {
await Promise.all(
roots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true }))
)
})
describe('SshHostStore', () => {
it('atomically creates a validated host with an encrypted credential and pinned key', async () => {
const { filePath, store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
expect(created).toMatchObject({
credentialConfigured: true,
credentialSource: 'encrypted',
hostKey: {
state: 'verified',
fingerprintSha256: fingerprintA,
generation: 1
},
lastValidatedAt: expect.any(String)
})
const persisted = await readFile(filePath, 'utf8')
expect(persisted).not.toContain('private password')
expect(persisted).toContain(fingerprintA)
expect(JSON.stringify(created)).not.toContain('ciphertextBase64')
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'private password',
hostKey: {
fingerprintSha256: fingerprintA
}
})
})
it('resolves one atomic connection target snapshot with credentials and generations', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const identity = await store.getHostIdentity(created.id)
const targetPromise = store.resolveConnectionTarget(created.id)
const editPromise = store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: passwordHostInput({
password: {
action: 'replace',
value: 'rotated password'
}
}),
hostKey: keyB
})
await expect(targetPromise).resolves.toMatchObject({
host: {
id: created.id,
password: 'private password',
hostKey: {
fingerprintSha256: fingerprintA,
generation: 1
}
},
hostRevision: 1,
hostKeyGeneration: 1
})
await expect(editPromise).resolves.toMatchObject({
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
})
await expect(
store.resolveConnectionTarget(created.id)
).resolves.toMatchObject({
host: {
password: 'rotated password',
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
},
hostRevision: 2,
hostKeyGeneration: 2
})
})
it('reuses credential validation across unchanged public snapshots', async () => {
const baseCipher = createCipher()
const decrypt = vi.fn(baseCipher.decrypt)
const { store } = await createHarness({
...baseCipher,
decrypt
})
await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const decryptionsAfterCommit = decrypt.mock.calls.length
await store.getSnapshot()
await store.getSnapshot()
expect(decrypt).toHaveBeenCalledTimes(decryptionsAfterCommit)
})
it('keeps or replaces credentials only through validated edits', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const renamed = await updateValidated(
store,
created,
passwordHostInput({
name: 'Renamed host',
password: { action: 'keep' }
})
)
expect(renamed).toMatchObject({
name: 'Renamed host',
hostKey: {
fingerprintSha256: fingerprintA,
generation: 1
}
})
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'private password'
})
const identity = await store.getHostIdentity(created.id)
await expect(
store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: passwordHostInput({
hostname: 'moved.example.com',
password: { action: 'keep' }
}),
hostKey: keyB
})
).rejects.toThrow('重新输入密码')
const moved = await updateValidated(
store,
renamed,
passwordHostInput({
name: 'Moved host',
hostname: 'moved.example.com',
password: {
action: 'replace',
value: 'second password'
}
}),
keyB
)
expect(moved).toMatchObject({
hostname: 'moved.example.com',
hostKey: {
fingerprintSha256: fingerprintB,
generation: 2
}
})
await expect(store.resolveHost(created.id)).resolves.toMatchObject({
password: 'second password'
})
})
it('rejects stale validated edits with a monotonic revision', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const identity = await store.getHostIdentity(created.id)
await store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: {
name: 'First edit',
hostname: created.hostname,
port: created.port,
username: created.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
await expect(
store.commitValidated({
hostId: created.id,
expectedRevision: identity.revision,
input: {
name: 'Stale edit',
hostname: created.hostname,
port: created.port,
username: created.username,
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
).rejects.toThrow('配置已变化')
})
it('binds encrypted credentials to the host id', async () => {
const { filePath, store } = await createHarness()
const first = await store.commitValidated({
input: passwordHostInput({
name: 'First',
hostname: 'first.example.com',
password: { action: 'replace', value: 'first secret' }
}),
hostKey: keyA
})
const second = await store.commitValidated({
input: passwordHostInput({
name: 'Second',
hostname: 'second.example.com',
password: { action: 'replace', value: 'second secret' }
}),
hostKey: keyA
})
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as {
hosts: Array<{
id: string
credential?: unknown
}>
}
persisted.hosts[1]!.credential = persisted.hosts[0]!.credential
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const reloaded = new SshHostStore(filePath, createCipher())
const snapshot = await reloaded.getSnapshot()
expect(
snapshot.hosts.find((host) => host.id === first.id)
).toMatchObject({ credentialSource: 'encrypted' })
expect(
snapshot.hosts.find((host) => host.id === second.id)
).toMatchObject({
credentialConfigured: false,
credentialSource: 'unreadable'
})
await expect(reloaded.resolveHost(second.id)).rejects.toThrow(
'不匹配'
)
})
it('binds encrypted credentials to the target address and username', async () => {
const { filePath, store } = await createHarness()
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as {
hosts: Array<{
hostname: string
}>
}
persisted.hosts[0]!.hostname = 'attacker.example.com'
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
const reloaded = new SshHostStore(filePath, createCipher())
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
hosts: [
expect.objectContaining({
id: created.id,
credentialConfigured: false,
credentialSource: 'unreadable'
})
]
})
await expect(reloaded.resolveHost(created.id)).rejects.toThrow(
'不匹配'
)
})
it('increments host-key generations only for new key material or targets', async () => {
const { store } = await createHarness()
const created = await store.commitValidated({
input: {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
hostKey: keyA
})
const same = await updateValidated(
store,
created,
{
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyA
)
const changed = await updateValidated(
store,
same,
{
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyB
)
const moved = await updateValidated(
store,
changed,
{
name: 'Build host',
hostname: 'moved.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
},
keyB
)
expect(created.hostKey.generation).toBe(1)
expect(same.hostKey.generation).toBe(1)
expect(changed.hostKey.generation).toBe(2)
expect(moved.hostKey.generation).toBe(3)
})
it('loads legacy revision-zero hosts and requires guided revalidation', async () => {
const { filePath } = await createHarness()
await writeFile(
filePath,
JSON.stringify({
version: 1,
hosts: [
{
id: '00000000-0000-4000-8000-000000000121',
name: 'Legacy host',
hostname: 'legacy.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
hostKey: {
...keyA,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
},
hostKeyGeneration: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
]
}),
'utf8'
)
const store = new SshHostStore(filePath, createCipher())
const snapshot = await store.getSnapshot()
expect(snapshot).toMatchObject({
hosts: [{ name: 'Legacy host', hostKey: { state: 'verified' } }]
})
expect(snapshot.hosts[0]).not.toHaveProperty('lastValidatedAt')
await expect(
store.getHostIdentity('00000000-0000-4000-8000-000000000121')
).resolves.toMatchObject({ revision: 0 })
})
it('requires secure storage for passwords and isolates corrupt settings', async () => {
const unavailable = await createHarness(createCipher(false))
await expect(
unavailable.store.commitValidated({
input: passwordHostInput({
password: { action: 'replace', value: 'secret' }
}),
hostKey: keyA
})
).rejects.toThrow('安全存储不可用')
const { filePath } = await createHarness()
await writeFile(filePath, '{invalid-json', 'utf8')
const recovered = new SshHostStore(filePath, createCipher())
await expect(recovered.getSnapshot()).resolves.toMatchObject({
hosts: []
})
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('ssh-hosts.json.corrupt-')
)
).toBe(true)
})
it('asserts a loaded current target synchronously without resolving credentials', async () => {
const { store } = await createHarness()
expect(() =>
store.assertConnectionTargetCurrent({
hostId: '00000000-0000-4000-8000-000000000001',
hostRevision: 1,
hostKeyGeneration: 1,
username: 'builder'
})
).toThrow('尚未加载')
const created = await store.commitValidated({
input: passwordHostInput(),
hostKey: keyA
})
const target = await store.resolveConnectionTarget(created.id)
const current = {
hostId: created.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
username: target.host.username
}
expect(() =>
store.assertConnectionTargetCurrent(current)
).not.toThrow()
expect(() =>
store.assertConnectionTargetCurrent({
...current,
hostRevision: current.hostRevision + 1
})
).toThrow('配置已变化')
expect(() =>
store.assertConnectionTargetCurrent({
...current,
username: 'other-user'
})
).toThrow('配置已变化')
})
})
+669
View File
@@ -0,0 +1,669 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
SSH_HOST_LIMITS,
sshAuthenticationKindSchema,
sshHostCreateInputSchema,
sshHostUpdateInputSchema,
type SshHost,
type SshHostsSnapshot,
type SshHostUpdateInput
} from '../../shared/ssh-host-contracts'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
const CURRENT_SETTINGS_VERSION = 1
const storedHostKeySchema = z
.object({
algorithm: z.string().min(1).max(64),
publicKeyBase64: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumHostKeyLength * 2)
.regex(/^[A-Za-z0-9+/]+={0,2}$/u),
fingerprintSha256: z
.string()
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/u),
acceptedAt: z.string().datetime({ offset: true }),
generation: z.number().int().positive()
})
.strict()
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
.extend({
ciphertextBase64: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumPasswordLength * 8)
.regex(/^[A-Za-z0-9+/]+={0,2}$/u)
})
.strict()
const storedHostSchema = z
.object({
id: z.string().uuid(),
name: sshHostCreateInputSchema.shape.name,
hostname: sshHostCreateInputSchema.shape.hostname,
port: sshHostCreateInputSchema.shape.port,
username: sshHostCreateInputSchema.shape.username,
authentication: sshAuthenticationKindSchema,
credential: encryptedCredentialSchema.optional(),
hostKey: storedHostKeySchema.optional(),
hostKeyGeneration: z.number().int().nonnegative(),
revision: z.number().int().nonnegative().default(0),
lastValidatedAt: z
.string()
.datetime({ offset: true })
.optional(),
createdAt: z.string().datetime({ offset: true }),
updatedAt: z.string().datetime({ offset: true })
})
.strict()
.superRefine((host, context) => {
if (
host.hostKey &&
host.hostKey.generation !== host.hostKeyGeneration
) {
context.addIssue({
code: 'custom',
path: ['hostKeyGeneration'],
message: 'SSH 主机密钥代际不一致'
})
}
})
const storedSettingsSchema = z
.object({
version: z.literal(CURRENT_SETTINGS_VERSION),
hosts: z.array(storedHostSchema).max(SSH_HOST_LIMITS.maximumHosts)
})
.strict()
const credentialPayloadSchema = z
.object({
version: z.literal(1),
hostId: z.string().uuid(),
authentication: z.literal('password'),
hostname: sshHostCreateInputSchema.shape.hostname,
port: sshHostCreateInputSchema.shape.port,
username: sshHostCreateInputSchema.shape.username,
password: z
.string()
.min(1)
.max(SSH_HOST_LIMITS.maximumPasswordLength)
})
.strict()
type StoredSettings = z.infer<typeof storedSettingsSchema>
type StoredHost = z.infer<typeof storedHostSchema>
type StoredHostKey = z.infer<typeof storedHostKeySchema>
type CredentialPayload = z.infer<typeof credentialPayloadSchema>
export type ResolvedSshHost = {
id: string
name: string
hostname: string
port: number
username: string
authentication: SshHost['authentication']
password?: string
hostKey?: Pick<
StoredHostKey,
'algorithm' | 'publicKeyBase64' | 'fingerprintSha256'
> &
Partial<Pick<StoredHostKey, 'acceptedAt' | 'generation'>>
}
export type SshHostIdentity = {
id: string
hostname: string
port: number
username: string
revision: number
hostKey?: {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
generation: number
}
}
export type SshConnectionTarget = {
host: ResolvedSshHost
hostRevision: number
hostKeyGeneration: number
}
export type CurrentSshConnectionTarget = Readonly<{
hostId: string
hostRevision: number
hostKeyGeneration: number
username: string
}>
export type ValidatedSshHostCommit = {
hostId?: string
expectedRevision?: number
input: SshHostUpdateInput
hostKey: {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
}
}
const defaultSettings: StoredSettings = {
version: CURRENT_SETTINGS_VERSION,
hosts: []
}
function cloneSettings(settings: StoredSettings): StoredSettings {
return structuredClone(settings)
}
function targetChanged(
current: StoredHost,
input: SshHostUpdateInput
): boolean {
return (
current.hostname !== input.hostname ||
current.port !== input.port ||
current.username !== input.username ||
current.authentication !== input.authentication
)
}
function credentialMatchesHost(
payload: CredentialPayload,
host: StoredHost
): boolean {
return (
payload.hostId === host.id &&
payload.authentication === host.authentication &&
payload.hostname === host.hostname &&
payload.port === host.port &&
payload.username === host.username
)
}
export class SshHostStore {
private settings?: StoredSettings
private settingsLoad?: Promise<StoredSettings>
private updateQueue: Promise<void> = Promise.resolve()
private readonly credentialSourceCache = new Map<
string,
{
credential: NonNullable<StoredHost['credential']>
secureStorageAvailable: boolean
hostname: string
port: number
username: string
source: Extract<
SshHost['credentialSource'],
'encrypted' | 'unreadable'
>
}
>()
constructor(
private readonly filePath: string,
private readonly cipher: SettingsCredentialCipher,
private readonly now: () => Date = () => new Date()
) {}
async getSnapshot(): Promise<SshHostsSnapshot> {
const settings = await this.load()
return {
hosts: settings.hosts.map((host) => this.toPublicHost(host)),
secureStorageAvailable: this.cipher.isAvailable()
}
}
isSecureStorageAvailable(): boolean {
return this.cipher.isAvailable()
}
commitValidated(
commit: ValidatedSshHostCommit
): Promise<SshHost> {
const parsed = commit.hostId
? sshHostUpdateInputSchema.parse(commit.input)
: sshHostCreateInputSchema.parse(commit.input)
return this.queue(async () => {
const settings = cloneSettings(await this.load())
const timestamp = this.now().toISOString()
if (!commit.hostId) {
if (settings.hosts.length >= SSH_HOST_LIMITS.maximumHosts) {
throw new Error(
`SSH 主机数量不能超过 ${SSH_HOST_LIMITS.maximumHosts}`
)
}
const id = randomUUID()
const host: StoredHost = storedHostSchema.parse({
id,
name: parsed.name,
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username,
authentication: parsed.authentication,
...(parsed.authentication === 'password' &&
parsed.password.action === 'replace'
? {
credential: this.encryptPassword(
id,
{
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
},
parsed.password.value
)
}
: {}),
hostKey: {
...commit.hostKey,
acceptedAt: timestamp,
generation: 1
},
hostKeyGeneration: 1,
revision: 1,
lastValidatedAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp
})
settings.hosts.push(host)
await this.persist(settings)
this.settings = settings
return this.toPublicHost(host)
}
const index = settings.hosts.findIndex(
(host) => host.id === commit.hostId
)
const current = settings.hosts[index]
if (!current) {
throw new Error('SSH 主机不存在')
}
if (
commit.expectedRevision === undefined ||
current.revision !== commit.expectedRevision
) {
throw new Error('SSH 主机配置已变化,请重新验证')
}
const changedTarget = targetChanged(current, parsed)
if (
current.credential &&
changedTarget &&
parsed.password.action === 'keep'
) {
throw new Error(
'主机地址、端口、用户名或认证方式已变化,请重新输入密码'
)
}
let credential =
parsed.password.action === 'keep'
? current.credential
: undefined
if (
parsed.authentication === 'password' &&
parsed.password.action === 'replace'
) {
credential = this.encryptPassword(
current.id,
{
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username
},
parsed.password.value
)
}
if (parsed.authentication === 'password' && !credential) {
throw new Error('SSH 密码不能为空')
}
const generation =
!changedTarget &&
current.hostKey?.publicKeyBase64 ===
commit.hostKey.publicKeyBase64
? current.hostKey.generation
: current.hostKeyGeneration + 1
const next: StoredHost = storedHostSchema.parse({
...current,
name: parsed.name,
hostname: parsed.hostname,
port: parsed.port,
username: parsed.username,
authentication: parsed.authentication,
...(credential ? { credential } : { credential: undefined }),
hostKey: {
...commit.hostKey,
acceptedAt: timestamp,
generation
},
hostKeyGeneration: generation,
revision: current.revision + 1,
lastValidatedAt: timestamp,
updatedAt: timestamp
})
settings.hosts[index] = next
await this.persist(settings)
this.settings = settings
return this.toPublicHost(next)
})
}
remove(hostId: string): Promise<void> {
return this.queue(async () => {
const settings = cloneSettings(await this.load())
const index = settings.hosts.findIndex(
(host) => host.id === hostId
)
if (index === -1) {
throw new Error('SSH 主机不存在')
}
settings.hosts.splice(index, 1)
await this.persist(settings)
this.settings = settings
this.credentialSourceCache.delete(hostId)
})
}
async resolveHost(hostId: string): Promise<ResolvedSshHost> {
const host = await this.getStoredHost(hostId)
return this.resolveStoredHost(host)
}
resolveConnectionTarget(
hostId: string
): Promise<SshConnectionTarget> {
return this.queue(async () => {
const host = (await this.load()).hosts.find(
(candidate) => candidate.id === hostId
)
if (!host) {
throw new Error('SSH 主机不存在')
}
const snapshot = structuredClone(host)
return {
host: this.resolveStoredHost(snapshot),
hostRevision: snapshot.revision,
hostKeyGeneration: snapshot.hostKeyGeneration
}
})
}
/**
* Synchronous transaction precondition for callers that already resolved a
* target. It deliberately consults only loaded, credential-free metadata so
* it is safe to call from inside a SQLite transaction.
*/
assertConnectionTargetCurrent(
expected: CurrentSshConnectionTarget
): void {
const settings = this.settings
if (!settings) {
throw new Error('SSH 主机当前状态尚未加载')
}
const host = settings.hosts.find(
(candidate) => candidate.id === expected.hostId
)
if (
!host ||
host.revision !== expected.hostRevision ||
host.hostKeyGeneration !== expected.hostKeyGeneration ||
host.username !== expected.username ||
!host.hostKey ||
host.hostKey.generation !== expected.hostKeyGeneration
) {
throw new Error('SSH 主机配置已变化,请重新验证')
}
}
private resolveStoredHost(host: StoredHost): ResolvedSshHost {
if (host.authentication === 'system-agent') {
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
hostKey: host.hostKey
}
}
if (!host.credential) {
throw new Error('SSH 主机尚未配置密码')
}
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, host.credential)
)
if (!credentialMatchesHost(payload, host)) {
throw new Error('SSH 主机凭据与当前主机不匹配')
}
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
password: payload.password,
hostKey: host.hostKey
}
}
private async getStoredHost(hostId: string): Promise<StoredHost> {
const host = (await this.load()).hosts.find(
(candidate) => candidate.id === hostId
)
if (!host) {
throw new Error('SSH 主机不存在')
}
return structuredClone(host)
}
async getHostIdentity(hostId: string): Promise<SshHostIdentity> {
const host = await this.getStoredHost(hostId)
return {
id: host.id,
hostname: host.hostname,
port: host.port,
username: host.username,
revision: host.revision,
...(host.hostKey
? {
hostKey: {
algorithm: host.hostKey.algorithm,
publicKeyBase64: host.hostKey.publicKeyBase64,
fingerprintSha256:
host.hostKey.fingerprintSha256,
generation: host.hostKeyGeneration
}
}
: {})
}
}
private encryptPassword(
hostId: string,
target: {
hostname: string
port: number
username: string
},
password: string
): StoredHost['credential'] {
if (!password) {
throw new Error('SSH 密码不能为空')
}
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存 SSH 密码')
}
return encryptSettingsCredential(this.cipher, {
version: 1,
hostId,
authentication: 'password',
...target,
password
})
}
private toPublicHost(host: StoredHost): SshHost {
let credentialSource: SshHost['credentialSource']
if (host.authentication === 'system-agent') {
this.credentialSourceCache.delete(host.id)
credentialSource = 'system-agent'
} else if (!host.credential) {
this.credentialSourceCache.delete(host.id)
credentialSource = 'none'
} else {
const secureStorageAvailable = this.cipher.isAvailable()
const cached = this.credentialSourceCache.get(host.id)
if (
cached?.credential === host.credential &&
cached.secureStorageAvailable === secureStorageAvailable &&
cached.hostname === host.hostname &&
cached.port === host.port &&
cached.username === host.username
) {
credentialSource = cached.source
} else {
try {
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, host.credential)
)
credentialSource =
credentialMatchesHost(payload, host)
? 'encrypted'
: 'unreadable'
} catch {
credentialSource = 'unreadable'
}
this.credentialSourceCache.set(host.id, {
credential: host.credential,
secureStorageAvailable,
hostname: host.hostname,
port: host.port,
username: host.username,
source: credentialSource
})
}
}
return {
id: host.id,
name: host.name,
hostname: host.hostname,
port: host.port,
username: host.username,
authentication: host.authentication,
credentialConfigured:
credentialSource === 'encrypted' ||
credentialSource === 'system-agent',
credentialSource,
hostKey: host.hostKey
? {
state: 'verified',
algorithm: host.hostKey.algorithm,
fingerprintSha256:
host.hostKey.fingerprintSha256,
generation: host.hostKeyGeneration
}
: {
state: 'unverified',
generation: host.hostKeyGeneration
},
...(host.lastValidatedAt
? { lastValidatedAt: host.lastValidatedAt }
: {}),
createdAt: host.createdAt,
updatedAt: host.updatedAt
}
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation, operation)
this.updateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
private async load(): Promise<StoredSettings> {
if (this.settings) {
return this.settings
}
if (!this.settingsLoad) {
this.settingsLoad = this.readStored().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readStored(): Promise<StoredSettings> {
this.credentialSourceCache.clear()
try {
const contents = await readFile(this.filePath, 'utf8')
let parsed: unknown
try {
parsed = JSON.parse(contents) as unknown
} catch {
await this.isolateCorruptFile()
this.settings = cloneSettings(defaultSettings)
return this.settings
}
assertSupportedSettingsVersion(
parsed,
CURRENT_SETTINGS_VERSION,
(version) =>
`当前 GoodBuddy 不支持 SSH 主机设置版本 ${version},请升级应用后重试`
)
const result = storedSettingsSchema.safeParse(parsed)
if (!result.success) {
await this.isolateCorruptFile()
this.settings = cloneSettings(defaultSettings)
return this.settings
}
this.settings = result.data
return this.settings
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (!isMissingFileError(error)) {
throw new Error('SSH 主机设置读取失败', { cause: error })
}
this.settings = cloneSettings(defaultSettings)
return this.settings
}
}
private async isolateCorruptFile(): Promise<void> {
await isolateCorruptSettingsFile(
this.filePath,
'SSH 主机设置损坏且无法隔离'
)
}
private async persist(settings: StoredSettings): Promise<void> {
await writeJsonFileAtomically(
this.filePath,
storedSettingsSchema.parse(settings)
)
}
}
+432
View File
@@ -0,0 +1,432 @@
import { generateKeyPairSync } from 'node:crypto'
import { EventEmitter } from 'node:events'
import type { AddressInfo } from 'node:net'
import {
Server,
type AuthContext,
type Connection
} from 'ssh2'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedSshHost } from './ssh-host-store'
import {
buildAuthenticatedSshConnectConfig,
createStrongSshAlgorithms,
describeSshHostKey,
selectStrongSshCiphers,
Ssh2Transport
} from './ssh-transport'
type ServerHarness = {
server: Server
port: number
authenticationMethods: string[]
passwords: string[]
commands: string[]
}
const servers: Server[] = []
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2_048,
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem'
},
publicKeyEncoding: {
type: 'pkcs1',
format: 'pem'
}
})
function createHostKeyBlob(algorithm: string): Buffer {
const name = Buffer.from(algorithm)
const length = Buffer.alloc(4)
length.writeUInt32BE(name.length)
return Buffer.concat([length, name, Buffer.alloc(32, 7)])
}
function createResolvedHost(
publicKey: Buffer,
algorithm = 'metadata-is-not-trusted'
): ResolvedSshHost {
return {
id: '00000000-0000-4000-8000-000000000101',
name: 'Pinned test host',
hostname: '127.0.0.1',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
algorithm,
publicKeyBase64: publicKey.toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
}
}
const testAlgorithms = createStrongSshAlgorithms([
'aes-256-gcm'
])
async function createServer(): Promise<ServerHarness> {
const authenticationMethods: string[] = []
const passwords: string[] = []
const commands: string[] = []
const server = new Server(
{ hostKeys: [privateKey] },
(client: Connection) => {
client.on('error', () => undefined)
client.on('authentication', (context: AuthContext) => {
authenticationMethods.push(context.method)
if (context.method === 'password') {
passwords.push(context.password)
if (
context.username === 'builder' &&
context.password === 'private password'
) {
context.accept()
return
}
}
context.reject(['password'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.on('exec', (acceptExec, _reject, info) => {
commands.push(info.command)
const stream = acceptExec()
stream.write(
[
'GOODBUDDY_SSH_PROBE_V1',
'Linux',
'x86_64',
'/bin/bash',
'/home/builder',
''
].join('\n')
)
stream.exit(0)
stream.end()
})
})
})
}
)
servers.push(server)
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => resolve())
})
return {
server,
port: (server.address() as AddressInfo).port,
authenticationMethods,
passwords,
commands
}
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
})
describe('SSH transport', () => {
it('removes ChaCha20 when the current OpenSSL build cannot provide it', () => {
expect(
selectStrongSshCiphers([
'aes-256-gcm',
'aes-128-gcm',
'aes-256-ctr',
'aes-192-ctr',
'aes-128-ctr'
])
).toEqual([
'aes256-gcm@openssh.com',
'aes128-gcm@openssh.com',
'aes256-ctr',
'aes192-ctr',
'aes128-ctr'
])
})
it('fails closed when no approved cipher is available', () => {
expect(() => selectStrongSshCiphers(['des-cbc'])).toThrow(
'不支持 GoodBuddy 要求的安全 SSH 加密算法'
)
})
it('describes complete SSH key blobs with SHA-256 fingerprints', () => {
const blob = createHostKeyBlob('ssh-ed25519')
expect(describeSshHostKey(blob)).toMatchObject({
algorithm: 'ssh-ed25519',
publicKeyBase64: blob.toString('base64'),
fingerprintSha256: expect.stringMatching(
/^SHA256:[A-Za-z0-9+/]{43}$/u
)
})
expect(() => describeSshHostKey(Buffer.alloc(0))).toThrow(
'大小无效'
)
})
it('negotiates only the algorithm represented by a pinned ECDSA key blob', () => {
const config = buildAuthenticatedSshConnectConfig(
createResolvedHost(
createHostKeyBlob('ecdsa-sha2-nistp384'),
'ssh-ed25519'
),
testAlgorithms,
undefined,
vi.fn()
)
expect(config.algorithms?.serverHostKey).toEqual([
'ecdsa-sha2-nistp384'
])
expect(testAlgorithms.serverHostKey).toContain('ssh-ed25519')
})
it('maps a pinned RSA key blob to RSA SHA-2 negotiation only', () => {
const config = buildAuthenticatedSshConnectConfig(
createResolvedHost(createHostKeyBlob('ssh-rsa')),
testAlgorithms,
undefined,
vi.fn()
)
expect(config.algorithms?.serverHostKey).toEqual([
'rsa-sha2-512',
'rsa-sha2-256'
])
expect(config.algorithms?.serverHostKey).not.toContain('ssh-rsa')
})
it('fails closed for malformed or unsupported pinned key algorithms', () => {
const malformed = Buffer.alloc(4)
malformed.writeUInt32BE(12)
expect(() =>
buildAuthenticatedSshConnectConfig(
createResolvedHost(malformed),
testAlgorithms,
undefined,
vi.fn()
)
).toThrow('主机密钥格式无效')
expect(() =>
buildAuthenticatedSshConnectConfig(
createResolvedHost(createHostKeyBlob('ssh-dss')),
testAlgorithms,
undefined,
vi.fn()
)
).toThrow('主机密钥算法不受支持')
})
it('keeps broad strong Host Key negotiation while inspecting draft hosts', async () => {
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn()
})
const transport = new Ssh2Transport({
createClient: () => client as never,
supportedOpenSslCiphers: () => ['aes-256-gcm']
})
const inspection = transport.inspectHostKey({
hostname: '127.0.0.1',
port: 22,
username: 'builder'
})
expect(client.connect).toHaveBeenCalledWith(
expect.objectContaining({
algorithms: expect.objectContaining({
serverHostKey: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256'
]
})
})
)
client.emit('error', new Error('stop inspection'))
await expect(inspection).rejects.toThrow('无法连接 SSH 主机')
})
it('captures the host key and terminates before sending any authentication method', async () => {
const harness = await createServer()
const transport = new Ssh2Transport()
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
expect(candidate).toMatchObject({
algorithm: 'ssh-rsa',
fingerprintSha256: expect.stringMatching(
/^SHA256:[A-Za-z0-9+/]{43}$/u
)
})
await new Promise((resolve) => setTimeout(resolve, 25))
expect(harness.authenticationMethods).toEqual([])
expect(harness.passwords).toEqual([])
})
it('pins the full key blob, authenticates, and executes only the bounded probe', async () => {
const harness = await createServer()
let now = 1_000
const transport = new Ssh2Transport({
now: () => {
now += 6
return now
}
})
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
const host: ResolvedSshHost = {
id: '00000000-0000-4000-8000-000000000102',
name: 'Local test host',
hostname: '127.0.0.1',
port: harness.port,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
...candidate,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
}
await expect(transport.testConnection(host)).resolves.toEqual({
connected: true,
latencyMs: 6,
platform: 'linux',
architecture: 'x64',
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
})
expect(harness.passwords).toEqual(['private password'])
expect(harness.commands).toHaveLength(1)
expect(harness.commands[0]).toContain(
"printf 'GOODBUDDY_SSH_PROBE_V1\\n'"
)
expect(harness.commands[0]).not.toContain('private password')
})
it('rejects a changed host key before authentication', async () => {
const harness = await createServer()
const transport = new Ssh2Transport()
const candidate = await transport.inspectHostKey({
hostname: '127.0.0.1',
port: harness.port,
username: 'builder'
})
const wrongKey = Buffer.from(
candidate.publicKeyBase64,
'base64'
)
const finalByte = wrongKey.readUInt8(wrongKey.length - 1)
wrongKey.writeUInt8(finalByte ^ 0xff, wrongKey.length - 1)
await expect(
transport.testConnection({
id: '00000000-0000-4000-8000-000000000103',
name: 'Local test host',
hostname: '127.0.0.1',
port: harness.port,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
...candidate,
publicKeyBase64: wrongKey.toString('base64'),
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
})
).rejects.toThrow('主机密钥已变化')
await new Promise((resolve) => setTimeout(resolve, 25))
expect(harness.passwords).toEqual([])
})
it('starts a separate bounded probe deadline after authentication', async () => {
vi.useFakeTimers()
try {
const stream = Object.assign(new EventEmitter(), {
destroy: vi.fn()
})
const client = Object.assign(new EventEmitter(), {
connect: vi.fn(function connect(this: EventEmitter) {
this.emit('ready')
}),
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(
(
_command: string,
callback: (
error: Error | undefined,
output: typeof stream
) => void
) => callback(undefined, stream)
)
})
const transport = new Ssh2Transport({
createClient: () => client as never,
now: () => 0
})
const connection = transport.testConnection({
id: '00000000-0000-4000-8000-000000000106',
name: 'Timeout test host',
hostname: '127.0.0.1',
port: 22,
username: 'builder',
authentication: 'password',
password: 'private password',
hostKey: {
algorithm: 'ssh-ed25519',
publicKeyBase64:
createHostKeyBlob('ssh-ed25519').toString('base64'),
fingerprintSha256: `SHA256:${'A'.repeat(43)}`,
acceptedAt: '2026-08-01T00:00:00.000Z',
generation: 1
}
})
const rejected = vi.fn()
void connection.catch(rejected)
await vi.advanceTimersByTimeAsync(9_999)
expect(rejected).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(rejected).toHaveBeenCalledOnce()
await expect(connection).rejects.toThrow('远端系统探测超时')
expect(client.end).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+551
View File
@@ -0,0 +1,551 @@
import { createHash, getCiphers } from 'node:crypto'
import {
Client,
type Algorithms,
type CipherAlgorithm,
type ConnectConfig,
type ServerHostKeyAlgorithm
} from 'ssh2'
import type {
SshHostConnectionTestResult
} from '../../shared/ssh-host-contracts'
import type { ResolvedSshHost } from './ssh-host-store'
export const SSH_CONNECTION_TIMEOUT_MS = 10_000
export const SSH_KEEPALIVE_INTERVAL_MS = 5_000
export const SSH_KEEPALIVE_COUNT_MAX = 2
const PROBE_TIMEOUT_MS = 10_000
const MAX_PROBE_OUTPUT_BYTES = 16 * 1024
const MAX_HOST_KEY_BYTES = 32 * 1024
const PROBE_MARKER = 'GOODBUDDY_SSH_PROBE_V1'
const PROBE_COMMAND = [
`printf '${PROBE_MARKER}\\n'`,
"uname -s 2>/dev/null || printf 'unknown\\n'",
"uname -m 2>/dev/null || printf 'unknown\\n'",
"printf '%s\\n' \"${SHELL:-unknown}\"",
"printf '%s\\n' \"${HOME:-unknown}\""
].join('; ')
const strongAlgorithms: Omit<Algorithms, 'cipher'> = {
kex: [
'curve25519-sha256',
'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group15-sha512',
'diffie-hellman-group16-sha512',
'diffie-hellman-group17-sha512',
'diffie-hellman-group18-sha512'
],
serverHostKey: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256'
],
hmac: [
'hmac-sha2-512-etm@openssh.com',
'hmac-sha2-256-etm@openssh.com',
'hmac-sha2-512',
'hmac-sha2-256'
]
}
const strongCipherCandidates = [
{
sshName: 'chacha20-poly1305@openssh.com',
openSslName: 'chacha20'
},
{
sshName: 'aes256-gcm@openssh.com',
openSslName: 'aes-256-gcm'
},
{
sshName: 'aes128-gcm@openssh.com',
openSslName: 'aes-128-gcm'
},
{ sshName: 'aes256-ctr', openSslName: 'aes-256-ctr' },
{ sshName: 'aes192-ctr', openSslName: 'aes-192-ctr' },
{ sshName: 'aes128-ctr', openSslName: 'aes-128-ctr' }
] as const
export function selectStrongSshCiphers(
supportedOpenSslCiphers: readonly string[]
): CipherAlgorithm[] {
const supported = new Set(supportedOpenSslCiphers)
const selected = strongCipherCandidates
.filter((candidate) => supported.has(candidate.openSslName))
.map((candidate) => candidate.sshName)
if (selected.length === 0) {
throw new Error('当前运行环境不支持 GoodBuddy 要求的安全 SSH 加密算法')
}
return selected
}
export function createStrongSshAlgorithms(
supportedOpenSslCiphers: readonly string[]
): Algorithms {
return {
...strongAlgorithms,
cipher: selectStrongSshCiphers(supportedOpenSslCiphers)
}
}
export type SshConnectionTarget = {
hostname: string
port: number
username: string
}
export type SshHostKeyCandidate = {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
}
export interface SshTransport {
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate>
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>>
}
type ClientLike = Pick<Client, 'connect' | 'end' | 'destroy' | 'exec'> & {
once: Client['once']
on: Client['on']
}
type SshTransportDependencies = {
createClient: () => ClientLike
now: () => number
supportedOpenSslCiphers: () => readonly string[]
systemAgent: () => string | undefined
}
function readSshString(
value: Buffer,
offset: number
): { value: string; offset: number } {
if (offset + 4 > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
const length = value.readUInt32BE(offset)
const start = offset + 4
const end = start + length
if (length <= 0 || end > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
return {
value: value.subarray(start, end).toString('ascii'),
offset: end
}
}
export function describeSshHostKey(
publicKey: Buffer
): SshHostKeyCandidate {
if (
publicKey.byteLength === 0 ||
publicKey.byteLength > MAX_HOST_KEY_BYTES
) {
throw new Error('SSH 主机密钥大小无效')
}
const algorithm = readSshString(publicKey, 0).value
if (
![
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-rsa'
].includes(algorithm)
) {
throw new Error('SSH 主机密钥算法不受支持')
}
return {
algorithm,
publicKeyBase64: publicKey.toString('base64'),
fingerprintSha256: `SHA256:${createHash('sha256')
.update(publicKey)
.digest('base64')
.replace(/=+$/u, '')}`
}
}
export function defaultSystemAgent(): string | undefined {
const configured = process.env.SSH_AUTH_SOCK?.trim()
if (configured) {
return configured
}
return process.platform === 'win32'
? '\\\\.\\pipe\\openssh-ssh-agent'
: undefined
}
export function safeSshError(error: unknown): Error {
if (
error !== null &&
typeof error === 'object' &&
'level' in error &&
typeof error.level === 'string'
) {
if (error.level.includes('client-authentication')) {
return new Error('SSH 认证失败,请检查用户名和认证凭据')
}
if (error.level.includes('client-timeout')) {
return new Error('SSH 连接超时')
}
}
const code =
error !== null &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string'
? error.code
: ''
if (code === 'ENOTFOUND') {
return new Error('无法解析 SSH 主机地址')
}
if (code === 'ECONNREFUSED') {
return new Error('SSH 主机拒绝连接')
}
if (code === 'ETIMEDOUT') {
return new Error('SSH 连接超时')
}
return new Error('无法连接 SSH 主机')
}
function selectPinnedServerHostKeyAlgorithms(
algorithm: string
): ServerHostKeyAlgorithm[] {
switch (algorithm) {
case 'ssh-ed25519':
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
return [algorithm]
case 'ssh-rsa':
return ['rsa-sha2-512', 'rsa-sha2-256']
default:
throw new Error('SSH 主机密钥算法不受支持')
}
}
export function buildAuthenticatedSshConnectConfig(
host: ResolvedSshHost,
algorithms: Algorithms,
systemAgent: string | undefined,
onHostKeyMismatch: () => void
): ConnectConfig {
if (!host.hostKey) {
throw new Error('请先验证并接受 SSH 主机密钥')
}
const publicKey = Buffer.from(
host.hostKey.publicKeyBase64,
'base64'
)
if (publicKey.byteLength === 0) {
throw new Error('SSH 主机密钥无效')
}
const pinnedAlgorithm = describeSshHostKey(publicKey).algorithm
const pinnedServerHostKeyAlgorithms =
selectPinnedServerHostKeyAlgorithms(pinnedAlgorithm)
const config: ConnectConfig = {
host: host.hostname,
port: host.port,
username: host.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
algorithms: {
...algorithms,
serverHostKey: [...pinnedServerHostKeyAlgorithms]
},
agentForward: false,
hostVerifier: (key: Buffer) => {
const matches = key.equals(publicKey)
if (!matches) {
onHostKeyMismatch()
}
return matches
}
}
if (host.authentication === 'password') {
if (!host.password) {
throw new Error('SSH 主机尚未配置密码')
}
config.password = host.password
} else {
if (!systemAgent) {
throw new Error('当前系统未检测到可用的 SSH Agent')
}
config.agent = systemAgent
}
return config
}
function parseProbeOutput(
stdout: Buffer,
latencyMs: number
): Omit<SshHostConnectionTestResult, 'hostId'> {
const lines = stdout
.toString('utf8')
.replaceAll('\r\n', '\n')
.trimEnd()
.split('\n')
if (lines[0] !== PROBE_MARKER || lines.length < 5) {
throw new Error('SSH 远端系统探测返回了无效结果')
}
const platform =
lines[1] === 'Linux'
? 'linux'
: lines[1] === 'Darwin'
? 'darwin'
: /^MINGW|^MSYS|^CYGWIN|^Windows_NT$/u.test(lines[1] ?? '')
? 'win32'
: 'unknown'
const architecture =
/^(x86_64|amd64)$/iu.test(lines[2] ?? '')
? 'x64'
: /^(aarch64|arm64)$/iu.test(lines[2] ?? '')
? 'arm64'
: 'unknown'
return {
connected: true,
latencyMs,
platform,
architecture,
shell: (lines[3] ?? 'unknown').slice(0, 256),
homeDirectory: (lines[4] ?? 'unknown').slice(0, 4_096),
detail: `SSH 已连接,远端系统为 ${platform}/${architecture}`
}
}
export class Ssh2Transport implements SshTransport {
private readonly dependencies: SshTransportDependencies
private readonly algorithms: Algorithms
constructor(
dependencies: Partial<SshTransportDependencies> = {}
) {
this.dependencies = {
createClient: () => new Client(),
now: Date.now,
supportedOpenSslCiphers: getCiphers,
systemAgent: defaultSystemAgent,
...dependencies
}
this.algorithms = createStrongSshAlgorithms(
this.dependencies.supportedOpenSslCiphers()
)
}
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate> {
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
let settled = false
const settle = (
outcome:
| { ok: true; value: SshHostKeyCandidate }
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.destroy()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
const timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 主机身份检查超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({ ok: false, error: safeSshError(error) })
})
client.connect({
host: target.hostname,
port: target.port,
username: target.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
algorithms: this.algorithms,
agentForward: false,
hostVerifier: (key: Buffer) => {
try {
settle({
ok: true,
value: describeSshHostKey(key)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 主机密钥无效')
})
}
return false
}
})
})
}
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>> {
if (!host.hostKey) {
return Promise.reject(
new Error('请先验证并接受 SSH 主机密钥')
)
}
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
const startedAt = this.dependencies.now()
let settled = false
let hostKeyMismatch = false
const settle = (
outcome:
| {
ok: true
value: Omit<SshHostConnectionTestResult, 'hostId'>
}
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.end()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
let timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 连接超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({
ok: false,
error: hostKeyMismatch
? new Error('SSH 主机密钥已变化,请重新验证主机身份')
: safeSshError(error)
})
})
client.once('ready', () => {
clearTimeout(timeout)
timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 远端系统探测超时')
}),
PROBE_TIMEOUT_MS
)
client.exec(PROBE_COMMAND, (error, stream) => {
if (error) {
settle({
ok: false,
error: new Error('无法启动 SSH 远端系统探测')
})
return
}
const chunks: Buffer[] = []
let outputBytes = 0
stream.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.from(chunk)
outputBytes += buffer.byteLength
if (outputBytes > MAX_PROBE_OUTPUT_BYTES) {
stream.destroy()
settle({
ok: false,
error: new Error('SSH 远端系统探测输出超过安全限制')
})
return
}
chunks.push(buffer)
})
stream.once('error', () => {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
})
stream.once('close', (code: number | null) => {
if (code !== 0) {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
return
}
try {
settle({
ok: true,
value: parseProbeOutput(
Buffer.concat(chunks),
Math.max(0, this.dependencies.now() - startedAt)
)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 远端系统探测失败')
})
}
})
})
})
let config: ConnectConfig
try {
config = buildAuthenticatedSshConnectConfig(
host,
this.algorithms,
this.dependencies.systemAgent(),
() => {
hostKeyMismatch = true
}
)
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 认证配置无效')
})
return
}
client.connect(config)
})
}
}