Skip to content

Commit 03811f9

Browse files
author
unraid
committed
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 个单元测试和完整文档。
1 parent 2a5b263 commit 03811f9

10 files changed

Lines changed: 2010 additions & 15 deletions

File tree

docs/features/ssh-remote.md

Lines changed: 426 additions & 0 deletions
Large diffs are not rendered by default.

scripts/defines.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,6 @@ export const DEFAULT_BUILD_FEATURES = [
7272
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
7373
// Team Memory
7474
'TEAMMEM', // 团队记忆,代理队友间共享记忆文件
75+
// SSH Remote
76+
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
7577
]as const;

src/main.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,7 @@ type PendingSSH = {
869869
local: boolean;
870870
/** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */
871871
extraCliArgs: string[];
872+
remoteBin: string | undefined;
872873
};
873874
const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE")
874875
? {
@@ -878,6 +879,7 @@ const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE")
878879
dangerouslySkipPermissions: false,
879880
local: false,
880881
extraCliArgs: [],
882+
remoteBin: undefined,
881883
}
882884
: undefined;
883885

@@ -1084,6 +1086,17 @@ export async function main() {
10841086
rawCliArgs.splice(eqI, 1);
10851087
}
10861088
};
1089+
const rbIdx = rawCliArgs.indexOf('--remote-bin');
1090+
if (rbIdx !== -1 && rawCliArgs[rbIdx + 1] && !rawCliArgs[rbIdx + 1]!.startsWith('-')) {
1091+
_pendingSSH.remoteBin = rawCliArgs[rbIdx + 1];
1092+
rawCliArgs.splice(rbIdx, 2);
1093+
}
1094+
const rbEqIdx = rawCliArgs.findIndex(a => a.startsWith('--remote-bin='));
1095+
if (rbEqIdx !== -1) {
1096+
_pendingSSH.remoteBin = rawCliArgs[rbEqIdx]!.split('=').slice(1).join('=');
1097+
rawCliArgs.splice(rbEqIdx, 1);
1098+
}
1099+
10871100
extractFlag("-c", { as: "--continue" });
10881101
extractFlag("--continue");
10891102
extractFlag("--resume", { hasValue: true });
@@ -4643,6 +4656,7 @@ async function run(): Promise<CommanderCommand> {
46434656
dangerouslySkipPermissions:
46444657
_pendingSSH.dangerouslySkipPermissions,
46454658
extraCliArgs: _pendingSSH.extraCliArgs,
4659+
remoteBin: _pendingSSH.remoteBin,
46464660
},
46474661
isTTY
46484662
? {
@@ -5980,6 +5994,11 @@ async function run(): Promise<CommanderCommand> {
59805994
"--dangerously-skip-permissions",
59815995
"Skip all permission prompts on the remote (dangerous)",
59825996
)
5997+
.option(
5998+
"--remote-bin <command>",
5999+
"Custom remote binary command (skips probe/deploy). " +
6000+
"Example: --remote-bin 'bun /path/to/project/dist/cli.js'",
6001+
)
59836002
.option(
59846003
"--local",
59856004
"e2e test mode — spawn the child CLI locally (skip ssh/deploy). " +

src/services/api/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export async function getAnthropicClient({
109109
: {}),
110110
// SDK consumers can identify their app/library for backend analytics
111111
...(clientApp ? { 'x-client-app': clientApp } : {}),
112+
// SSH auth proxy nonce — tunneled API requests must carry this header
113+
...(process.env.ANTHROPIC_AUTH_NONCE
114+
? { 'x-auth-nonce': process.env.ANTHROPIC_AUTH_NONCE }
115+
: {}),
112116
}
113117

114118
// Log API client configuration for HFI debugging

src/ssh/SSHAuthProxy.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { randomUUID } from 'crypto'
2+
import { unlinkSync } from 'fs'
3+
import { getClaudeAIOAuthTokens } from 'src/utils/auth.js'
4+
import { getOauthConfig } from 'src/constants/oauth.js'
5+
import { logForDebugging } from 'src/utils/debug.js'
6+
7+
export interface SSHAuthProxy {
8+
stop(): void
9+
}
10+
11+
export interface AuthProxyInfo {
12+
proxy: SSHAuthProxy
13+
/** Unix socket path or 127.0.0.1:<port> */
14+
localAddress: string
15+
/** Environment variables to inject into the remote/child CLI process */
16+
authEnv: Record<string, string>
17+
}
18+
19+
const isWindows = process.platform === 'win32'
20+
21+
function resolveAuthHeaders(): Record<string, string> {
22+
const apiKey = process.env.ANTHROPIC_API_KEY
23+
if (apiKey) {
24+
return { 'x-api-key': apiKey }
25+
}
26+
27+
const oauthTokens = getClaudeAIOAuthTokens()
28+
if (oauthTokens?.accessToken) {
29+
return { Authorization: `Bearer ${oauthTokens.accessToken}` }
30+
}
31+
32+
return {}
33+
}
34+
35+
function resolveUpstreamBaseUrl(): string {
36+
return process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL
37+
}
38+
39+
async function proxyFetch(
40+
req: Request,
41+
nonce: string | null,
42+
): Promise<Response> {
43+
if (nonce && req.headers.get('x-auth-nonce') !== nonce) {
44+
return new Response('Forbidden', { status: 403 })
45+
}
46+
47+
const upstreamBase = resolveUpstreamBaseUrl()
48+
const url = new URL(req.url)
49+
const upstreamUrl = `${upstreamBase}${url.pathname}${url.search}`
50+
51+
const authHeaders = resolveAuthHeaders()
52+
if (Object.keys(authHeaders).length === 0) {
53+
return new Response(
54+
JSON.stringify({
55+
error: 'No API credentials available on local machine',
56+
}),
57+
{ status: 401, headers: { 'content-type': 'application/json' } },
58+
)
59+
}
60+
61+
const forwardHeaders = new Headers(req.headers)
62+
for (const [k, v] of Object.entries(authHeaders)) {
63+
forwardHeaders.set(k, v)
64+
}
65+
forwardHeaders.delete('host')
66+
forwardHeaders.delete('x-auth-nonce')
67+
68+
logForDebugging(
69+
`[SSHAuthProxy] ${req.method} ${url.pathname} -> ${upstreamUrl}`,
70+
)
71+
72+
try {
73+
const upstreamRes = await fetch(upstreamUrl, {
74+
method: req.method,
75+
headers: forwardHeaders,
76+
body: req.body,
77+
// @ts-expect-error Bun supports duplex for streaming request bodies
78+
duplex: 'half',
79+
})
80+
81+
const responseHeaders = new Headers(upstreamRes.headers)
82+
responseHeaders.delete('content-encoding')
83+
responseHeaders.delete('content-length')
84+
85+
return new Response(upstreamRes.body, {
86+
status: upstreamRes.status,
87+
statusText: upstreamRes.statusText,
88+
headers: responseHeaders,
89+
})
90+
} catch (err) {
91+
const message = err instanceof Error ? err.message : String(err)
92+
logForDebugging(`[SSHAuthProxy] upstream error: ${message}`)
93+
return new Response(
94+
JSON.stringify({ error: `Proxy upstream error: ${message}` }),
95+
{ status: 502, headers: { 'content-type': 'application/json' } },
96+
)
97+
}
98+
}
99+
100+
export async function createAuthProxy(): Promise<AuthProxyInfo> {
101+
const id = randomUUID()
102+
103+
if (isWindows) {
104+
return createTcpAuthProxy(id)
105+
}
106+
return createUnixSocketAuthProxy(id)
107+
}
108+
109+
async function createUnixSocketAuthProxy(id: string): Promise<AuthProxyInfo> {
110+
const socketPath = `/tmp/claude-ssh-auth-${id}.sock`
111+
112+
const server = Bun.serve({
113+
unix: socketPath,
114+
fetch: req => proxyFetch(req, null),
115+
})
116+
117+
logForDebugging(`[SSHAuthProxy] listening on unix:${socketPath}`)
118+
119+
const proxy: SSHAuthProxy = {
120+
stop() {
121+
server.stop(true)
122+
try {
123+
unlinkSync(socketPath)
124+
} catch {
125+
// Socket file may already be cleaned up
126+
}
127+
},
128+
}
129+
130+
return {
131+
proxy,
132+
localAddress: socketPath,
133+
authEnv: { ANTHROPIC_AUTH_SOCKET: socketPath },
134+
}
135+
}
136+
137+
async function createTcpAuthProxy(id: string): Promise<AuthProxyInfo> {
138+
const nonce = randomUUID()
139+
140+
const server = Bun.serve({
141+
port: 0,
142+
hostname: '127.0.0.1',
143+
fetch: req => proxyFetch(req, nonce),
144+
})
145+
146+
const port = server.port
147+
logForDebugging(
148+
`[SSHAuthProxy] listening on TCP 127.0.0.1:${port} (nonce-protected)`,
149+
)
150+
151+
const proxy: SSHAuthProxy = {
152+
stop() {
153+
server.stop(true)
154+
},
155+
}
156+
157+
return {
158+
proxy,
159+
localAddress: `127.0.0.1:${port}`,
160+
authEnv: {
161+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
162+
ANTHROPIC_AUTH_NONCE: nonce,
163+
},
164+
}
165+
}

src/ssh/SSHDeploy.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { existsSync } from 'fs'
2+
import { resolve } from 'path'
3+
import { logForDebugging } from 'src/utils/debug.js'
4+
5+
const SSH_TIMEOUT_MS = 60_000
6+
const REMOTE_BIN_DIR = '~/.local/bin'
7+
const REMOTE_CLI_FILE = 'claude-code-cli.js'
8+
const REMOTE_WRAPPER = 'claude'
9+
10+
export interface DeployOptions {
11+
host: string
12+
remotePlatform: string
13+
remoteArch: string
14+
localVersion: string
15+
onProgress?: (msg: string) => void
16+
}
17+
18+
async function runSshCommand(
19+
host: string,
20+
command: string,
21+
timeoutMs = SSH_TIMEOUT_MS,
22+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
23+
const proc = Bun.spawn(['ssh', '-o', 'ConnectTimeout=10', host, command], {
24+
stdout: 'pipe',
25+
stderr: 'pipe',
26+
})
27+
28+
const timer = setTimeout(() => proc.kill(), timeoutMs)
29+
30+
try {
31+
const [stdout, stderr] = await Promise.all([
32+
new Response(proc.stdout).text(),
33+
new Response(proc.stderr).text(),
34+
])
35+
const exitCode = await proc.exited
36+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
37+
} finally {
38+
clearTimeout(timer)
39+
}
40+
}
41+
42+
function findLocalBinary(): string {
43+
const projectRoot = resolve(import.meta.dir, '../..')
44+
const distPath = resolve(projectRoot, 'dist/cli.js')
45+
if (existsSync(distPath)) return distPath
46+
47+
const devPath = resolve(projectRoot, 'src/entrypoints/cli.tsx')
48+
if (existsSync(devPath)) return devPath
49+
50+
throw new Error(
51+
'Cannot find local CLI binary to deploy. Run `bun run build` first.',
52+
)
53+
}
54+
55+
export async function deployBinary(options: DeployOptions): Promise<string> {
56+
const { host, remotePlatform, remoteArch, localVersion, onProgress } = options
57+
58+
if (remotePlatform !== 'linux' && remotePlatform !== 'darwin') {
59+
throw new Error(
60+
`Remote platform "${remotePlatform}" is not supported. Only linux and darwin are supported.`,
61+
)
62+
}
63+
64+
logForDebugging(
65+
`[SSHDeploy] deploying to ${host} (${remotePlatform}/${remoteArch}, v${localVersion})`,
66+
)
67+
68+
const localBinary = findLocalBinary()
69+
logForDebugging(`[SSHDeploy] local binary: ${localBinary}`)
70+
71+
onProgress?.('Creating remote directory...')
72+
const mkdirResult = await runSshCommand(host, `mkdir -p ${REMOTE_BIN_DIR}`)
73+
if (mkdirResult.exitCode !== 0) {
74+
throw new Error(`Failed to create remote directory: ${mkdirResult.stderr}`)
75+
}
76+
77+
onProgress?.('Uploading binary...')
78+
const remotePath = `${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE}`
79+
const scpProc = Bun.spawn(
80+
['scp', '-o', 'ConnectTimeout=10', localBinary, `${host}:${remotePath}`],
81+
{ stdout: 'pipe', stderr: 'pipe' },
82+
)
83+
const scpTimer = setTimeout(() => scpProc.kill(), SSH_TIMEOUT_MS)
84+
const scpStderr = await new Response(scpProc.stderr).text()
85+
const scpExit = await scpProc.exited
86+
clearTimeout(scpTimer)
87+
88+
if (scpExit !== 0) {
89+
throw new Error(`SCP upload failed (exit ${scpExit}): ${scpStderr.trim()}`)
90+
}
91+
92+
onProgress?.('Installing wrapper script...')
93+
const wrapperScript = [
94+
`cat > ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} << 'WRAPPER'`,
95+
'#!/bin/sh',
96+
`exec bun ${REMOTE_BIN_DIR}/${REMOTE_CLI_FILE} "$@"`,
97+
'WRAPPER',
98+
`chmod +x ${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`,
99+
].join('\n')
100+
101+
const wrapperResult = await runSshCommand(host, wrapperScript)
102+
if (wrapperResult.exitCode !== 0) {
103+
throw new Error(`Failed to install wrapper script: ${wrapperResult.stderr}`)
104+
}
105+
106+
onProgress?.('Verifying installation...')
107+
const verifyResult = await runSshCommand(
108+
host,
109+
`${REMOTE_BIN_DIR}/${REMOTE_WRAPPER} --version`,
110+
)
111+
if (verifyResult.exitCode !== 0) {
112+
throw new Error(
113+
`Binary deployed but verification failed (exit ${verifyResult.exitCode}): ${verifyResult.stderr}`,
114+
)
115+
}
116+
117+
logForDebugging(
118+
`[SSHDeploy] deployed successfully, remote version: ${verifyResult.stdout}`,
119+
)
120+
onProgress?.(`Deployed v${verifyResult.stdout}`)
121+
122+
return `${REMOTE_BIN_DIR}/${REMOTE_WRAPPER}`
123+
}

0 commit comments

Comments
 (0)