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 个单元测试和完整文档。
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
import { existsSync } from 'fs'
|
|
import { resolve } from 'path'
|
|
import { logForDebugging } from 'src/utils/debug.js'
|
|
|
|
const SSH_TIMEOUT_MS = 60_000
|
|
const REMOTE_BIN_DIR = '~/.local/bin'
|
|
const REMOTE_CLI_FILE = 'claude-code-cli.js'
|
|
const REMOTE_WRAPPER = 'claude'
|
|
|
|
export interface DeployOptions {
|
|
host: string
|
|
remotePlatform: string
|
|
remoteArch: string
|
|
localVersion: string
|
|
onProgress?: (msg: string) => void
|
|
}
|
|
|
|
async function runSshCommand(
|
|
host: string,
|
|
command: string,
|
|
timeoutMs = SSH_TIMEOUT_MS,
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
const proc = Bun.spawn(['ssh', '-o', 'ConnectTimeout=10', host, command], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
})
|
|
|
|
const timer = setTimeout(() => proc.kill(), timeoutMs)
|
|
|
|
try {
|
|
const [stdout, stderr] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
])
|
|
const exitCode = await proc.exited
|
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
function findLocalBinary(): string {
|
|
const projectRoot = resolve(import.meta.dir, '../..')
|
|
const distPath = resolve(projectRoot, 'dist/cli.js')
|
|
if (existsSync(distPath)) return distPath
|
|
|
|
const devPath = resolve(projectRoot, 'src/entrypoints/cli.tsx')
|
|
if (existsSync(devPath)) return devPath
|
|
|
|
throw new Error(
|
|
'Cannot find local CLI binary to deploy. Run `bun run build` first.',
|
|
)
|
|
}
|
|
|
|
export async function deployBinary(options: DeployOptions): Promise<string> {
|
|
const { host, remotePlatform, remoteArch, localVersion, onProgress } = options
|
|
|
|
if (remotePlatform !== 'linux' && remotePlatform !== 'darwin') {
|
|
throw new Error(
|
|
`Remote platform "${remotePlatform}" is not supported. Only linux and darwin are supported.`,
|
|
)
|
|
}
|
|
|
|
logForDebugging(
|
|
`[SSHDeploy] deploying to ${host} (${remotePlatform}/${remoteArch}, v${localVersion})`,
|
|
)
|
|
|
|
const localBinary = findLocalBinary()
|
|
logForDebugging(`[SSHDeploy] local binary: ${localBinary}`)
|
|
|
|
onProgress?.('Creating remote directory...')
|
|
const mkdirResult = await runSshCommand(host, `mkdir -p ${REMOTE_BIN_DIR}`)
|
|
if (mkdirResult.exitCode !== 0) {
|
|
throw new Error(`Failed to create remote directory: ${mkdirResult.stderr}`)
|
|
}
|
|
|
|
onProgress?.('Uploading binary...')
|
|
const remotePath = `${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE}`
|
|
const scpProc = Bun.spawn(
|
|
['scp', '-o', 'ConnectTimeout=10', localBinary, `${host}:${remotePath}`],
|
|
{ stdout: 'pipe', stderr: 'pipe' },
|
|
)
|
|
const scpTimer = setTimeout(() => scpProc.kill(), SSH_TIMEOUT_MS)
|
|
const scpStderr = await new Response(scpProc.stderr).text()
|
|
const scpExit = await scpProc.exited
|
|
clearTimeout(scpTimer)
|
|
|
|
if (scpExit !== 0) {
|
|
throw new Error(`SCP upload failed (exit ${scpExit}): ${scpStderr.trim()}`)
|
|
}
|
|
|
|
onProgress?.('Installing wrapper script...')
|
|
const wrapperScript = [
|
|
`cat > ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} << 'WRAPPER'`,
|
|
'#!/bin/sh',
|
|
`exec bun ${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE} "$@"`,
|
|
'WRAPPER',
|
|
`chmod +x ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`,
|
|
].join('\n')
|
|
|
|
const wrapperResult = await runSshCommand(host, wrapperScript)
|
|
if (wrapperResult.exitCode !== 0) {
|
|
throw new Error(`Failed to install wrapper script: ${wrapperResult.stderr}`)
|
|
}
|
|
|
|
onProgress?.('Verifying installation...')
|
|
const verifyResult = await runSshCommand(
|
|
host,
|
|
`${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} --version`,
|
|
)
|
|
if (verifyResult.exitCode !== 0) {
|
|
throw new Error(
|
|
`Binary deployed but verification failed (exit ${verifyResult.exitCode}): ${verifyResult.stderr}`,
|
|
)
|
|
}
|
|
|
|
logForDebugging(
|
|
`[SSHDeploy] deployed successfully, remote version: ${verifyResult.stdout}`,
|
|
)
|
|
onProgress?.(`Deployed v${verifyResult.stdout}`)
|
|
|
|
return `${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`
|
|
}
|