Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/defines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export const DEFAULT_BUILD_FEATURES = [
'COMMIT_ATTRIBUTION',
// Server mode (claude server / claude open)
'DIRECT_CONNECT',
// SSH remote mode (claude ssh <host>)
'SSH_REMOTE',
// Skill search
'EXPERIMENTAL_SKILL_SEARCH',
// P3: poor mode (disable extract_memories + prompt_suggestion)
Expand Down
161 changes: 161 additions & 0 deletions src/ssh/SSHAuthProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { randomUUID } from 'crypto'
import { unlinkSync } from 'fs'
import { getClaudeAIOAuthTokens } from 'src/utils/auth.js'
import { getOauthConfig } from 'src/constants/oauth.js'
import { logForDebugging } from 'src/utils/debug.js'

export interface SSHAuthProxy {
stop(): void
}

export interface AuthProxyInfo {
proxy: SSHAuthProxy
/** Unix socket path or 127.0.0.1:<port> */
localAddress: string
/** Environment variables to inject into the remote/child CLI process */
authEnv: Record<string, string>
}

const isWindows = process.platform === 'win32'

function resolveAuthHeaders(): Record<string, string> {
const apiKey = process.env.ANTHROPIC_API_KEY
if (apiKey) {
return { 'x-api-key': apiKey }
}

const oauthTokens = getClaudeAIOAuthTokens()
if (oauthTokens?.accessToken) {
return { Authorization: `Bearer ${oauthTokens.accessToken}` }
}

return {}
}

function resolveUpstreamBaseUrl(): string {
return process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL
}

async function proxyFetch(
req: Request,
nonce: string | null,
): Promise<Response> {
if (nonce && req.headers.get('x-auth-nonce') !== nonce) {
return new Response('Forbidden', { status: 403 })
}

const upstreamBase = resolveUpstreamBaseUrl()
const url = new URL(req.url)
const upstreamUrl = `${upstreamBase}${url.pathname}${url.search}`

const authHeaders = resolveAuthHeaders()
if (Object.keys(authHeaders).length === 0) {
return new Response(
JSON.stringify({
error: 'No API credentials available on local machine',
}),
{ status: 401, headers: { 'content-type': 'application/json' } },
)
}

const forwardHeaders = new Headers(req.headers)
for (const [k, v] of Object.entries(authHeaders)) {
forwardHeaders.set(k, v)
}
forwardHeaders.delete('host')
forwardHeaders.delete('x-auth-nonce')

logForDebugging(
`[SSHAuthProxy] ${req.method} ${url.pathname} -> ${upstreamUrl}`,
)

try {
const upstreamRes = await fetch(upstreamUrl, {
method: req.method,
headers: forwardHeaders,
body: req.body,
// @ts-expect-error Bun supports duplex for streaming request bodies
duplex: 'half',
})

return new Response(upstreamRes.body, {
status: upstreamRes.status,
statusText: upstreamRes.statusText,
headers: upstreamRes.headers,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logForDebugging(`[SSHAuthProxy] upstream error: ${message}`)
return new Response(
JSON.stringify({ error: `Proxy upstream error: ${message}` }),
{ status: 502, headers: { 'content-type': 'application/json' } },
)
}
}

export async function createAuthProxy(): Promise<AuthProxyInfo> {
const id = randomUUID()

if (isWindows) {
return createTcpAuthProxy(id)
}
return createUnixSocketAuthProxy(id)
}

async function createUnixSocketAuthProxy(id: string): Promise<AuthProxyInfo> {
const socketPath = `/tmp/claude-ssh-auth-${id}.sock`

const server = Bun.serve({
unix: socketPath,
fetch: req => proxyFetch(req, null),
})

logForDebugging(`[SSHAuthProxy] listening on unix:${socketPath}`)

const proxy: SSHAuthProxy = {
stop() {
server.stop(true)
try {
unlinkSync(socketPath)
} catch {
// Socket file may already be cleaned up
}
},
}

return {
proxy,
localAddress: socketPath,
authEnv: { ANTHROPIC_AUTH_SOCKET: socketPath },
}
}

async function createTcpAuthProxy(id: string): Promise<AuthProxyInfo> {
const nonce = randomUUID()

const server = Bun.serve({
port: 0,
hostname: '127.0.0.1',
fetch: req => proxyFetch(req, nonce),
})

const port = server.port
logForDebugging(
`[SSHAuthProxy] listening on TCP 127.0.0.1:${port} (nonce-protected)`,
)

const proxy: SSHAuthProxy = {
stop() {
server.stop(true)
},
}

return {
proxy,
localAddress: `127.0.0.1:${port}`,
authEnv: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_NONCE: nonce,
},
}
}
123 changes: 123 additions & 0 deletions src/ssh/SSHDeploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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}`
}
99 changes: 99 additions & 0 deletions src/ssh/SSHProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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,
'command -v claude 2>/dev/null; claude --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,
}
}
Loading
Loading