Files
claude-code/src/ssh/SSHProbe.ts
unraid 03811f973b feat: 实现 SSH Remote — 本地 REPL + 远端工具执行
SSH Remote 允许在本地运行交互式 REPL,同时将工具调用(Bash、文件读写等)
通过 SSH 隧道转发到远程主机执行。

核心模块:
- SSHSessionManager: NDJSON 双向通信、权限转发、指数退避重连
- SSHAuthProxy: 本地认证代理 + SSH -R 反向端口转发,nonce 验证
- SSHProbe: 远端主机平台/架构/已有二进制探测
- SSHDeploy: 远端二进制部署(scp)
- createSSHSession: 会话编排(probe → deploy → spawn → attach)

新增选项:
- --remote-bin: 跳过 probe/deploy,使用自定义远端二进制
- ANTHROPIC_AUTH_NONCE: API 请求认证 nonce header

包含 17 个单元测试和完整文档。
2026-04-24 14:25:56 +08:00

100 lines
2.7 KiB
TypeScript

import { logForDebugging } from 'src/utils/debug.js'
const PROBE_TIMEOUT_MS = 15_000
export interface ProbeResult {
hasBinary: boolean
remoteVersion: string | null
remotePlatform: 'linux' | 'darwin'
remoteArch: 'x64' | 'arm64'
defaultCwd: string
binaryPath: string | null
}
export class SSHProbeError extends Error {
constructor(message: string) {
super(message)
this.name = 'SSHProbeError'
}
}
export async function probeRemote(
host: string,
onProgress?: (msg: string) => void,
): Promise<ProbeResult> {
onProgress?.('Probing remote host…')
const proc = Bun.spawn(
[
'ssh',
'-o',
'BatchMode=yes',
'-o',
'ConnectTimeout=10',
host,
'CLAUDE_BIN=$(test -x "$HOME/.local/bin/claude" && echo "$HOME/.local/bin/claude" || command -v claude 2>/dev/null); echo "$CLAUDE_BIN"; $CLAUDE_BIN --version 2>/dev/null; uname -sm; pwd',
],
{ stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
)
const result = await Promise.race([
proc.exited,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new SSHProbeError(
`SSH probe timed out after ${PROBE_TIMEOUT_MS / 1000}s`,
),
),
PROBE_TIMEOUT_MS,
),
),
])
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (result !== 0) {
const detail = stderr.trim() || `exit code ${result}`
throw new SSHProbeError(`SSH probe failed: ${detail}`)
}
const lines = stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
logForDebugging(`[SSHProbe] raw lines: ${JSON.stringify(lines)}`)
const unameIdx = lines.findIndex(l => /^(Linux|Darwin)\s/.test(l))
if (unameIdx === -1) {
throw new SSHProbeError(
'Could not detect remote platform (uname output missing)',
)
}
const binaryPath = unameIdx >= 2 ? lines[unameIdx - 2] || null : null
const versionLine = unameIdx >= 1 ? lines[unameIdx - 1] || null : null
const remoteVersion =
versionLine && /^\d+\.\d+/.test(versionLine) ? versionLine : null
const hasBinary = binaryPath !== null && binaryPath.startsWith('/')
const defaultCwd = lines[unameIdx + 1] || '/'
const [osName, arch] = lines[unameIdx]!.split(/\s+/)
const remotePlatform = osName === 'Darwin' ? 'darwin' : 'linux'
const remoteArch: 'x64' | 'arm64' =
arch === 'aarch64' || arch === 'arm64' ? 'arm64' : 'x64'
onProgress?.(`Detected ${remotePlatform}/${remoteArch}`)
return {
hasBinary: hasBinary && remoteVersion !== null,
remoteVersion,
remotePlatform,
remoteArch,
defaultCwd,
binaryPath: hasBinary ? binaryPath : null,
}
}