Skip to content
Open
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ config
opencode-src
temp

.git
.github
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.git is no longer ignored in the Docker build context. This can significantly increase build time/context size and risks shipping repository history (and potentially sensitive data) into intermediate build layers/caches. Prefer keeping .git ignored and pass git SHA/dirty state via build args/labels (or CI env) to generate build-info.json without including the full repo history in the context.

Suggested change
.github
.github
.git

Copilot uses AI. Check for mistakes.
.gitignore

Expand Down
38 changes: 36 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ RUN curl -fsSL https://bun.sh/install | bash && \

WORKDIR /app

FROM base AS metadata

COPY . .

RUN node <<'NODE'
const { execSync } = require('child_process')
const fs = require('fs')

const run = (command, fallback) => {
try {
return execSync(command, { encoding: 'utf8' }).trim() || fallback
} catch {
return fallback
}
}

const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'))
const sha = run('git rev-parse HEAD', 'unknown')
const shortSha = run('git rev-parse --short=12 HEAD', sha === 'unknown' ? 'unknown' : sha.slice(0, 12))
const dirty = run('git status --porcelain', '') !== ''

const buildInfo = {
packageVersion: packageJson.version || 'unknown',
buildTime: new Date().toISOString(),
git: {
sha,
shortSha,
dirty,
},
}

fs.writeFileSync('/tmp/build-info.json', JSON.stringify(buildInfo, null, 2))
NODE

FROM base AS deps

COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
Expand Down Expand Up @@ -61,13 +95,13 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/shared ./shared
COPY --from=builder /app/backend ./backend
COPY --from=builder /app/frontend/dist ./frontend/dist
COPY --from=metadata /tmp/build-info.json ./build-info.json
COPY package.json pnpm-workspace.yaml ./

RUN mkdir -p /app/backend/node_modules/@opencode-webui && \
ln -s /app/shared /app/backend/node_modules/@opencode-webui/shared

COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
COPY --chmod=755 scripts/docker-entrypoint.sh /docker-entrypoint.sh

RUN mkdir -p /workspace /app/data

Expand Down
12 changes: 10 additions & 2 deletions backend/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { opencodeServerManager } from '../services/opencode-single-server'
import { getBuildInfo } from '../services/build-info'

export function createHealthRoutes(db: Database) {
const app = new Hono()
Expand All @@ -11,23 +12,30 @@ export function createHealthRoutes(db: Database) {
const opencodeHealthy = await opencodeServerManager.checkHealth()

const status = dbCheck && opencodeHealthy ? 'healthy' : 'degraded'
const build = getBuildInfo()

return c.json({
status,
timestamp: new Date().toISOString(),
database: dbCheck ? 'connected' : 'disconnected',
opencode: opencodeHealthy ? 'healthy' : 'unhealthy',
opencodePort: opencodeServerManager.getPort()
opencodePort: opencodeServerManager.getPort(),
build
})
} catch (error) {
return c.json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
error: error instanceof Error ? error.message : 'Unknown error',
build: getBuildInfo()
}, 503)
}
})

app.get('/build', (c) => {
return c.json(getBuildInfo())
})

