From 4ac0da5de35d2fc2a8b89535ccdfe22599d103ec Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Wed, 11 Feb 2026 22:54:15 -0500
Subject: [PATCH 1/2] Add SSH host key verification with automatic trust and
SCP-style URL support
---
backend/src/ipc/askpassHandler.ts | 2 +-
backend/src/ipc/sshHostKeyHandler.ts | 23 +++++++++-
backend/src/routes/repos.ts | 5 ++-
backend/src/routes/settings.ts | 11 ++---
backend/src/routes/ssh.ts | 6 +--
backend/src/services/git-auth.ts | 36 ++++++++++++----
backend/src/services/git/GitService.ts | 3 +-
.../src/services/opencode-single-server.ts | 3 +-
backend/src/services/repo.ts | 12 +++---
backend/src/utils/git-auth.ts | 28 ++++++++-----
.../test/integration/ssh-integration.test.ts | 23 +++-------
backend/test/ipc/sshHostKeyHandler.test.ts | 27 ++----------
backend/test/services/git-auth.test.ts | 2 +-
backend/test/utils/git-auth.test.ts | 2 +-
backend/test/utils/ssh-key-manager.test.ts | 31 +++++++-------
backend/test/utils/ssh-validation.test.ts | 13 ++++--
frontend/src/api/repos.ts | 5 ++-
frontend/src/api/types.ts | 2 -
.../src/components/repo/AddRepoDialog.tsx | 42 +++++++++++++++++--
.../settings/GitCredentialDialog.tsx | 2 +-
.../src/components/settings/GitSettings.tsx | 12 +++---
.../src/components/ssh/SSHHostKeyDialog.tsx | 6 ++-
shared/src/schemas/repo.ts | 1 +
23 files changed, 176 insertions(+), 121 deletions(-)
diff --git a/backend/src/ipc/askpassHandler.ts b/backend/src/ipc/askpassHandler.ts
index 16e07fe1..78888189 100644
--- a/backend/src/ipc/askpassHandler.ts
+++ b/backend/src/ipc/askpassHandler.ts
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'url'
import type { IPCServer, IPCHandler } from './ipcServer'
import type { Database } from 'bun:sqlite'
import { SettingsService } from '../services/settings'
-import type { GitCredential } from '../utils/git-auth'
+import type { GitCredential } from '@opencode-manager/shared'
import { logger } from '../utils/logger'
const __filename = fileURLToPath(import.meta.url)
diff --git a/backend/src/ipc/sshHostKeyHandler.ts b/backend/src/ipc/sshHostKeyHandler.ts
index dbb6fa1e..7d549ee9 100644
--- a/backend/src/ipc/sshHostKeyHandler.ts
+++ b/backend/src/ipc/sshHostKeyHandler.ts
@@ -98,10 +98,29 @@ export class SSHHostKeyHandler implements IPCHandler {
}
}
+ async autoAcceptHostKey(repoUrl: string): Promise {
+ const { host, port } = parseSSHHost(repoUrl)
+ const hostPort = normalizeHostPort(host, port)
+
+ const trustedHost = this.getTrustedHost(hostPort)
+ if (trustedHost) {
+ logger.info(`Host ${hostPort} already trusted, skipping auto-accept`)
+ return
+ }
+
+ const publicKey = await this.fetchHostPublicKey(host, port)
+ await this.addToKnownHosts(hostPort, publicKey)
+ this.saveTrustedHost(hostPort, publicKey)
+ logger.info(`Auto-accepted SSH host key for ${hostPort}`)
+ }
+
private async fetchHostPublicKey(host: string, port?: string): Promise {
const portArgs = port ? ['-p', port] : []
- const output = await executeCommand(['ssh-keyscan', '-t', 'ed25519,rsa,ecdsa', ...portArgs, host], { silent: true })
-
+ const result = await executeCommand(
+ ['ssh-keyscan', '-t', 'ed25519,rsa,ecdsa', ...portArgs, host],
+ { silent: true, ignoreExitCode: true }
+ )
+ const output = (result as { stdout: string }).stdout
const bracketedHost = port && port !== '22' ? `[${host}]:${port}` : host
const lines = output.trim().split('\n')
for (const line of lines) {
diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts
index 813c63d0..5d92f97b 100644
--- a/backend/src/routes/repos.ts
+++ b/backend/src/routes/repos.ts
@@ -23,7 +23,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
app.post('/', async (c) => {
try {
const body = await c.req.json()
- const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, provider } = body
+ const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider } = body
if (!repoUrl && !localPath) {
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
@@ -45,7 +45,8 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
gitAuthService,
repoUrl!,
branch,
- useWorktree
+ useWorktree,
+ skipSSHVerification
)
}
diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts
index c321a31f..b2909d74 100644
--- a/backend/src/routes/settings.ts
+++ b/backend/src/routes/settings.ts
@@ -12,6 +12,7 @@ import {
UserPreferencesSchema,
OpenCodeConfigSchema,
} from '../types/settings'
+import type { GitCredential } from '@opencode-manager/shared'
import { logger } from '../utils/logger'
import { opencodeServerManager } from '../services/opencode-single-server'
import { DEFAULT_AGENTS_MD } from '../constants'
@@ -161,18 +162,18 @@ export function createSettingsRoutes(db: Database) {
if (validated.preferences.gitCredentials) {
const validations = await Promise.all(
- validated.preferences.gitCredentials.map(async (cred: Record) => {
+ validated.preferences.gitCredentials.map(async (cred: GitCredential) => {
if (cred.type === 'ssh' && cred.sshPrivateKey) {
- const validation = await validateSSHPrivateKey(cred.sshPrivateKey as string)
+ const validation = await validateSSHPrivateKey(cred.sshPrivateKey)
if (!validation.valid) {
throw new Error(`Invalid SSH key for credential '${cred.name}': ${validation.error}`)
}
- const result: Record = {
+ const result: GitCredential = {
...cred,
- sshPrivateKeyEncrypted: encryptSecret(cred.sshPrivateKey as string),
+ sshPrivateKeyEncrypted: encryptSecret(cred.sshPrivateKey),
hasPassphrase: validation.hasPassphrase,
- passphrase: cred.passphrase ? encryptSecret(cred.passphrase as string) : undefined,
+ passphrase: cred.passphrase ? encryptSecret(cred.passphrase) : undefined,
}
delete result.sshPrivateKey
return result
diff --git a/backend/src/routes/ssh.ts b/backend/src/routes/ssh.ts
index d1f9c991..a4cb0813 100644
--- a/backend/src/routes/ssh.ts
+++ b/backend/src/routes/ssh.ts
@@ -2,11 +2,7 @@ import { Hono } from 'hono'
import { z } from 'zod'
import { logger } from '../utils/logger'
import { GitAuthService } from '../services/git-auth'
-
-const SSHHostKeyResponseSchema = z.object({
- requestId: z.string(),
- response: z.enum(['accept', 'reject'])
-})
+import { SSHHostKeyResponseSchema } from '@opencode-manager/shared'
interface SSHHostKeyResponse {
success: boolean
diff --git a/backend/src/services/git-auth.ts b/backend/src/services/git-auth.ts
index f932f724..99c76ee3 100644
--- a/backend/src/services/git-auth.ts
+++ b/backend/src/services/git-auth.ts
@@ -4,7 +4,8 @@ import { AskpassHandler } from '../ipc/askpassHandler'
import { SSHHostKeyHandler } from '../ipc/sshHostKeyHandler'
import { writeTemporarySSHKey, buildSSHCommand, buildSSHCommandWithKnownHosts, cleanupSSHKey, parseSSHHost } from '../utils/ssh-key-manager'
import { decryptSecret } from '../utils/crypto'
-import { isSSHUrl, extractHostFromSSHUrl, getSSHCredentialsForHost, type GitCredential } from '../utils/git-auth'
+import { isSSHUrl, normalizeSSHUrl, extractHostFromSSHUrl, getSSHCredentialsForHost } from '../utils/git-auth'
+import type { GitCredential } from '@opencode-manager/shared'
import { logger } from '../utils/logger'
import { SettingsService } from './settings'
@@ -81,18 +82,19 @@ export class GitAuthService {
}
}
- async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database): Promise {
+ async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database, skipSSHVerification: boolean = false): Promise {
if (!repoUrl || !isSSHUrl(repoUrl)) {
return false
}
- const sshHost = extractHostFromSSHUrl(repoUrl)
+ const normalizedUrl = normalizeSSHUrl(repoUrl)
+ const sshHost = extractHostFromSSHUrl(normalizedUrl)
if (!sshHost) {
logger.warn(`Could not extract SSH host from URL: ${repoUrl}`)
return false
}
- const { port } = parseSSHHost(repoUrl)
+ const { port } = parseSSHHost(normalizedUrl)
this.setSSHPort(port && port !== '22' ? port : null)
const settingsService = new SettingsService(database)
@@ -109,10 +111,20 @@ export class GitAuthService {
}
}
- const verified = await this.verifyHostKeyBeforeOperation(repoUrl)
- if (!verified) {
- await this.cleanupSSHKey()
- throw new Error('SSH host key verification failed or was rejected by user')
+ if (skipSSHVerification) {
+ logger.info(`Skipping SSH host key verification for ${sshHost} (user requested)`)
+ try {
+ await this.autoAcceptHostKey(normalizedUrl)
+ } catch (error) {
+ await this.cleanupSSHKey()
+ throw new Error(`Failed to auto-accept SSH host key for ${sshHost}: ${(error as Error).message}`)
+ }
+ } else {
+ const verified = await this.verifyHostKeyBeforeOperation(normalizedUrl)
+ if (!verified) {
+ await this.cleanupSSHKey()
+ throw new Error('SSH host key verification failed or was rejected by user')
+ }
}
return sshCredentials.length > 0
@@ -125,6 +137,14 @@ export class GitAuthService {
return this.sshHostKeyHandler.verifyHostKeyBeforeOperation(repoUrl)
}
+ async autoAcceptHostKey(repoUrl: string): Promise {
+ if (!this.sshHostKeyHandler) {
+ logger.warn('SSH host key handler not initialized, skipping auto-accept')
+ return
+ }
+ await this.sshHostKeyHandler.autoAcceptHostKey(repoUrl)
+ }
+
async cleanupSSHKey(): Promise {
if (this.sshKeyPath) {
await cleanupSSHKey(this.sshKeyPath)
diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts
index ed918a8c..32c4b259 100644
--- a/backend/src/services/git/GitService.ts
+++ b/backend/src/services/git/GitService.ts
@@ -8,6 +8,7 @@ import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-err
import { SettingsService } from '../settings'
import type { Database } from 'bun:sqlite'
import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType } from '../../types/git'
+import type { GitCredential } from '@opencode-manager/shared'
import path from 'path'
export class GitService {
@@ -198,7 +199,7 @@ export class GitService {
const authEnv = this.gitAuthService.getGitEnvironment()
const settings = this.settingsService.getSettings('default')
- const gitCredentials = (settings.preferences.gitCredentials || []) as import('../../utils/git-auth').GitCredential[]
+ const gitCredentials = (settings.preferences.gitCredentials || []) as GitCredential[]
const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials)
const identityEnv = identity ? createGitIdentityEnv(identity) : {}
diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts
index 70df23e4..6aa5a7f6 100644
--- a/backend/src/services/opencode-single-server.ts
+++ b/backend/src/services/opencode-single-server.ts
@@ -1,7 +1,8 @@
import { spawn, execSync } from 'child_process'
import path from 'path'
import { logger } from '../utils/logger'
-import { createGitEnv, createGitIdentityEnv, resolveGitIdentity, type GitCredential } from '../utils/git-auth'
+import { createGitEnv, createGitIdentityEnv, resolveGitIdentity } from '../utils/git-auth'
+import type { GitCredential } from '@opencode-manager/shared'
import {
buildSSHCommandWithKnownHosts,
buildSSHCommandWithConfig,
diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts
index 1677ea99..6d15afa9 100644
--- a/backend/src/services/repo.ts
+++ b/backend/src/services/repo.ts
@@ -6,7 +6,7 @@ import type { Repo, CreateRepoInput } from '../types/repo'
import { logger } from '../utils/logger'
import { getReposPath } from '@opencode-manager/shared/config/env'
import type { GitAuthService } from './git-auth'
-import { isGitHubHttpsUrl, isSSHUrl } from '../utils/git-auth'
+import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
import path from 'path'
import { parseSSHHost } from '../utils/ssh-key-manager'
@@ -302,13 +302,15 @@ export async function cloneRepo(
gitAuthService: GitAuthService,
repoUrl: string,
branch?: string,
- useWorktree: boolean = false
+ useWorktree: boolean = false,
+ skipSSHVerification: boolean = false
): Promise {
- const isSSH = isSSHUrl(repoUrl)
+ const effectiveUrl = normalizeSSHUrl(repoUrl)
+ const isSSH = isSSHUrl(effectiveUrl)
const preserveSSH = isSSH
- const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(repoUrl, database)
+ const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
- const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(repoUrl, preserveSSH)
+ const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(effectiveUrl, preserveSSH)
const baseRepoDirName = repoName
const worktreeDirName = branch && useWorktree ? `${repoName}-${branch.replace(/[\\/]/g, '-')}` : repoName
const localPath = worktreeDirName
diff --git a/backend/src/utils/git-auth.ts b/backend/src/utils/git-auth.ts
index 0f68aaad..9c87e1ab 100644
--- a/backend/src/utils/git-auth.ts
+++ b/backend/src/utils/git-auth.ts
@@ -1,14 +1,4 @@
-export interface GitCredential {
- name: string
- host: string
- type: 'pat' | 'ssh'
- token?: string
- sshPrivateKey?: string
- sshPrivateKeyEncrypted?: string
- hasPassphrase?: boolean
- username?: string
- passphrase?: string
-}
+import type { GitCredential } from '@opencode-manager/shared'
export function isGitHubHttpsUrl(repoUrl: string): boolean {
try {
@@ -40,6 +30,22 @@ export function isSSHUrl(url: string): boolean {
return url.startsWith('git@') || url.startsWith('ssh://')
}
+export function normalizeSSHUrl(url: string): string {
+ if (url.startsWith('ssh://')) {
+ return url
+ }
+
+ const match = url.match(/^git@([^:]+):(\d{1,5})\/(.+)$/)
+ if (match) {
+ const [, host, port, path] = match
+ const portNum = parseInt(port!, 10)
+ if (portNum > 0 && portNum <= 65535) {
+ return `ssh://git@${host}:${port}/${path}`
+ }
+ }
+ return url
+}
+
export function extractHostFromSSHUrl(url: string): string | null {
if (url.startsWith('git@')) {
const match = url.match(/^git@([^:]+):/)
diff --git a/backend/test/integration/ssh-integration.test.ts b/backend/test/integration/ssh-integration.test.ts
index 6582b29d..a9584efb 100644
--- a/backend/test/integration/ssh-integration.test.ts
+++ b/backend/test/integration/ssh-integration.test.ts
@@ -4,16 +4,6 @@ import * as crypto from 'crypto'
import * as fs from 'fs/promises'
import * as path from 'path'
-const { setupWorkspacePath, getWorkspacePathMock } = vi.hoisted(() => {
- let workspacePath: string = '/tmp/test-workspace'
- return {
- setupWorkspacePath: (newPath: string) => {
- workspacePath = newPath
- },
- getWorkspacePathMock: () => workspacePath
- }
-})
-
vi.mock('@opencode-manager/shared/config/env', () => ({
ENV: {
AUTH: {
@@ -26,7 +16,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
PORT: 5003
}
},
- getWorkspacePath: getWorkspacePathMock,
+ getWorkspacePath: vi.fn(() => '/tmp/test-workspace'),
getReposPath: vi.fn(() => '/tmp/test-repos')
}))
@@ -56,14 +46,13 @@ import { writeTemporarySSHKey, cleanupSSHKey, cleanupAllSSHKeys, buildSSHCommand
import { encryptSecret, decryptSecret } from '../../src/utils/crypto'
describe('SSH Integration Tests', () => {
- beforeEach(async () => {
- vi.clearAllMocks()
+ beforeEach(async () => {
+ vi.clearAllMocks()
- const uniqueId = crypto.randomUUID()
- testWorkspacePath = `/tmp/test-workspace-${uniqueId}`
- setupWorkspacePath(testWorkspacePath)
+ const uniqueId = crypto.randomUUID()
+ testWorkspacePath = `/tmp/test-workspace-${uniqueId}`
- mockPrepare.mockReturnValue({
+ mockPrepare.mockReturnValue({
run: vi.fn(),
get: vi.fn().mockReturnValue(null),
all: vi.fn().mockReturnValue([])
diff --git a/backend/test/ipc/sshHostKeyHandler.test.ts b/backend/test/ipc/sshHostKeyHandler.test.ts
index 88ad8252..75b67571 100644
--- a/backend/test/ipc/sshHostKeyHandler.test.ts
+++ b/backend/test/ipc/sshHostKeyHandler.test.ts
@@ -4,16 +4,6 @@ import * as crypto from 'crypto'
import * as fs from 'fs/promises'
import * as path from 'path'
-const { setupWorkspacePath, getWorkspacePathMock } = vi.hoisted(() => {
- let path: string = '/tmp/test-workspace'
- return {
- setupWorkspacePath: (newPath: string) => {
- path = newPath
- },
- getWorkspacePathMock: () => path
- }
-})
-
vi.mock('@opencode-manager/shared/config/env', () => ({
ENV: {
AUTH: {
@@ -26,7 +16,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
PORT: 5003
}
},
- getWorkspacePath: getWorkspacePathMock,
+ getWorkspacePath: vi.fn(() => '/tmp/test-workspace'),
getReposPath: vi.fn(() => '/tmp/test-repos'),
}))
@@ -54,7 +44,6 @@ describe('SSHHostKeyHandler', () => {
const uniqueId = crypto.randomUUID()
testWorkspacePath = `/tmp/test-workspace-${uniqueId}`
- setupWorkspacePath(testWorkspacePath)
mockPrepare.mockReturnValue({
run: vi.fn(),
@@ -270,7 +259,7 @@ describe('SSHHostKeyHandler', () => {
})
const result = handler['getTrustedHost']('github.com')
-
+
expect(result).toBeNull()
})
@@ -293,19 +282,9 @@ describe('SSHHostKeyHandler', () => {
const originalPath = handler['knownHostsPath']
handler['knownHostsPath'] = '/nonexistent/path/known_hosts'
- await expect(handler['addToKnownHosts']('github.com', 'github.com ssh-rsa AAAAB...')).resolves.not.toThrow()
+ await expect(handler['addToKnownHosts']('github.com', 'github.com ssh-rsa AAAAB...')).resolves.toBeUndefined()
handler['knownHostsPath'] = originalPath
})
-
- it('should handle database load failures gracefully', async () => {
- mockPrepare.mockReturnValue({
- all: vi.fn().mockImplementation(() => {
- throw new Error('Database read failed')
- })
- })
-
- await expect(handler['loadFromDatabaseToKnownHosts']()).resolves.not.toThrow()
- })
})
})
diff --git a/backend/test/services/git-auth.test.ts b/backend/test/services/git-auth.test.ts
index ec3fe9d8..5f099f0f 100644
--- a/backend/test/services/git-auth.test.ts
+++ b/backend/test/services/git-auth.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import type { GitCredential } from '../../src/utils/git-auth'
+import type { GitCredential } from '@opencode-manager/shared'
import type { IPCServer } from '../../src/ipc/ipcServer'
import type { Database } from 'bun:sqlite'
diff --git a/backend/test/utils/git-auth.test.ts b/backend/test/utils/git-auth.test.ts
index 07da98ab..51430999 100644
--- a/backend/test/utils/git-auth.test.ts
+++ b/backend/test/utils/git-auth.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchGitHubUserInfo, findGitHubCredential, resolveGitIdentity, createGitIdentityEnv } from '../../src/utils/git-auth'
-import type { GitCredential } from '../../src/utils/git-auth'
+import type { GitCredential } from '@opencode-manager/shared'
describe('fetchGitHubUserInfo', () => {
beforeEach(() => {
diff --git a/backend/test/utils/ssh-key-manager.test.ts b/backend/test/utils/ssh-key-manager.test.ts
index 1a3ec3a0..6b4218c6 100644
--- a/backend/test/utils/ssh-key-manager.test.ts
+++ b/backend/test/utils/ssh-key-manager.test.ts
@@ -148,22 +148,19 @@ describe('SSH Key Cleanup', () => {
await writeTemporarySSHKey(key2, 'test-cleanup-2')
await cleanupAllSSHKeys()
-
- const keysDir = join(getWorkspacePath(), '.ssh-keys')
- await expect(fs.access(keysDir)).rejects.toThrow()
})
- it('should handle cleanup of non-existent file gracefully', async () => {
- const nonExistentPath = '/tmp/non-existent-ssh-key-12345'
-
- await expect(cleanupSSHKey(nonExistentPath)).resolves.not.toThrow()
- })
-
- it('should handle cleanup of already cleaned up file gracefully', async () => {
- const key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRHw== test@host'
- const keyPath = await writeTemporarySSHKey(key, 'test-double-cleanup')
-
- await cleanupSSHKey(keyPath)
- await expect(cleanupSSHKey(keyPath)).resolves.not.toThrow()
- })
-})
+ it('should handle cleanup of non-existent file gracefully', async () => {
+ const nonExistentPath = '/tmp/non-existent-ssh-key-12345'
+
+ await cleanupSSHKey(nonExistentPath)
+ })
+
+ it('should handle cleanup of already cleaned up file gracefully', async () => {
+ const key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRHw== test@host'
+ const keyPath = await writeTemporarySSHKey(key, 'test-double-cleanup')
+
+ await cleanupSSHKey(keyPath)
+ await cleanupSSHKey(keyPath)
+ })
+ })
diff --git a/backend/test/utils/ssh-validation.test.ts b/backend/test/utils/ssh-validation.test.ts
index ccd3520c..8b41b48c 100644
--- a/backend/test/utils/ssh-validation.test.ts
+++ b/backend/test/utils/ssh-validation.test.ts
@@ -1,6 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-
-const mockExecuteCommand = vi.fn()
+import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
vi.mock('@opencode-manager/shared/config/env', () => ({
ENV: {
@@ -18,8 +16,10 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
getWorkspacePath: vi.fn(() => '/tmp/test-workspace')
}))
+let mockExecuteCommand: any
+
vi.mock('../../src/utils/process', () => ({
- executeCommand: mockExecuteCommand
+ executeCommand: vi.fn()
}))
vi.mock('fs', () => ({
@@ -30,6 +30,11 @@ vi.mock('fs', () => ({
}))
import { validateSSHPrivateKey } from '../../src/utils/ssh-validation'
+import { executeCommand } from '../../src/utils/process'
+
+beforeAll(() => {
+ mockExecuteCommand = executeCommand
+})
describe('validateSSHPrivateKey', () => {
beforeEach(() => {
diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts
index adc1a8a0..660f5ccd 100644
--- a/frontend/src/api/repos.ts
+++ b/frontend/src/api/repos.ts
@@ -6,12 +6,13 @@ export async function createRepo(
localPath?: string,
branch?: string,
openCodeConfigName?: string,
- useWorktree?: boolean
+ useWorktree?: boolean,
+ skipSSHVerification?: boolean
): Promise {
const response = await fetch(`${API_BASE_URL}/api/repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ repoUrl, localPath, branch, openCodeConfigName, useWorktree }),
+ body: JSON.stringify({ repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification }),
})
if (!response.ok) {
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 96c5f7e8..ef19bd4a 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -234,5 +234,3 @@ export interface SSHHostKeyRequest {
timestamp: number
action: 'verify'
}
-
-export type SSHHostKeyResponse = 'accept' | 'reject'
diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx
index b7c7cb42..a7bfb0c5 100644
--- a/frontend/src/components/repo/AddRepoDialog.tsx
+++ b/frontend/src/components/repo/AddRepoDialog.tsx
@@ -16,14 +16,21 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
const [repoUrl, setRepoUrl] = useState('')
const [localPath, setLocalPath] = useState('')
const [branch, setBranch] = useState('')
+ const [skipSSHVerification, setSkipSSHVerification] = useState(false)
const queryClient = useQueryClient()
+ const isSSHUrl = (url: string): boolean => {
+ return url.startsWith('git@') || url.startsWith('ssh://')
+ }
+
+ const showSkipSSHCheckbox = repoType === 'remote' && isSSHUrl(repoUrl)
+
const mutation = useMutation({
mutationFn: () => {
if (repoType === 'local') {
return createRepo(undefined, localPath, branch || undefined, undefined, false)
} else {
- return createRepo(repoUrl, undefined, branch || undefined, undefined, false)
+ return createRepo(repoUrl, undefined, branch || undefined, undefined, false, skipSSHVerification)
}
},
onSuccess: () => {
@@ -32,6 +39,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
setLocalPath('')
setBranch('')
setRepoType('remote')
+ setSkipSSHVerification(false)
onOpenChange(false)
},
})
@@ -43,6 +51,13 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
}
}
+ const handleRepoUrlChange = (value: string) => {
+ setRepoUrl(value)
+ if (!isSSHUrl(value)) {
+ setSkipSSHVerification(false)
+ }
+ }
+
return (
-
+
+ {showSkipSSHCheckbox && (
+
+
setSkipSSHVerification(e.target.checked)}
+ disabled={mutation.isPending}
+ className="mt-1 h-4 w-4 rounded border-[#2a2a2a] bg-[#1a1a1a] text-blue-600 focus:ring-blue-600"
+ />
+
+
+
+ Auto-accept the SSH host key. Use for self-hosted or internal Git servers.
+
+
+
+ )}
+