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
19 changes: 13 additions & 6 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,24 @@ jobs:

steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v5
with:
version: 9.15.0
node-version: 22
package-manager-cache: false

- name: Set up Node.js
uses: actions/setup-node@v4
- name: Set up pnpm
run: |
corepack enable
Comment on lines +23 to +31
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

actions/setup-node is configured with cache: pnpm before pnpm is installed. setup-node expects pnpm to already be on PATH when caching is enabled (it runs pnpm store path), so this can fail the workflow. Install pnpm first (e.g. pnpm/action-setup@v5 with run_install: false), or remove cache: pnpm / switch to an explicit cache step after installing pnpm.

Copilot uses AI. Check for mistakes.
corepack prepare pnpm@9.15.0 --activate

- name: Restore pnpm cache
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
cache: pnpm

- name: Set up Bun
Expand Down
34 changes: 34 additions & 0 deletions backend/test/middleware/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const createTestApp = () => {
app.use('/api/*', createRequireControlPlaneAuth({ token: 'secret-token' }))

app.get('/api/health', (c) => c.json({ ok: true }))
app.get('/api/health/build', (c) => c.json({ ok: true, build: true }))
app.get('/api/repos', (c) => c.json({ ok: true }))
app.post('/api/repos', (c) => c.json({ ok: true, method: c.req.method }))
app.get('/api/opencode/event', (c) => c.text('stream-ready'))

return app
Expand All @@ -28,11 +30,20 @@ describe('control-plane middleware', () => {
expect(response.status).toBe(200)
})

it('keeps nested health endpoints public', async () => {
const app = createTestApp()
const response = await app.request('http://localhost/api/health/build')

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ ok: true, build: true })
})

it('rejects anonymous access to protected routes', async () => {
const app = createTestApp()
const response = await app.request('http://localhost/api/repos')

expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe('Bearer realm="opencode-webui"')
await expect(response.json()).resolves.toEqual({ error: 'Authentication required' })
})

Expand Down Expand Up @@ -60,6 +71,29 @@ describe('control-plane middleware', () => {
await expect(response.text()).resolves.toBe('stream-ready')
})

it('rejects query token auth on non-GET requests', async () => {
const app = createTestApp()
const response = await app.request('http://localhost/api/repos?access_token=secret-token', {
method: 'POST',
})

expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({ error: 'Authentication required' })
})

it('returns 503 when auth middleware is enabled without a configured token', async () => {
const app = new Hono()
app.use('/api/*', createRequireControlPlaneAuth({ token: ' ' }))
app.get('/api/repos', (c) => c.json({ ok: true }))

const response = await app.request('http://localhost/api/repos')

expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({
error: 'Control-plane auth is enabled but AUTH_TOKEN is not configured',
})
})

it('returns allowed CORS headers for configured origins', async () => {
const app = createTestApp()
const response = await app.request('http://localhost/api/repos', {
Expand Down
79 changes: 79 additions & 0 deletions backend/test/routes/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ describe('health routes', () => {
})
})

it('exposes readiness from the root health endpoint', async () => {
const app = createHealthRoutes(createMockDb(), {
checkOpencodeHealth: vi.fn().mockResolvedValue(true),
getOpencodePort: () => 5551,
getBuildInfo: () => buildInfo,
})

const response = await app.request('http://localhost/')

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'healthy',
build: buildInfo,
})
})

