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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ PORT=5003
HOST=0.0.0.0
CORS_ORIGIN=http://localhost:5173
NODE_ENV=production
# LOG_LEVEL is accepted but currently unused; DEBUG controls debug output
LOG_LEVEL=info

# ============================================
Expand All @@ -21,6 +22,8 @@ OPENCODE_HOST=127.0.0.1
# OpenCode 2 always requires a password: when this is unset and no password is
# stored via Settings → OpenCode → Server Auth, OpenCode Manager generates one
# and persists it. DB-stored passwords override this env var.
# Docker: the default docker-compose.yml does not forward this variable from
# .env; add it to the compose environment block or set it via the UI.
# OPENCODE_SERVER_PASSWORD=

# Optional - import an existing standalone OpenCode install on first startup
Expand Down Expand Up @@ -88,7 +91,9 @@ AUTH_SECRET=CHANGE_ME_GENERATE_WITH_openssl_rand_base64_32
# ADMIN_PASSWORD_RESET=false

# Secure Cookies (set to false when running HTTP without a reverse proxy)
# Defaults to true in production, false in development
# Runtime default: true when NODE_ENV=production, false otherwise.
# Docker Compose default: false (docker-compose.yml forwards AUTH_SECURE_COOKIES:-false),
# so set true explicitly for HTTPS.
# AUTH_SECURE_COOKIES=true

# OAuth Providers (optional - enable by providing client ID and secret)
Expand Down Expand Up @@ -159,6 +164,8 @@ PASSKEY_ORIGIN=http://localhost:5003
# Frontend Configuration (Vite)
# These are optional - frontend uses defaults if not set
# ============================================
# VITE_API_URL is unset by default: requests are same-origin and the Vite dev
# server proxies /api to the backend. Set only for a split frontend/backend origin.
# VITE_API_URL=http://localhost:5003
# VITE_SERVER_PORT=5003
# VITE_OPENCODE_PORT=5551
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
## Commands

- `pnpm dev` - Start both backend (5003) and frontend (5173)
- `pnpm dev:backend` - Backend only: `bun --watch-path backend/src --watch backend/src/index.ts`
- `pnpm dev:backend` - Backend only: `NODE_ENV=development bun --watch backend/src/index.ts`
- `pnpm dev:frontend` - Frontend only: `pnpm --filter frontend dev`
- `pnpm build` - Build both backend and frontend
- `pnpm test` - Run backend tests: `pnpm --filter backend test` (vitest)
- `pnpm build` - Build CLI, backend, and frontend
- `pnpm test` - Run CLI, backend, and frontend tests
- `cd backend && vitest <filename>` - Run single test file
- `cd backend && vitest --ui` - Test UI with coverage
- `cd backend && vitest --coverage` - Coverage report (80% threshold)
- `pnpm lint` - Lint both backend and frontend
- `pnpm lint` - Lint CLI, frontend, and backend
- `pnpm lint:backend` - Backend linting
- `pnpm lint:frontend` - Frontend linting

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ Each item is tagged with a theme to help you find work in your area of interest:
### Running Tests

```bash
pnpm test # Run all tests (backend)
pnpm test # Run CLI, backend, and frontend tests
cd backend && vitest <filename> # Run single test file
```

### Linting

```bash
pnpm lint # Lint both backend and frontend
pnpm lint # Lint CLI, frontend, and backend
```

Run linting before submitting a PR.
Expand Down
16 changes: 16 additions & 0 deletions backend/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { betterAuth } from 'better-auth'
import { APIError } from 'better-auth/api'
import { passkey } from '@better-auth/passkey'
import { Database } from 'bun:sqlite'
import { ENV } from '@opencode-manager/shared/config/env'

export type AuthInstance = ReturnType<typeof createAuth>

export function isAdminConfigured(): boolean {
return !!(ENV.AUTH.ADMIN_EMAIL && ENV.AUTH.ADMIN_PASSWORD)
}

