Skip to content

Commit cd567f8

Browse files
feat: add OpenCode server authentication settings and config
1 parent becb16a commit cd567f8

21 files changed

Lines changed: 844 additions & 49 deletions

.env.example

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ LOG_LEVEL=info
1717
OPENCODE_SERVER_PORT=5551
1818
OPENCODE_HOST=127.0.0.1
1919

20-
# Optional - bearer password required to talk to the spawned OpenCode server.
21-
# When set, the backend spawns OpenCode with this password and attaches it to
22-
# every proxied request. Leave unset to disable OpenCode-level auth.
20+
# Optional - bearer password required when OPENCODE_HOST=0.0.0.0 (external exposure).
21+
# The managed OpenCode server will refuse to start if OPENCODE_HOST is not
22+
# localhost/127.0.0.1 and no password is configured (either here or via
23+
# Settings → OpenCode → Server Auth).
24+
# DB-stored passwords (set via UI) override this env var.
2325
# OPENCODE_SERVER_PASSWORD=
2426

27+
# Optional - Basic Auth username (default: opencode)
28+
# OPENCODE_SERVER_USERNAME=opencode
29+
2530
# Optional - import an existing standalone OpenCode install on first startup
2631
# Useful for Docker when your host OpenCode data is bind-mounted into the container
2732
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json

backend/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ app.use('/*', cors({
8686
const db = initializeDatabase(DB_PATH)
8787
const auth = createAuth(db)
8888
const requireAuth = createAuthMiddleware(auth)
89-
const openCodeClient = createOpenCodeClient()
89+
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())
9090

9191
import { DEFAULT_AGENTS_MD } from './constants'
9292

@@ -264,16 +264,16 @@ try {
264264
await gitAuthService.initialize(ipcServer, db)
265265
logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`)
266266

267+
await syncAdminFromEnv(auth, db)
268+
267269
opencodeServerManager.setDatabase(db)
268-
opencodeServerManager.setOpenCodeClient(openCodeClient)
269270
const openCodeStatus = await openCodeSupervisor.start()
270271
if (openCodeStatus.healthy) {
271272
logger.info(`OpenCode server running on port ${openCodeStatus.port}`)
272273
} else {
273274
logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`)
274275
}
275276

276-
await syncAdminFromEnv(auth, db)
277277
} catch (error) {
278278
logger.error('Failed to initialize workspace:', error)
279279
}

backend/src/routes/settings.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { encryptSecret } from '../utils/crypto'
2929
import { compareVersions, isValidVersion } from '../utils/version-utils'
3030
import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../services/opencode-import'
3131
import { relinkReposFromSessionDirectories } from '../services/repo'
32+
import { ENV } from '@opencode-manager/shared/config/env'
3233
import {
3334
listManagedSkills,
3435
getSkill,
@@ -1509,5 +1510,58 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
15091510
}
15101511
})
15111512

1513+
const OpenCodeServerAuthBodySchema = z.object({
1514+
password: z.union([z.string().min(8), z.null()]),
1515+
})
1516+
1517+
app.get('/opencode-server-auth', async (c) => {
1518+
try {
1519+
const hasStored = settingsService.hasStoredOpenCodeServerPassword()
1520+
const source = hasStored ? 'db' : ENV.OPENCODE.SERVER_PASSWORD ? 'env' : 'none'
1521+
const isSet = source !== 'none'
1522+
return c.json({ isSet, source })
1523+
} catch (error) {
1524+
logger.error('Failed to get OpenCode server auth status:', error)
1525+
return c.json({ error: 'Failed to get OpenCode server auth status' }, 500)
1526+
}
1527+
})
1528+
1529+
app.patch('/opencode-server-auth', async (c) => {
1530+
try {
1531+
const body = await c.req.json()
1532+
const validated = OpenCodeServerAuthBodySchema.parse(body)
1533+
const previousPasswordState = settingsService.getStoredOpenCodeServerPasswordState()
1534+
1535+
if (validated.password === null) {
1536+
settingsService.clearOpenCodeServerPassword()
1537+
} else if (validated.password) {
1538+
settingsService.setOpenCodeServerPassword(validated.password)
1539+
}
1540+
1541+
try {
1542+
await opencodeServerManager.restart()
1543+
} catch (restartError) {
1544+
try {
1545+
settingsService.restoreOpenCodeServerPasswordState(previousPasswordState)
1546+
await opencodeServerManager.restart()
1547+
} catch (restoreError) {
1548+
logger.error('Failed to restore OpenCode server auth runtime after restart failure:', restoreError)
1549+
}
1550+
throw restartError
1551+
}
1552+
1553+
const hasStored = settingsService.hasStoredOpenCodeServerPassword()
1554+
const source = hasStored ? 'db' : ENV.OPENCODE.SERVER_PASSWORD ? 'env' : 'none'
1555+
const isSet = source !== 'none'
1556+
return c.json({ isSet, source })
1557+
} catch (error) {
1558+
logger.error('Failed to update OpenCode server auth:', error)
1559+
if (error instanceof z.ZodError) {
1560+
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
1561+
}
1562+
return c.json({ error: 'Failed to update OpenCode server auth' }, 500)
1563+
}
1564+
})
1565+
15121566
return app
15131567
}

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

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,8 @@ import { patchConfigWithRecovery } from './opencode/config-recovery'
2424
import type { OpenCodeClient } from './opencode/client'
2525
import { writeFileContent } from './file-operations'
2626