it('keeps liveness independent from readiness', async () => {
const app = createHealthRoutes(createMockDb(false), {
checkOpencodeHealth: vi.fn().mockResolvedValue(false),
Expand All @@ -92,4 +108,67 @@ describe('health routes', () => {
build: buildInfo,
})
})

it('returns build metadata directly', async () => {
const app = createHealthRoutes(createMockDb(), {
checkOpencodeHealth: vi.fn().mockResolvedValue(true),
getOpencodePort: () => 5551,
getBuildInfo: () => buildInfo,
})

const response = await app.request('http://localhost/build')

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual(buildInfo)
})

it('returns unhealthy readiness with build info when dependency checks throw', async () => {
const app = createHealthRoutes(createMockDb(), {
checkOpencodeHealth: vi.fn().mockRejectedValue(new Error('opencode probe failed')),
getOpencodePort: () => 5551,
getBuildInfo: () => buildInfo,
})

const response = await app.request('http://localhost/ready')

expect(response.status).toBe(503)
await expect(response.json()).resolves.toMatchObject({
status: 'unhealthy',
error: 'opencode probe failed',
build: buildInfo,
})
})

it('reports process health and port details', async () => {
const app = createHealthRoutes(createMockDb(), {
checkOpencodeHealth: vi.fn().mockResolvedValue(true),
getOpencodePort: () => 6001,
getBuildInfo: () => buildInfo,
})

const response = await app.request('http://localhost/processes')

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
opencode: {
port: 6001,
healthy: true,
},
})
})

it('returns 500 from process health when dependency checks throw', async () => {
const app = createHealthRoutes(createMockDb(), {
checkOpencodeHealth: vi.fn().mockRejectedValue(new Error('process probe failed')),
getOpencodePort: () => 6001,
getBuildInfo: () => buildInfo,
})

const response = await app.request('http://localhost/processes')

expect(response.status).toBe(500)
await expect(response.json()).resolves.toMatchObject({
error: 'process probe failed',
})
})
})
104 changes: 104 additions & 0 deletions backend/test/services/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs/promises'
import path from 'path'

vi.mock('../../src/db/queries', () => ({
listRepos: vi.fn(),
}))

import * as repoQueries from '../../src/db/queries'
import { proxyRequest } from '../../src/services/proxy'

const artifactsRoot = path.resolve(process.cwd(), 'test-artifacts/proxy')
const reposRoot = path.resolve(process.cwd(), 'workspace/repos')

describe('proxyRequest hardening', () => {
beforeEach(async () => {
vi.restoreAllMocks()
vi.mocked(repoQueries.listRepos).mockReturnValue([])
await fs.rm(artifactsRoot, { recursive: true, force: true })
await fs.rm(reposRoot, { recursive: true, force: true })
await fs.mkdir(artifactsRoot, { recursive: true })
await fs.mkdir(reposRoot, { recursive: true })
Comment on lines +12 to +22
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This test suite deletes and recreates ${process.cwd()}/workspace/repos in beforeEach/afterEach. That path is the default workspace location and may contain real developer data; it also risks cross-test interference when Vitest runs files in parallel. Prefer using a test-only temp directory under backend/test-artifacts/... and make getReposPath() resolve to it (e.g. by mocking @opencode-webui/shared or setting WORKSPACE_PATH before importing the module under test).

Copilot uses AI. Check for mistakes.
})

afterEach(async () => {
await fs.rm(artifactsRoot, { recursive: true, force: true })
await fs.rm(reposRoot, { recursive: true, force: true })
})

it('rejects proxy directory traversal outside the tracked repos base', async () => {
const outsidePath = path.join(artifactsRoot, 'outside')
await fs.mkdir(outsidePath, { recursive: true })

const response = await proxyRequest(
new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(outsidePath)}`),
{} as any,
)

expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ error: 'Path traversal detected' })
})

it('rejects proxy requests for untracked repositories', async () => {
const untrackedRepo = path.join(reposRoot, 'untracked-repo')
await fs.mkdir(untrackedRepo, { recursive: true })

const fetchSpy = vi.spyOn(globalThis, 'fetch')
const response = await proxyRequest(
new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(untrackedRepo)}`),
{} as any,
)

expect(response.status).toBe(403)
await expect(response.json()).resolves.toEqual({ error: 'Directory is not a tracked repository' })
expect(fetchSpy).not.toHaveBeenCalled()
})

