Skip to content
Merged
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: 1 addition & 1 deletion backend/src/ipc/askpassHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 21 additions & 2 deletions backend/src/ipc/sshHostKeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,29 @@ export class SSHHostKeyHandler implements IPCHandler {
}
}

async autoAcceptHostKey(repoUrl: string): Promise<void> {
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<string> {
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) {
Expand Down
5 changes: 3 additions & 2 deletions backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -45,7 +45,8 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
gitAuthService,
repoUrl!,
branch,
useWorktree
useWorktree,
skipSSHVerification
)
}

Expand Down
11 changes: 6 additions & 5 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) => {
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<string, unknown> = {
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
Expand Down
6 changes: 1 addition & 5 deletions backend/src/routes/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 28 additions & 8 deletions backend/src/services/git-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -81,18 +82,19 @@ export class GitAuthService {
}
}

async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database): Promise<boolean> {
async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database, skipSSHVerification: boolean = false): Promise<boolean> {
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)
Expand All @@ -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
Expand All @@ -125,6 +137,14 @@ export class GitAuthService {
return this.sshHostKeyHandler.verifyHostKeyBeforeOperation(repoUrl)
}

async autoAcceptHostKey(repoUrl: string): Promise<void> {
if (!this.sshHostKeyHandler) {
logger.warn('SSH host key handler not initialized, skipping auto-accept')
return
}
await this.sshHostKeyHandler.autoAcceptHostKey(repoUrl)
}

async cleanupSSHKey(): Promise<void> {
if (this.sshKeyPath) {
await cleanupSSHKey(this.sshKeyPath)
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/git/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) : {}

Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/opencode-single-server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 7 additions & 5 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -302,13 +302,15 @@ export async function cloneRepo(
gitAuthService: GitAuthService,
repoUrl: string,
branch?: string,
useWorktree: boolean = false
useWorktree: boolean = false,
skipSSHVerification: boolean = false
): Promise<Repo> {
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
Expand Down
28 changes: 17 additions & 11 deletions backend/src/utils/git-auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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@([^:]+):/)
Expand Down
23 changes: 6 additions & 17 deletions backend/test/integration/ssh-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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')
}))

Expand Down Expand Up @@ -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([])
Expand Down
27 changes: 3 additions & 24 deletions backend/test/ipc/sshHostKeyHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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'),
}))

Expand Down Expand Up @@ -54,7 +44,6 @@ describe('SSHHostKeyHandler', () => {

const uniqueId = crypto.randomUUID()
testWorkspacePath = `/tmp/test-workspace-${uniqueId}`
setupWorkspacePath(testWorkspacePath)

mockPrepare.mockReturnValue({
run: vi.fn(),
Expand Down Expand Up @@ -270,7 +259,7 @@ describe('SSHHostKeyHandler', () => {
})

const result = handler['getTrustedHost']('github.com')

expect(result).toBeNull()
})

Expand All @@ -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()
})
})
})
2 changes: 1 addition & 1 deletion backend/test/services/git-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
Loading