app.get('/processes', async (c) => {
try {
const opencodeHealthy = await opencodeServerManager.checkHealth()
Expand Down
9 changes: 7 additions & 2 deletions backend/src/routes/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from '../utils/logger'
export function createProvidersRoutes() {
const app = new Hono()
const authService = new AuthService()
const oauthOnlyProviders = new Set(['github-copilot'])

app.get('/credentials', async (c) => {
try {
Expand All @@ -21,8 +22,8 @@ export function createProvidersRoutes() {
app.get('/:id/credentials/status', async (c) => {
try {
const providerId = c.req.param('id')
const hasCredentials = await authService.has(providerId)
return c.json({ hasCredentials })
const status = await authService.getStatus(providerId)
return c.json(status)
} catch (error) {
logger.error('Failed to check credential status:', error)
return c.json({ error: 'Failed to check credential status' }, 500)
Expand All @@ -32,6 +33,10 @@ export function createProvidersRoutes() {
app.post('/:id/credentials', async (c) => {
try {
const providerId = c.req.param('id')
if (oauthOnlyProviders.has(providerId)) {
return c.json({ error: 'This provider uses device/OAuth login and cannot be configured with an API key.' }, 400)
}

const body = await c.req.json()
const validated = SetCredentialRequestSchema.parse(body)

Expand Down
6 changes: 3 additions & 3 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function createSettingsRoutes(db: Database) {
try {
const userId = c.req.query('userId') || 'default'
const settings = settingsService.getSettings(userId)
return c.json(settings)
return c.json(settingsService.toClientSettings(settings))
} catch (error) {
logger.error('Failed to get settings:', error)
return c.json({ error: 'Failed to get settings' }, 500)
Expand All @@ -80,7 +80,7 @@ export function createSettingsRoutes(db: Database) {
const validated = UpdateSettingsSchema.parse(body)

const settings = settingsService.updateSettings(validated.preferences, userId)
return c.json(settings)
return c.json(settingsService.toClientSettings(settings))
} catch (error) {
logger.error('Failed to update settings:', error)
if (error instanceof z.ZodError) {
Expand All @@ -94,7 +94,7 @@ export function createSettingsRoutes(db: Database) {
try {
const userId = c.req.query('userId') || 'default'
const settings = settingsService.resetSettings(userId)
return c.json(settings)
return c.json(settingsService.toClientSettings(settings))
} catch (error) {
logger.error('Failed to reset settings:', error)
return c.json({ error: 'Failed to reset settings' }, 500)
Expand Down
45 changes: 42 additions & 3 deletions backend/src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export class AuthService {
const auth = await this.getAll()
delete auth[providerId]

await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2))
await fs.mkdir(path.dirname(this.authPath), { recursive: true })
await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2), { mode: 0o600 })
logger.info(`Deleted credentials for provider: ${providerId}`)
}

Expand All @@ -52,8 +53,46 @@ export class AuthService {
}

async has(providerId: string): Promise<boolean> {
const auth = await this.getAll()
return !!auth[providerId]
const status = await this.getStatus(providerId)
return status.hasCredentials
}

async getStatus(providerId: string): Promise<{
hasCredentials: boolean
type: AuthEntry['type'] | null
expired: boolean
expires: number | null
hasAccessToken: boolean
hasRefreshToken: boolean
}> {
const entry = await this.get(providerId)
if (!entry) {
return {
hasCredentials: false,
type: null,
expired: false,
expires: null,
hasAccessToken: false,
hasRefreshToken: false,
}
}

const hasAccessToken = typeof entry.access === 'string' && entry.access.length > 0
const hasRefreshToken = typeof entry.refresh === 'string' && entry.refresh.length > 0
const expires = typeof entry.expires === 'number' ? entry.expires : null
const expired = entry.type === 'oauth' && expires !== null ? expires <= Date.now() : false
const hasCredentials = entry.type === 'apiKey'
? typeof entry.apiKey === 'string' && entry.apiKey.length > 0
: hasAccessToken || hasRefreshToken

return {
hasCredentials,
type: entry.type,
expired,
expires,
hasAccessToken,
hasRefreshToken,
}
}

async get(providerId: string): Promise<AuthEntry | null> {
Expand Down
49 changes: 49 additions & 0 deletions backend/src/services/build-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFileSync } from 'fs'
import path from 'path'

type BuildInfo = {
packageVersion: string
buildTime: string
git: {
sha: string
shortSha: string
dirty: boolean
}
}

const DEFAULT_BUILD_INFO: BuildInfo = {
packageVersion: 'unknown',
buildTime: 'unknown',
git: {
sha: 'unknown',
shortSha: 'unknown',
dirty: false,
},
}

let cachedBuildInfo: BuildInfo | null = null

export function getBuildInfo(): BuildInfo {
if (cachedBuildInfo) {
return cachedBuildInfo
}

const buildInfoPath = path.join(process.cwd(), 'build-info.json')

try {
const parsed = JSON.parse(readFileSync(buildInfoPath, 'utf8'))
cachedBuildInfo = {
packageVersion: typeof parsed.packageVersion === 'string' ? parsed.packageVersion : DEFAULT_BUILD_INFO.packageVersion,
buildTime: typeof parsed.buildTime === 'string' ? parsed.buildTime : DEFAULT_BUILD_INFO.buildTime,
git: {
sha: typeof parsed.git?.sha === 'string' ? parsed.git.sha : DEFAULT_BUILD_INFO.git.sha,
shortSha: typeof parsed.git?.shortSha === 'string' ? parsed.git.shortSha : DEFAULT_BUILD_INFO.git.shortSha,
dirty: typeof parsed.git?.dirty === 'boolean' ? parsed.git.dirty : DEFAULT_BUILD_INFO.git.dirty,
},
}
} catch {
cachedBuildInfo = DEFAULT_BUILD_INFO
}

return cachedBuildInfo
}
38 changes: 38 additions & 0 deletions backend/src/services/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@ import { ENV } from '@opencode-webui/shared'

const OPENCODE_SERVER_URL = `http://127.0.0.1:${ENV.OPENCODE.PORT}`

function enrichProviderPayload(payload: unknown) {
if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { providers?: unknown[] }).providers)) {
return payload
}

return {
...(payload as Record<string, unknown>),
providers: (payload as { providers: Array<Record<string, unknown>> }).providers.map((provider) => {
const authMethod = provider.id === 'github-copilot' ? 'oauth' : 'apiKey'
const options = provider.options && typeof provider.options === 'object'
? { ...(provider.options as Record<string, unknown>) }
: undefined

if (authMethod === 'oauth' && options) {
delete options.apiKey
}

return {
...provider,
authMethod,
...(options ? { options } : {}),
}
}),
}
}

export async function patchOpenCodeConfig(config: Record<string, unknown>): Promise<boolean> {
try {
const response = await fetch(`${OPENCODE_SERVER_URL}/config`, {
Expand Down Expand Up @@ -53,6 +79,18 @@ export async function proxyRequest(request: Request) {
}
})

if (request.method === 'GET' && cleanPath === '/config/providers') {
const payload = await response.json()
return new Response(JSON.stringify(enrichProviderPayload(payload)), {
status: response.status,
statusText: response.statusText,
headers: {
...responseHeaders,
'content-type': 'application/json',
},
})
}
Comment on lines +82 to +92
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls response.json() unconditionally, regardless of the upstream HTTP status. If the OpenCode server returns a non-JSON error response (e.g., an HTML 502 from a reverse proxy), response.json() will throw and the outer catch will return a generic 502 with Proxy request failed, losing the original status code and error details. Consider guarding with if (!response.ok) and falling through to the normal Response passthrough for error statuses.

Fix it with Roo Code or mention @roomote and request a fix.


return new Response(response.body, {
status: response.status,
statusText: response.statusText,
Expand Down
Loading
Loading