it('allows tracked repositories and preserves upstream JSON responses', async () => {
const trackedRepo = path.join(reposRoot, 'tracked-repo')
await fs.mkdir(trackedRepo, { recursive: true })
vi.mocked(repoQueries.listRepos).mockReturnValue([
{
id: 1,
localPath: 'tracked-repo',
fullPath: trackedRepo,
defaultBranch: 'main',
cloneStatus: 'ready',
clonedAt: Date.now(),
} as any,
])

const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'content-type': 'application/json',
connection: 'keep-alive',
},
}))

const response = await proxyRequest(
new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(trackedRepo)}`, {
headers: {
Authorization: 'Bearer secret-token',
Connection: 'keep-alive',
Host: 'localhost:3001',
'X-Trace-Id': 'trace-123',
},
}),
{} as any,
)

expect(fetchSpy).toHaveBeenCalledWith('http://127.0.0.1:5551/session', {
method: 'GET',
headers: {
authorization: 'Bearer secret-token',
'x-trace-id': 'trace-123',
},
body: undefined,
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ ok: true })
expect(response.headers.get('connection')).toBeNull()
})
})
45 changes: 40 additions & 5 deletions frontend/src/api/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ export interface ProviderWithModels {
authMethod?: "oauth" | "apiKey" | "none";
}

type ModelCapabilities = {
attachment?: boolean;
reasoning?: boolean;
temperature?: boolean;
toolcall?: boolean;
};

type ProviderModelConfig = Partial<Model> & {
capabilities?: ModelCapabilities;
};

type LiveProviderConfig = {
id: string;
name?: string;
api?: string;
env?: string[];
npm?: string;
models?: Record<string, ProviderModelConfig>;
options?: Record<string, unknown>;
authMethod?: Provider["authMethod"];
};

// Default providers for common OpenCode setups
export const DEFAULT_PROVIDERS: Provider[] = [
{
Expand Down Expand Up @@ -176,13 +198,13 @@ async function getProvidersFromConfig(): Promise<Provider[]> {
try {
const { data } = await apiClient.get(`${API_BASE_URL}/api/opencode/config/providers`);
if (Array.isArray(data?.providers)) {
return data.providers.map((provider: any) => ({
return data.providers.map((provider: LiveProviderConfig) => ({
id: provider.id,
name: provider.name || provider.id,
api: provider.api,
env: Array.isArray(provider.env) ? provider.env : [],
npm: provider.npm,
models: provider.models || {},
models: normalizeModels(provider.models),
options: provider.options || {},
authMethod: provider.authMethod || 'apiKey',
}));
Expand All @@ -194,13 +216,13 @@ async function getProvidersFromConfig(): Promise<Provider[]> {
try {
const configResponse = await settingsApi.getDefaultOpenCodeConfig();
if (configResponse?.content?.provider) {
const providerRecord = configResponse.content.provider as Record<string, Provider>;
const providerRecord = configResponse.content.provider as Record<string, Partial<Provider>>;
return Object.entries(providerRecord).map(([id, provider]) => ({
...provider,
id: provider.id || id,
name: provider.name || id,
env: provider.env || [],
models: provider.models || {},
models: normalizeModels(provider.models),
authMethod: provider.authMethod || 'apiKey',
}));
}
Expand All @@ -216,7 +238,20 @@ export async function getProviders(): Promise<Provider[]> {
return await getProvidersFromConfig();
}

function normalizeModel(id: string, model: any): Model {
function normalizeModels(models: unknown): Record<string, Model> {
if (!models || typeof models !== 'object' || Array.isArray(models)) {
return {};
}

return Object.fromEntries(
Object.entries(models as Record<string, ProviderModelConfig>).map(([id, model]) => [
id,
normalizeModel(id, model),
]),
);
}

function normalizeModel(id: string, model: ProviderModelConfig = {}): Model {
const capabilities = model?.capabilities || {};
return {
...model,
Expand Down
Loading