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..424b4c10 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 unknown 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/services/git/GitService.test.ts b/backend/test/services/git/GitService.test.ts index eb45f201..783853f1 100644 --- a/backend/test/services/git/GitService.test.ts +++ b/backend/test/services/git/GitService.test.ts @@ -625,6 +625,10 @@ describe('GitService', () => { const mockRepo = { id: 1, fullPath: '/path/to/repo', + localPath: '/path/to/repo', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 123456, } getRepoByIdMock.mockReturnValue(mockRepo) executeCommandMock.mockResolvedValue('Everything up-to-date') @@ -643,6 +647,10 @@ describe('GitService', () => { const mockRepo = { id: 1, fullPath: '/path/to/repo', + localPath: '/path/to/repo', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 123456, } getRepoByIdMock.mockReturnValue(mockRepo) executeCommandMock.mockResolvedValueOnce('main\n').mockResolvedValueOnce('') @@ -659,7 +667,14 @@ describe('GitService', () => { describe('fetch', () => { it('fetches from remote', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + localPath: '/path/to/repo', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 123456, + } getRepoByIdMock.mockReturnValue(mockRepo) executeCommandMock.mockResolvedValue('') @@ -671,7 +686,14 @@ describe('GitService', () => { describe('pull', () => { it('pulls from remote', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + localPath: '/path/to/repo', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 123456, + } getRepoByIdMock.mockReturnValue(mockRepo) executeCommandMock.mockResolvedValue('') 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 ( @@ -88,7 +103,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { setRepoUrl(e.target.value)} + onChange={(e) => handleRepoUrlChange(e.target.value)} disabled={mutation.isPending} className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500" /> @@ -136,7 +151,28 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { }

- + + {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. +

+
+
+ )} +