27-
const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT
2827
const OPENCODE_SERVER_HOST = ENV.OPENCODE.HOST
29-
const OPENCODE_SERVER_PUBLIC_URL = ENV.OPENCODE.PUBLIC_URL
30-
const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD
31-
const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME
28+
export const OPENCODE_SERVER_CONNECT_HOST = OPENCODE_SERVER_HOST === '0.0.0.0' ? '127.0.0.1' : OPENCODE_SERVER_HOST
3229
const MIN_OPENCODE_VERSION = '1.0.137'
3330
const MAX_STDERR_SIZE = 10240
3431
const HEALTH_CHECK_TIMEOUT_MS = 3000
@@ -91,6 +88,10 @@ function formatStartupError(stderrOutput: string, fallback: string): string {
9188
// This allows proper mocking in tests
9289
const getOpenCodeServerDirectory = () => getWorkspacePath()
9390
const getOpenCodeConfigPath = () => getOpenCodeConfigFilePath()
91+
const getOpenCodeServerPort = () => ENV.OPENCODE.PORT
92+
const getOpenCodeServerHost = () => ENV.OPENCODE.HOST
93+
const getOpenCodeServerPublicUrl = () => ENV.OPENCODE.PUBLIC_URL
94+
const getOpenCodeServerUsername = () => ENV.OPENCODE.SERVER_USERNAME
9495

9596
class OpenCodeServerManager {
9697
private static instance: OpenCodeServerManager
@@ -113,6 +114,20 @@ class OpenCodeServerManager {
113114
this.openCodeClient = client
114115
}
115116

117+
async rebuildClient(): Promise<void> {
118+
const password = this.getResolvedPassword()
119+
const { createOpenCodeClient } = await import('./opencode/client')
120+
this.openCodeClient = createOpenCodeClient(password)
121+
}
122+
123+
private getResolvedPassword(): string {
124+
if (this.db) {
125+
const settingsService = new SettingsService(this.db)
126+
return settingsService.getOpenCodeServerPassword()
127+
}
128+
return ENV.OPENCODE.SERVER_PASSWORD
129+
}
130+
116131
private requireClient(): OpenCodeClient {
117132
if (!this.openCodeClient) {
118133
throw new Error('OpenCodeClient not configured on OpenCodeServerManager. Call setOpenCodeClient() during startup.')
@@ -166,7 +181,18 @@ class OpenCodeServerManager {
166181
return
167182
}
168183

184+
await this.rebuildClient()
185+
169186
const isDevelopment = ENV.SERVER.NODE_ENV !== 'production'
187+
const password = this.getResolvedPassword()
188+
const openCodeServerHost = getOpenCodeServerHost()
189+
const isExposed = openCodeServerHost !== '127.0.0.1' && openCodeServerHost !== 'localhost'
190+
if (isExposed && !password) {
191+
const msg = `OPENCODE_HOST=${openCodeServerHost} exposes the OpenCode server externally but no password is configured. Set OPENCODE_SERVER_PASSWORD env var or configure a password via Settings → OpenCode → Server Auth.`
192+
this.lastStartupError = msg
193+
logger.error(msg)
194+
throw new Error(msg)
195+
}
170196

171197
let gitCredentials: GitCredential[] = []
172198
let gitIdentityEnv: Record<string, string> = {}
@@ -186,9 +212,10 @@ class OpenCodeServerManager {
186212
}
187213
}
188214

189-
const existingProcesses = await this.findProcessesByPort(OPENCODE_SERVER_PORT)
215+
const openCodeServerPort = getOpenCodeServerPort()
216+
const existingProcesses = await this.findProcessesByPort(openCodeServerPort)
190217
if (existingProcesses.length > 0) {
191-
logger.info(`OpenCode server already running on port ${OPENCODE_SERVER_PORT}`)
218+
logger.info(`OpenCode server already running on port ${openCodeServerPort}`)
192219
const healthy = await this.checkHealth()
193220
if (healthy) {
194221
if (isDevelopment) {
@@ -288,7 +315,7 @@ class OpenCodeServerManager {
288315

289316
this.serverProcess = spawn(
290317
'opencode',
291-
['serve', '--port', OPENCODE_SERVER_PORT.toString(), '--hostname', OPENCODE_SERVER_HOST],
318+
['serve', '--port', openCodeServerPort.toString(), '--hostname', openCodeServerHost],
292319
{
293320
cwd: openCodeServerDirectory,
294321
detached: !isDevelopment,
@@ -301,11 +328,11 @@ class OpenCodeServerManager {
301328
XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
302329
XDG_STATE_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
303330
XDG_CONFIG_HOME: path.join(openCodeServerDirectory, '.config'),
304-
...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}),
305-
...(OPENCODE_SERVER_PASSWORD
331+
...(getOpenCodeServerPublicUrl() ? { OPENCODE_PUBLIC_URL: getOpenCodeServerPublicUrl() } : {}),
332+
...(password
306333
? {
307-
OPENCODE_SERVER_PASSWORD,
308-
OPENCODE_SERVER_USERNAME,
334+
OPENCODE_SERVER_PASSWORD: password,
335+
OPENCODE_SERVER_USERNAME: getOpenCodeServerUsername(),
309336
}
310337
: {}),
311338
OPENCODE_CONFIG: openCodeConfigPath,
@@ -531,7 +558,7 @@ class OpenCodeServerManager {
531558
}
532559

533560
getPort(): number {
534-
return OPENCODE_SERVER_PORT
561+
return getOpenCodeServerPort()
535562
}
536563

537564
getVersion(): string | null {

backend/src/services/opencode/auth.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { ENV } from '@opencode-manager/shared/config/env'
22

3-
export function buildOpenCodeBasicAuthHeader(): string | null {
4-
const password = ENV.OPENCODE.SERVER_PASSWORD
3+
export type OpenCodePasswordResolver = () => string | Promise<string>
4+
5+
export function getOpenCodeBasicAuthHeader(): string | null
6+
export function getOpenCodeBasicAuthHeader(password: string): string | null
7+
export function getOpenCodeBasicAuthHeader(passwordResolver: OpenCodePasswordResolver): Promise<string | null>
8+
export function getOpenCodeBasicAuthHeader(source?: string | OpenCodePasswordResolver): string | null | Promise<string | null> {
9+
if (typeof source === 'function') {
10+
return Promise.resolve(source()).then((password) => getOpenCodeBasicAuthHeader(password))
11+
}
12+
13+
const password = source ?? ENV.OPENCODE.SERVER_PASSWORD
514
const username = ENV.OPENCODE.SERVER_USERNAME
615
if (!password) return null
716
const token = Buffer.from(`${username}:${password}`).toString('base64')

backend/src/services/opencode/client.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { logger } from '../../utils/logger'
22
import { ENV } from '@opencode-manager/shared/config/env'
3-
import { buildOpenCodeBasicAuthHeader } from './auth'
3+
import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './auth'
44

55
export interface ForwardRequest {
66
method: string
@@ -42,6 +42,7 @@ export interface OpenCodeClient {
4242
export interface FetchOpenCodeClientConfig {
4343
baseUrl: string
4444
basicAuth: string | null
45+
passwordResolver?: OpenCodePasswordResolver
4546
fetchFn?: typeof fetch
4647
}
4748

@@ -52,6 +53,14 @@ export class FetchOpenCodeClient implements OpenCodeClient {
5253
return this.config.fetchFn ?? fetch
5354
}
5455

56+
private async getBasicAuth(): Promise<string> {
57+
if (!this.config.passwordResolver) {
58+
return this.config.basicAuth ?? ''
59+
}
60+
61+
return await getOpenCodeBasicAuthHeader(this.config.passwordResolver) ?? ''
62+
}
63+
5564
private async request(req: ForwardRequest): Promise<Response> {
5665
const url = new URL(this.config.baseUrl + req.path)
5766

@@ -60,9 +69,10 @@ export class FetchOpenCodeClient implements OpenCodeClient {
6069
}
6170

6271
const headers: Record<string, string> = { ...(req.headers ?? {}) }
72+
const basicAuth = await this.getBasicAuth()
6373

64-
if (this.config.basicAuth) {
65-
headers.Authorization = this.config.basicAuth
74+
if (basicAuth) {
75+
headers.Authorization = basicAuth
6676
}
6777

6878
try {
@@ -238,9 +248,12 @@ export class FetchOpenCodeClient implements OpenCodeClient {
238248
}
239249
}
240250

241-
export function createOpenCodeClient(): OpenCodeClient {
242-
const baseUrl = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}`
243-
const basicAuth = buildOpenCodeBasicAuthHeader()
251+
export function createOpenCodeClient(passwordOverride?: string | OpenCodePasswordResolver): OpenCodeClient {
252+
const host = ENV.OPENCODE.HOST === '0.0.0.0' ? '127.0.0.1' : ENV.OPENCODE.HOST
253+
const baseUrl = `http://${host}:${ENV.OPENCODE.PORT}`
254+
const passwordResolver = typeof passwordOverride === 'function' ? passwordOverride : undefined
255+
const password = typeof passwordOverride === 'string' ? passwordOverride : ENV.OPENCODE.SERVER_PASSWORD
256+
const basicAuth = getOpenCodeBasicAuthHeader(password)
244257

245-
return new FetchOpenCodeClient({ baseUrl, basicAuth })
258+
return new FetchOpenCodeClient({ baseUrl, basicAuth, passwordResolver })
246259
}

backend/src/services/settings.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env'
44
import { logger } from '../utils/logger'
55
import { parseJsonc } from '@opencode-manager/shared/utils'
66
import { z } from 'zod'
7+
import { encryptSecret, decryptSecret } from '../utils/crypto'
8+
import { ENV } from '@opencode-manager/shared/config/env'
79
import type {
810
UserPreferences,
911
SettingsResponse,
@@ -42,6 +44,12 @@ interface CreateOpenCodeConfigOptions {
4244
suppressAutoDefault?: boolean
4345
}
4446

47+
interface OpenCodeServerPasswordState {
48+
value: string
49+
createdAt: number
50+
updatedAt: number
51+
}
52+
4553

4654
export class SettingsService {
4755
private static lastKnownGoodConfigContent: string | null = null
@@ -597,4 +605,60 @@ export class SettingsService {
597605
return false
598606
}
599607
}
608+
609+
getOpenCodeServerPassword(): string {
610+
const row = this.db.prepare('SELECT value FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string } | undefined
611+
if (!row) {
612+
return ENV.OPENCODE.SERVER_PASSWORD
613+
}
614+
try {
615+
return decryptSecret(row.value)
616+
} catch (error) {
617+
logger.error('Failed to decrypt opencode_server_password, falling back to env', error)
618+
return ENV.OPENCODE.SERVER_PASSWORD
619+
}
620+
}
621+
622+
hasStoredOpenCodeServerPassword(): boolean {
623+
const row = this.db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get('opencode_server_password')
624+
return Boolean(row)
625+
}
626+
627+
getStoredOpenCodeServerPasswordState(): OpenCodeServerPasswordState | null {
628+
const row = this.db.prepare('SELECT value, created_at, updated_at FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string; created_at: number; updated_at: number } | undefined
629+
if (!row) {
630+
return null
631+
}
632+
633+
return {
634+
value: row.value,
635+
createdAt: row.created_at,
636+
updatedAt: row.updated_at,
637+
}
638+
}
639+
640+
restoreOpenCodeServerPasswordState(state: OpenCodeServerPasswordState | null): void {
641+
if (!state) {
642+
this.clearOpenCodeServerPassword()
643+
return
644+
}
645+
646+
this.db.prepare(`
647+
INSERT INTO app_secrets (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)
648+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at, updated_at = excluded.updated_at
649+
`).run('opencode_server_password', state.value, state.createdAt, state.updatedAt)
650+
}
651+
652+
setOpenCodeServerPassword(password: string): void {
653+
const now = Date.now()
654+
const encrypted = encryptSecret(password)
655+
this.db.prepare(`
656+
INSERT INTO app_secrets (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)
657+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
658+
`).run('opencode_server_password', encrypted, now, now)
659+
}
660+
661+
clearOpenCodeServerPassword(): void {
662+
this.db.prepare('DELETE FROM app_secrets WHERE key = ?').run('opencode_server_password')
663+
}
600664
}

0 commit comments

Comments
 (0)