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
26 changes: 26 additions & 0 deletions backend/src/db/migrations/202610181200-opencode-servers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
id: '202610181200-opencode-servers',

up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS opencode_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
username TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
},

down(db) {
db.run('DROP TABLE IF EXISTS opencode_servers')
db.run("DELETE FROM app_secrets WHERE key LIKE 'opencode_server:%:password'")
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import migration202610081200 from './202610081200-change-walkthrough-sources'
import migration202610141200 from './202610141200-session-sandbox-modes'
import migration202610151200 from './202610151200-change-walkthrough-sessions'
import migration202610161200 from './202610161200-change-walkthrough-execution'
import migration202610181200 from './202610181200-opencode-servers'

export const allMigrations: Migration[] = [
migration001,
Expand Down Expand Up @@ -69,4 +70,5 @@ export const allMigrations: Migration[] = [
migration202610141200,
migration202610151200,
migration202610161200,
migration202610181200,
]
161 changes: 161 additions & 0 deletions backend/src/db/opencode-servers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { randomBytes } from 'node:crypto'
import type { Database } from 'bun:sqlite'
import type { OpenCodeServer } from '@opencode-manager/shared/schemas'
import { trimUrlTrailingSlashes } from '@opencode-manager/shared/utils'
import { deleteSecret, readEncryptedSecret, writeEncryptedSecret } from './secrets'

interface OpenCodeServerRow {
id: string
name: string
base_url: string
username: string
enabled: number
created_at: number
updated_at: number
has_password: number
}

export interface OpenCodeServerInsert {
name: string
baseUrl: string
username: string
enabled: boolean
password?: string | null
}

export interface OpenCodeServerUpdate {
name?: string
baseUrl?: string
username?: string
enabled?: boolean
password?: string | null
}

const ROW_COLUMNS = 'id, name, base_url, username, enabled, created_at, updated_at'

const PASSWORD_SECRET_KEY_PREFIX = 'opencode_server:'
const PASSWORD_SECRET_KEY_SUFFIX = ':password'

const SELECT_SERVER = `
SELECT s.id, s.name, s.base_url, s.username, s.enabled, s.created_at, s.updated_at,
CASE WHEN k.key IS NULL THEN 0 ELSE 1 END AS has_password
FROM opencode_servers s
LEFT JOIN app_secrets k ON k.key = '${PASSWORD_SECRET_KEY_PREFIX}' || s.id || '${PASSWORD_SECRET_KEY_SUFFIX}'
`

function passwordSecretKey(serverId: string): string {
return `${PASSWORD_SECRET_KEY_PREFIX}${serverId}${PASSWORD_SECRET_KEY_SUFFIX}`
}

function mapRow(row: OpenCodeServerRow): OpenCodeServer {
return {
id: row.id,
name: row.name,
baseUrl: row.base_url,
username: row.username,
enabled: row.enabled === 1,
hasPassword: row.has_password === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}

export function listOpenCodeServers(db: Database): OpenCodeServer[] {
const rows = db
.prepare(`${SELECT_SERVER} ORDER BY s.name COLLATE NOCASE, s.created_at`)
.all() as OpenCodeServerRow[]
return rows.map(mapRow)
}

export function getOpenCodeServer(db: Database, serverId: string): OpenCodeServer | null {
const row = db
.prepare(`${SELECT_SERVER} WHERE s.id = ?`)
.get(serverId) as OpenCodeServerRow | undefined
return row ? mapRow(row) : null
}

export function readOpenCodeServerPassword(db: Database, serverId: string): string | null {
return readEncryptedSecret(db, passwordSecretKey(serverId))
}

export function insertOpenCodeServer(db: Database, input: OpenCodeServerInsert): OpenCodeServer {
const id = `srv_${randomBytes(18).toString('base64url')}`
const now = Date.now()

const insert = db.transaction(() => {
db.prepare(`INSERT INTO opencode_servers (${ROW_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
id,
input.name,
trimUrlTrailingSlashes(input.baseUrl),
input.username,
input.enabled ? 1 : 0,
now,
now,
)
if (input.password) {
writeEncryptedSecret(db, passwordSecretKey(id), input.password)
}
return getOpenCodeServer(db, id)
})

const server = insert()
if (!server) {
throw new Error(`Failed to read the OpenCode server inserted with id ${id}`)
}
return server
}

export function updateOpenCodeServer(
db: Database,
serverId: string,
patch: OpenCodeServerUpdate,
): OpenCodeServer | null {
const update = db.transaction(() => {
if (!getOpenCodeServer(db, serverId)) return null

const assignments: string[] = []
const values: Array<string | number> = []

if (patch.name !== undefined) {
assignments.push('name = ?')
values.push(patch.name)
}
if (patch.baseUrl !== undefined) {
assignments.push('base_url = ?')
values.push(trimUrlTrailingSlashes(patch.baseUrl))
}
if (patch.username !== undefined) {
assignments.push('username = ?')
values.push(patch.username)
}
if (patch.enabled !== undefined) {
assignments.push('enabled = ?')
values.push(patch.enabled ? 1 : 0)
}

assignments.push('updated_at = ?')
values.push(Date.now())

db.prepare(`UPDATE opencode_servers SET ${assignments.join(', ')} WHERE id = ?`).run(...values, serverId)

if (patch.password === null) {
deleteSecret(db, passwordSecretKey(serverId))
} else if (patch.password !== undefined) {
writeEncryptedSecret(db, passwordSecretKey(serverId), patch.password)
}

return getOpenCodeServer(db, serverId)
})

return update()
}

export function deleteOpenCodeServer(db: Database, serverId: string): boolean {
const remove = db.transaction(() => {
const result = db.prepare('DELETE FROM opencode_servers WHERE id = ?').run(serverId)
deleteSecret(db, passwordSecretKey(serverId))
return result.changes > 0
})

return remove()
}
27 changes: 27 additions & 0 deletions backend/src/db/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Database } from 'bun:sqlite'
import { decryptSecret, encryptSecret } from '../utils/crypto'
import { logger } from '../utils/logger'

export function readEncryptedSecret(db: Database, key: string): string | null {
const row = db.prepare('SELECT value FROM app_secrets WHERE key = ?').get(key) as { value: string } | undefined
if (!row) return null

try {
return decryptSecret(row.value)
} catch (error) {
logger.error(`Failed to decrypt ${key}`, error)
return null
}
}

export function writeEncryptedSecret(db: Database, key: string, value: string): void {
const now = Date.now()
db.prepare(`
INSERT INTO app_secrets (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(key, encryptSecret(value), now, now)
}

export function deleteSecret(db: Database, key: string): void {
db.prepare('DELETE FROM app_secrets WHERE key = ?').run(key)
}
12 changes: 12 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ import { createInternalRoutes } from './routes/internal'
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy'
import { createOpenCodeServerProxyRoutes } from './routes/opencode-server-proxy'
import { createOpenCodeServerRoutes } from './routes/opencode-servers'
import { sseAggregator } from './services/sse-aggregator'
import { RemoteServerEventHub } from './services/remote-server-events'
import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations'
import { SettingsService } from './services/settings'
import { SessionPermissionModeService } from './services/session-permission-modes'
Expand All @@ -62,6 +65,7 @@ import { MultiRunService } from './services/multi-runs'
import { ChangeWalkthroughService } from './services/change-walkthroughs'
import { opencodeServerManager } from './services/opencode-single-server'
import { createOpenCodeClient } from './services/opencode/client'
import { OpenCodeServerRegistry } from './services/opencode-server-registry'
import { getOpenCodeUpstreamBaseUrl } from './services/opencode/upstream'
import { NotificationService } from './services/notification'
import { ScheduleRunner, ScheduleService } from './services/schedules'
Expand Down Expand Up @@ -127,6 +131,7 @@ const openCodeClient = createOpenCodeClient(
() => new SettingsService(db).getOpenCodeServerPassword(),
() => opencodeServerManager.getEffectiveServerHost(),
)
export const opencodeServerRegistry = new OpenCodeServerRegistry(db, openCodeClient)

import { DEFAULT_AGENTS_MD } from './constants'

Expand Down Expand Up @@ -340,6 +345,9 @@ sseAggregator.onUpstreamConnected(() => {
})
sseAggregator.start()

const remoteServerEvents = new RemoteServerEventHub(opencodeServerRegistry, sseAggregator)
remoteServerEvents.start()

void sessionGoalService.recoverOpenGoals().catch((error) => {
logger.error('Session goal recovery error:', error)
})
Expand Down Expand Up @@ -379,6 +387,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/session-pins', createSessionPinRoutes(db))
protectedApi.route('/opencode-servers', createOpenCodeServerRoutes(opencodeServerRegistry, () => remoteServerEvents.getStatuses()))
protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService))
protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService))
protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService))
Expand All @@ -390,6 +399,7 @@ protectedApi.route('/preview', createPreviewRoutes({ store: previewSessionStore,

app.route('/api', protectedApi)

app.route('/api/opencode/servers', createOpenCodeServerProxyRoutes(opencodeServerRegistry, requireAuth))
app.route('/api/opencode', createAuthenticatedOpenCodeProxyRoutes(openCodeClient, requireAuth))

const isProduction = ENV.SERVER.NODE_ENV === 'production'
Expand Down Expand Up @@ -468,6 +478,8 @@ const shutdown = async (signal: string) => {

logger.info(`${signal} received, shutting down gracefully...`)
try {
remoteServerEvents.shutdown()
logger.info('Remote server event hub stopped')
sseAggregator.shutdown()
logger.info('SSE Aggregator stopped')
if (ipcServer) {
Expand Down
Loading