export function createAuth(db: Database) {
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {}

Expand Down Expand Up @@ -44,6 +49,17 @@ export function createAuth(db: Database) {
autoSignIn: true,
},
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
databaseHooks: {
user: {
create: {
before: async (user, context) => {
if (isAdminConfigured() && (context?.request || user.email.toLowerCase() !== ENV.AUTH.ADMIN_EMAIL!.toLowerCase())) {
throw new APIError('FORBIDDEN', { message: 'Registration is disabled' })
}
},
},
},
},
plugins: [
passkey({
rpID: ENV.AUTH.PASSKEY_RP_ID,
Expand Down
6 changes: 1 addition & 5 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import type { AuthInstance } from '../auth'
import { isAdminConfigured, type AuthInstance } from '../auth'
import { Database } from 'bun:sqlite'
import { ENV } from '@opencode-manager/shared/config/env'
import { logger } from '../utils/logger'
Expand All @@ -25,10 +25,6 @@ export function createAuthRoutes(auth: AuthInstance): Hono {
return app
}

const isAdminConfigured = (): boolean => {
return !!(ENV.AUTH.ADMIN_EMAIL && ENV.AUTH.ADMIN_PASSWORD)
}

export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promise<void> {
if (!isAdminConfigured()) return

Expand Down
60 changes: 60 additions & 0 deletions backend/src/services/opencode-config-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'crypto'
import { promises as fs } from 'fs'
import { readFile, readdir, rename, rm, stat } from 'fs/promises'
import path from 'path'
import { isDeepStrictEqual } from 'node:util'
Expand All @@ -23,6 +24,7 @@ import type {
import { logger } from '../utils/logger'
import { withFileLock } from '../utils/atomic-json'
import { existingFileMode, writeFileAtomic } from '../utils/fs-safe'
import { restoreEnforcementSections, type EnforcementRemovedSections } from './opencode/enforcement-config'
import { ensureDirectoryExists } from './file-operations'

export const OPENCODE_CONFIG_SEED = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
Expand All @@ -41,6 +43,8 @@ const OPENCODE_CONFIG_SNAPSHOT_MARKER = 'opencode-config-snapshot'

const OPENCODE_CONFIG_SNAPSHOT_ARTIFACT_PREFIX = 'opencode-config-broken'

const OPENCODE_CONFIG_LEGACY_BACKUP_SUFFIX = '.ocm-sandbox-backup'

export type OpenCodeConfigUpdateMode = 'replace' | 'merge'

export interface UpdateOpenCodeConfigOptions {
Expand Down Expand Up @@ -763,6 +767,62 @@ export async function foldLegacyConfigJsonSource(): Promise<boolean> {
}
}

export async function restoreLegacyOpenCodeConfigBackup(configPath: string): Promise<void> {
const backupPath = `${configPath}${OPENCODE_CONFIG_LEGACY_BACKUP_SUFFIX}`
await withFileLock(path.dirname(configPath), async () => {
let backupContent: string
try {
backupContent = await fs.readFile(backupPath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
throw new Error(`cannot read legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`)
}

let currentContent: string
try {
currentContent = await fs.readFile(configPath, 'utf8')
} catch (error) {
throw new Error(`cannot read config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`)
}

let backupRecord: unknown
try {
backupRecord = parseJsonc(backupContent)
} catch (error) {
throw new Error(`cannot parse legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`)
}
if (!isPlainObject(backupRecord)) {
throw new Error(`legacy backup ${backupPath} is malformed`)
}

const removed: EnforcementRemovedSections = isPlainObject(backupRecord.removedSections)
? backupRecord.removedSections
: {
plugin: Array.isArray(backupRecord.originalPlugins)
? backupRecord.originalPlugins
: Array.isArray(backupRecord.plugin) ? backupRecord.plugin : [],
}

let currentConfig: unknown
try {
currentConfig = parseJsonc(currentContent)
} catch (error) {
throw new Error(`cannot parse config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`)
}
if (!isPlainObject(currentConfig)) {
throw new Error(`config ${configPath} is not an object while restoring legacy backup`)
}

const restored = restoreEnforcementSections(currentConfig, removed)
const restoredContent = JSON.stringify(restored, null, 2)
if (restoredContent !== currentContent) {
const mode = await existingFileMode(configPath)
await writeFileAtomic(configPath, restoredContent, { mode })
}
await fs.rm(backupPath, { force: true })
})
}

export async function pruneHealthWatchDirectory(dirPath: string): Promise<void> {
try {
const entries = await readdir(dirPath, { withFileTypes: true })
Expand Down
62 changes: 3 additions & 59 deletions backend/src/services/opencode-plugin-quarantine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,12 @@ import { promises as fs } from 'fs'
import { lstat, realpath } from 'fs/promises'
import path from 'path'
import { OPENCODE_CONFIG_SOURCE_NAMES } from '@opencode-manager/shared'
import { parseJsonc } from '@opencode-manager/shared/utils'
import { logger } from '../utils/logger'
import { existingFileMode, mkdirSafe, writeFileAtomic } from '../utils/fs-safe'
import { withOpenCodeConfigLock } from './opencode-config-file'
import { mkdirSafe } from '../utils/fs-safe'
import { restoreLegacyOpenCodeConfigBackup } from './opencode-config-file'
import { getOpenCodeHome } from './opencode-home'
import { getOpenCodePluginDir } from './opencode/plugin-registry'
import {
isRecord,
restoreEnforcementSections,
type EnforcementRemovedSections,
} from './opencode/enforcement-config'

const PLUGIN_CONFIG_BACKUP_SUFFIX = '.ocm-sandbox-backup'
const QUARANTINE_CONFLICT_SUFFIX = '.ocm-conflict'
const QUARANTINE_MANIFEST_FILENAME = '.ocm-quarantine-manifest.json'

Expand Down Expand Up @@ -238,55 +231,6 @@ async function restorePluginEntries(dir: string): Promise<void> {
}
}

async function restoreEnforcementConfigSections(configPath: string): Promise<void> {
const backupPath = `${configPath}${PLUGIN_CONFIG_BACKUP_SUFFIX}`
if (!(await pathExists(backupPath))) return

let backupContent: string
try {
backupContent = await fs.readFile(backupPath, 'utf-8')
} catch (error) {
throw new Error(`cannot read legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`)
}
let currentContent: string
try {
currentContent = await fs.readFile(configPath, 'utf-8')
} catch (error) {
throw new Error(`cannot read config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`)
}

let backupRecord: Record<string, unknown>
try {
backupRecord = parseJsonc(backupContent) as Record<string, unknown>
} catch (error) {
throw new Error(`cannot parse legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`)
}
if (!isRecord(backupRecord)) {
throw new Error(`legacy backup ${backupPath} is malformed`)
}
const removed: EnforcementRemovedSections = isRecord(backupRecord.removedSections)
? backupRecord.removedSections
: {
plugin: Array.isArray(backupRecord.originalPlugins)
? backupRecord.originalPlugins
: Array.isArray(backupRecord.plugin) ? backupRecord.plugin : [],
}
let currentConfig: Record<string, unknown>
try {
currentConfig = parseJsonc(currentContent) as Record<string, unknown>
} catch (error) {
throw new Error(`cannot parse config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`)
}

const restored = restoreEnforcementSections(currentConfig, removed)
const restoredContent = JSON.stringify(restored, null, 2)
if (restoredContent !== currentContent) {
const mode = await existingFileMode(configPath)
await withOpenCodeConfigLock(() => writeFileAtomic(configPath, restoredContent, { mode }))
}
await fs.rm(backupPath, { force: true })
}

export async function restoreQuarantinedOpenCodePlugins(configHome: string, configPath: string): Promise<void> {
for (const dir of getPluginDirs(configHome)) {
await restorePluginEntries(dir)
Expand All @@ -295,6 +239,6 @@ export async function restoreQuarantinedOpenCodePlugins(configHome: string, conf
await restorePluginEntries(dir)
}
for (const nativeConfigPath of getEnforcementConfigPaths(configHome, configPath)) {
await restoreEnforcementConfigSections(nativeConfigPath)
await restoreLegacyOpenCodeConfigBackup(nativeConfigPath)
}
}
65 changes: 65 additions & 0 deletions backend/test/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createTestDb } from '../helpers/assistant-workspace'
import { createAuth } from '../../src/auth'
import { DatabaseSync } from 'node:sqlite'
import type { Database } from 'bun:sqlite'
import { migrate } from '../../src/db/migration-runner'
import { allMigrations } from '../../src/db/migrations'
import { syncAdminFromEnv } from '../../src/routes/auth'

const { ENV } = vi.hoisted(() => ({
ENV: {
Expand Down Expand Up @@ -36,6 +41,8 @@ function resetEnv(): void {
ENV.SERVER.PORT = 5003
ENV.AUTH.TRUSTED_ORIGINS = 'http://localhost:5173,http://localhost:5003'
ENV.AUTH.SECURE_COOKIES = false
ENV.AUTH.ADMIN_EMAIL = undefined
ENV.AUTH.ADMIN_PASSWORD = undefined
ENV.AUTH.GITHUB_CLIENT_ID = undefined
ENV.AUTH.GITHUB_CLIENT_SECRET = undefined
ENV.AUTH.GOOGLE_CLIENT_ID = undefined
Expand All @@ -44,11 +51,69 @@ function resetEnv(): void {
ENV.AUTH.DISCORD_CLIENT_SECRET = undefined
}

function createAuthDatabase(): Database {
const db = new DatabaseSync(':memory:')
const compatible = Object.assign(db, {
run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params),
}) as unknown as Database
migrate(compatible, allMigrations)
return compatible
}

describe('createAuth', () => {
beforeEach(() => {
resetEnv()
})

it('rejects HTTP signup in admin mode while allowing internal admin provisioning and sign-in', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
ENV.AUTH.ADMIN_PASSWORD = 'admin-password'
const db = createAuthDatabase()
const auth = createAuth(db)

try {
for (const email of ['other@example.com', ENV.AUTH.ADMIN_EMAIL]) {
const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-up/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
body: JSON.stringify({ email, password: 'test-password', name: 'Test' }),
}))
expect(response.status).toBe(403)
}
expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 0 })
await expect(auth.api.signUpEmail({
body: { email: 'other@example.com', password: 'test-password', name: 'Other' },
})).rejects.toThrow('Registration is disabled')

await syncAdminFromEnv(auth, db)
const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
body: JSON.stringify({ email: ENV.AUTH.ADMIN_EMAIL, password: ENV.AUTH.ADMIN_PASSWORD }),
}))
expect(response.status).toBe(200)
expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 1 })
} finally {
db.close()
}
})

it('allows signup without a complete preconfigured admin', async () => {
ENV.AUTH.ADMIN_EMAIL = 'admin@example.com'
const db = createAuthDatabase()
const auth = createAuth(db)
try {
const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-up/email', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' },
body: JSON.stringify({ email: 'other@example.com', password: 'test-password', name: 'Other' }),
}))
expect(response.status).toBe(200)
} finally {
db.close()
}
})

it('creates an auth instance without social providers and responds to requests', async () => {
const db = createTestDb()
const auth = createAuth(db)
Expand Down
Loading
Loading