Skip to content

Commit 7341dce

Browse files
Add skip SSH host key verification and fix self-hosted server support (#121)
* Add SSH host key verification with automatic trust and SCP-style URL support * Fix TypeScript errors for SSH host key schema and test mocks
1 parent 677ce74 commit 7341dce

25 files changed

Lines changed: 204 additions & 124 deletions

backend/src/ipc/askpassHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'url'
33
import type { IPCServer, IPCHandler } from './ipcServer'
44
import type { Database } from 'bun:sqlite'
55
import { SettingsService } from '../services/settings'
6-
import type { GitCredential } from '../utils/git-auth'
6+
import type { GitCredential } from '@opencode-manager/shared'
77
import { logger } from '../utils/logger'
88

99
const __filename = fileURLToPath(import.meta.url)

backend/src/ipc/sshHostKeyHandler.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,29 @@ export class SSHHostKeyHandler implements IPCHandler {
9898
}
9999
}
100100

101+
async autoAcceptHostKey(repoUrl: string): Promise<void> {
102+
const { host, port } = parseSSHHost(repoUrl)
103+
const hostPort = normalizeHostPort(host, port)
104+
105+
const trustedHost = this.getTrustedHost(hostPort)
106+
if (trustedHost) {
107+
logger.info(`Host ${hostPort} already trusted, skipping auto-accept`)
108+
return
109+
}
110+
111+
const publicKey = await this.fetchHostPublicKey(host, port)
112+
await this.addToKnownHosts(hostPort, publicKey)
113+
this.saveTrustedHost(hostPort, publicKey)
114+
logger.info(`Auto-accepted SSH host key for ${hostPort}`)
115+
}
116+
101117
private async fetchHostPublicKey(host: string, port?: string): Promise<string> {
102118
const portArgs = port ? ['-p', port] : []
103-
const output = await executeCommand(['ssh-keyscan', '-t', 'ed25519,rsa,ecdsa', ...portArgs, host], { silent: true })
104-
119+
const result = await executeCommand(
120+
['ssh-keyscan', '-t', 'ed25519,rsa,ecdsa', ...portArgs, host],
121+
{ silent: true, ignoreExitCode: true }
122+
)
123+
const output = (result as unknown as { stdout: string }).stdout
105124
const bracketedHost = port && port !== '22' ? `[${host}]:${port}` : host
106125
const lines = output.trim().split('\n')
107126
for (const line of lines) {

backend/src/routes/repos.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
2323
app.post('/', async (c) => {
2424
try {
2525
const body = await c.req.json()
26-
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, provider } = body
26+
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider } = body
2727

2828
if (!repoUrl && !localPath) {
2929
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
@@ -45,7 +45,8 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
4545
gitAuthService,
4646
repoUrl!,
4747
branch,
48-
useWorktree
48+
useWorktree,
49+
skipSSHVerification
4950
)
5051
}
5152

backend/src/routes/settings.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
UserPreferencesSchema,
1313
OpenCodeConfigSchema,
1414
} from '../types/settings'
15+
import type { GitCredential } from '@opencode-manager/shared'
1516
import { logger } from '../utils/logger'
1617
import { opencodeServerManager } from '../services/opencode-single-server'
1718
import { DEFAULT_AGENTS_MD } from '../constants'
@@ -161,18 +162,18 @@ export function createSettingsRoutes(db: Database) {
161162

162163
if (validated.preferences.gitCredentials) {
163164
const validations = await Promise.all(
164-
validated.preferences.gitCredentials.map(async (cred: Record<string, unknown>) => {
165+
validated.preferences.gitCredentials.map(async (cred: GitCredential) => {
165166
if (cred.type === 'ssh' && cred.sshPrivateKey) {
166-
const validation = await validateSSHPrivateKey(cred.sshPrivateKey as string)
167+
const validation = await validateSSHPrivateKey(cred.sshPrivateKey)
167168
if (!validation.valid) {
168169
throw new Error(`Invalid SSH key for credential '${cred.name}': ${validation.error}`)
169170
}
170171

171-
const result: Record<string, unknown> = {
172+
const result: GitCredential = {
172173
...cred,
173-
sshPrivateKeyEncrypted: encryptSecret(cred.sshPrivateKey as string),
174+
sshPrivateKeyEncrypted: encryptSecret(cred.sshPrivateKey),
174175
hasPassphrase: validation.hasPassphrase,
175-
passphrase: cred.passphrase ? encryptSecret(cred.passphrase as string) : undefined,
176+
passphrase: cred.passphrase ? encryptSecret(cred.passphrase) : undefined,
176177
}
177178
delete result.sshPrivateKey
178179
return result

backend/src/routes/ssh.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { Hono } from 'hono'
22
import { z } from 'zod'
33
import { logger } from '../utils/logger'
44
import { GitAuthService } from '../services/git-auth'
5-
6-
const SSHHostKeyResponseSchema = z.object({
7-
requestId: z.string(),
8-
response: z.enum(['accept', 'reject'])
9-
})
5+
import { SSHHostKeyResponseSchema } from '@opencode-manager/shared'
106

117
interface SSHHostKeyResponse {
128
success: boolean

backend/src/services/git-auth.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { AskpassHandler } from '../ipc/askpassHandler'
44
import { SSHHostKeyHandler } from '../ipc/sshHostKeyHandler'
55
import { writeTemporarySSHKey, buildSSHCommand, buildSSHCommandWithKnownHosts, cleanupSSHKey, parseSSHHost } from '../utils/ssh-key-manager'
66
import { decryptSecret } from '../utils/crypto'
7-
import { isSSHUrl, extractHostFromSSHUrl, getSSHCredentialsForHost, type GitCredential } from '../utils/git-auth'
7+
import { isSSHUrl, normalizeSSHUrl, extractHostFromSSHUrl, getSSHCredentialsForHost } from '../utils/git-auth'
8+
import type { GitCredential } from '@opencode-manager/shared'
89
import { logger } from '../utils/logger'
910
import { SettingsService } from './settings'
1011

@@ -81,18 +82,19 @@ export class GitAuthService {
8182
}
8283
}
8384

84-
async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database): Promise<boolean> {
85+
async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database, skipSSHVerification: boolean = false): Promise<boolean> {
8586
if (!repoUrl || !isSSHUrl(repoUrl)) {
8687
return false
8788
}
8889

89-
const sshHost = extractHostFromSSHUrl(repoUrl)
90+
const normalizedUrl = normalizeSSHUrl(repoUrl)
91+
const sshHost = extractHostFromSSHUrl(normalizedUrl)
9092
if (!sshHost) {
9193
logger.warn(`Could not extract SSH host from URL: ${repoUrl}`)
9294
return false
9395
}
9496

95-
const { port } = parseSSHHost(repoUrl)
97+
const { port } = parseSSHHost(normalizedUrl)
9698
this.setSSHPort(port && port !== '22' ? port : null)
9799

98100
const settingsService = new SettingsService(database)
@@ -109,10 +111,20 @@ export class GitAuthService {
109111
}
110112
}
111113

112-
const verified = await this.verifyHostKeyBeforeOperation(repoUrl)
113-
if (!verified) {
114-
await this.cleanupSSHKey()
115-
throw new Error('SSH host key verification failed or was rejected by user')
114+
if (skipSSHVerification) {
115+
logger.info(`Skipping SSH host key verification for ${sshHost} (user requested)`)
116+
try {
117+
await this.autoAcceptHostKey(normalizedUrl)
118+
} catch (error) {
119+
await this.cleanupSSHKey()
120+
throw new Error(`Failed to auto-accept SSH host key for ${sshHost}: ${(error as Error).message}`)
121+
}
122+
} else {
123+
const verified = await this.verifyHostKeyBeforeOperation(normalizedUrl)
124+
if (!verified) {
125+
await this.cleanupSSHKey()
126+
throw new Error('SSH host key verification failed or was rejected by user')
127+
}
116128
}
117129

118130
return sshCredentials.length > 0
@@ -125,6 +137,14 @@ export class GitAuthService {
125137
return this.sshHostKeyHandler.verifyHostKeyBeforeOperation(repoUrl)
126138
}
127139

140+
async autoAcceptHostKey(repoUrl: string): Promise<void> {
141+
if (!this.sshHostKeyHandler) {
142+
logger.warn('SSH host key handler not initialized, skipping auto-accept')
143+
return
144+
}
145+
await this.sshHostKeyHandler.autoAcceptHostKey(repoUrl)
146+
}
147+
128148
async cleanupSSHKey(): Promise<void> {
129149
if (this.sshKeyPath) {
130150
await cleanupSSHKey(this.sshKeyPath)

backend/src/services/git/GitService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-err
88
import { SettingsService } from '../settings'
99
import type { Database } from 'bun:sqlite'
1010
import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType } from '../../types/git'
11+
import type { GitCredential } from '@opencode-manager/shared'
1112
import path from 'path'
1213

1314
export class GitService {
@@ -198,7 +199,7 @@ export class GitService {
198199
const authEnv = this.gitAuthService.getGitEnvironment()
199200

200201
const settings = this.settingsService.getSettings('default')
201-
const gitCredentials = (settings.preferences.gitCredentials || []) as import('../../utils/git-auth').GitCredential[]
202+
const gitCredentials = (settings.preferences.gitCredentials || []) as GitCredential[]
202203
const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials)
203204
const identityEnv = identity ? createGitIdentityEnv(identity) : {}
204205

backend/src/services/opencode-single-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { spawn, execSync } from 'child_process'
22
import path from 'path'
33
import { logger } from '../utils/logger'
4-
import { createGitEnv, createGitIdentityEnv, resolveGitIdentity, type GitCredential } from '../utils/git-auth'
4+
import { createGitEnv, createGitIdentityEnv, resolveGitIdentity } from '../utils/git-auth'
5+
import type { GitCredential } from '@opencode-manager/shared'
56
import {
67
buildSSHCommandWithKnownHosts,
78
buildSSHCommandWithConfig,

backend/src/services/repo.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Repo, CreateRepoInput } from '../types/repo'
66
import { logger } from '../utils/logger'
77
import { getReposPath } from '@opencode-manager/shared/config/env'
88
import type { GitAuthService } from './git-auth'
9-
import { isGitHubHttpsUrl, isSSHUrl } from '../utils/git-auth'
9+
import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
1010
import path from 'path'
1111
import { parseSSHHost } from '../utils/ssh-key-manager'
1212

@@ -302,13 +302,15 @@ export async function cloneRepo(
302302
gitAuthService: GitAuthService,
303303
repoUrl: string,
304304
branch?: string,
305-
useWorktree: boolean = false
305+
useWorktree: boolean = false,
306+
skipSSHVerification: boolean = false
306307
): Promise<Repo> {
307-
const isSSH = isSSHUrl(repoUrl)
308+
const effectiveUrl = normalizeSSHUrl(repoUrl)
309+
const isSSH = isSSHUrl(effectiveUrl)
308310
const preserveSSH = isSSH
309-
const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(repoUrl, database)
311+
const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
310312

311-
const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(repoUrl, preserveSSH)
313+
const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(effectiveUrl, preserveSSH)
312314
const baseRepoDirName = repoName
313315
const worktreeDirName = branch && useWorktree ? `${repoName}-${branch.replace(/[\\/]/g, '-')}` : repoName
314316
const localPath = worktreeDirName

backend/src/utils/git-auth.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
export interface GitCredential {
2-
name: string
3-
host: string
4-
type: 'pat' | 'ssh'
5-
token?: string
6-
sshPrivateKey?: string
7-
sshPrivateKeyEncrypted?: string
8-
hasPassphrase?: boolean
9-
username?: string
10-
passphrase?: string
11-
}
1+
import type { GitCredential } from '@opencode-manager/shared'
122

133
export function isGitHubHttpsUrl(repoUrl: string): boolean {
144
try {
@@ -40,6 +30,22 @@ export function isSSHUrl(url: string): boolean {
4030
return url.startsWith('git@') || url.startsWith('ssh://')
4131
}
4232

33+
export function normalizeSSHUrl(url: string): string {
34+
if (url.startsWith('ssh://')) {
35+
return url
36+
}
37+
38+
const match = url.match(/^git@([^:]+):(\d{1,5})\/(.+)$/)
39+
if (match) {
40+
const [, host, port, path] = match
41+
const portNum = parseInt(port!, 10)
42+
if (portNum > 0 && portNum <= 65535) {
43+
return `ssh://git@${host}:${port}/${path}`
44+
}
45+
}
46+
return url
47+
}
48+
4349
export function extractHostFromSSHUrl(url: string): string | null {
4450
if (url.startsWith('git@')) {
4551
const match = url.match(/^git@([^:]+):/)

0 commit comments

Comments
 (0)