diff --git a/.env.example b/.env.example index b6307452..873d8410 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,16 @@ LOG_LEVEL=info OPENCODE_SERVER_PORT=5551 OPENCODE_HOST=127.0.0.1 -# Optional - bearer password required to talk to the spawned OpenCode server. -# When set, the backend spawns OpenCode with this password and attaches it to -# every proxied request. Leave unset to disable OpenCode-level auth. +# Optional - bearer password required when OPENCODE_HOST=0.0.0.0 (external exposure). +# The managed OpenCode server will refuse to start if OPENCODE_HOST is not +# localhost/127.0.0.1 and no password is configured (either here or via +# Settings → OpenCode → Server Auth). +# DB-stored passwords (set via UI) override this env var. # OPENCODE_SERVER_PASSWORD= +# Optional - Basic Auth username (default: opencode) +# OPENCODE_SERVER_USERNAME=opencode + # Optional - import an existing standalone OpenCode install on first startup # Useful for Docker when your host OpenCode data is bind-mounted into the container # OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json diff --git a/README.md b/README.md index 410cff26..7c7846fc 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,37 @@ For local development setup, see the [Development Guide](https://chriswritescode ## Features -- **Git** — Multi-repo support, SSH authentication, worktrees, unified diffs with line numbers, PR creation +- **Repositories & Git** — Multi-repo management with local discovery, SSH auth, worktrees, unified diffs, branch/commit management +- **Chat & Sessions** — Real-time SSE streaming, slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams - **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download -- **Chat** — Real-time streaming (SSE), slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams -- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, and markdown-rendered output -- **Audio** — Text-to-speech (browser + OpenAI-compatible), speech-to-text (browser + OpenAI-compatible) -- **AI** — Model selection, provider config, OAuth for Anthropic/GitHub Copilot, custom agents with system prompts -- **MCP** — Local and remote MCP server support with pre-built templates -- **Memory** — Persistent project knowledge with semantic search ([plugin repo](https://github.com/chriswritescode-dev/opencode-memory)) and compaction awareness -- **Mobile** — Responsive UI, PWA installable, iOS-optimized with proper keyboard handling and swipe navigation +- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, markdown-rendered output +- **AI & OpenCode** — Model/provider configuration, OAuth for Anthropic/GitHub Copilot, custom agents, OpenCode server supervision and proxying +- **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) +- **Mobile & Notifications** — Responsive PWA, mobile-first navigation, push notification support + +## Architecture + +OpenCode Manager is a pnpm workspace with three TypeScript packages: + +- `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications. +- `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation. +- `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend. + +A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting. + +## Development + +This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`. + +```bash +pnpm install +pnpm dev +pnpm lint +pnpm typecheck +pnpm test +``` + +See the [Development Guide](https://chriswritescode-dev.github.io/opencode-manager/development/setup/) for local setup, scripts, database notes, and testing. ## Configuration diff --git a/backend/package.json b/backend/package.json index ddbfc8b1..45562a3a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,9 +8,9 @@ "start": "bun src/index.ts", "build": "bun build src/index.ts --outdir=dist --target=bun", "typecheck": "tsc --noEmit", - "test": "bun test src/", - "test:vitest": "vitest", - "test:all": "bun test src/ && vitest test/", + "test": "pnpm run test:bun && pnpm run test:vitest", + "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts src/db/model-state.test.ts src/routes/providers.test.ts", + "test:vitest": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest --watch", "lint": "eslint . --ext .ts", diff --git a/backend/src/auth/internal-token-middleware.ts b/backend/src/auth/internal-token-middleware.ts new file mode 100644 index 00000000..591f0ce2 --- /dev/null +++ b/backend/src/auth/internal-token-middleware.ts @@ -0,0 +1,19 @@ +import { createMiddleware } from 'hono/factory' +import { timingSafeEqual } from 'node:crypto' +import type { Database } from 'bun:sqlite' +import { getOrCreateInternalToken } from '../services/internal-token' + +export function createInternalTokenMiddleware(db: Database) { + return createMiddleware(async (c, next) => { + const header = c.req.header('authorization') ?? c.req.header('Authorization') + if (!header || !header.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401) + } + const provided = Buffer.from(header.slice(7)) + const expected = Buffer.from(getOrCreateInternalToken(db)) + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return c.json({ error: 'Unauthorized' }, 401) + } + await next() + }) +} diff --git a/backend/src/db/migrations/013-app-secrets.ts b/backend/src/db/migrations/013-app-secrets.ts new file mode 100644 index 00000000..ce64c2cd --- /dev/null +++ b/backend/src/db/migrations/013-app-secrets.ts @@ -0,0 +1,21 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + version: 13, + name: 'app-secrets', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS app_secrets ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + }, + down(db) { + db.run('DROP TABLE IF EXISTS app_secrets') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 6cc1089f..4f05d4d2 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -11,6 +11,7 @@ import migration009 from './009-repo-source-path' import migration010 from './009-prompt-templates' import migration011 from './011-repo-last-accessed' import migration012 from './012-opencode-model-state' +import migration013 from './013-app-secrets' export const allMigrations: Migration[] = [ migration001, @@ -25,4 +26,5 @@ export const allMigrations: Migration[] = [ migration010, migration011, migration012, + migration013, ] diff --git a/backend/src/index.ts b/backend/src/index.ts index c05867c5..fc8911cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,17 +29,17 @@ import { createOAuthRoutes } from './routes/oauth' import { createSSERoutes } from './routes/sse' import { createSSHRoutes } from './routes/ssh' import { createNotificationRoutes } from './routes/notifications' -import { createMemoryRoutes } from './routes/memory' import { createMcpOauthProxyRoutes } from './routes/mcp-oauth-proxy' import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth' import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' +import { createInternalRoutes } from './routes/internal' import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations' import { SettingsService } from './services/settings' import { opencodeServerManager } from './services/opencode-single-server' -import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './services/proxy' +import { createOpenCodeClient } from './services/opencode/client' import { NotificationService } from './services/notification' import { ScheduleRunner, ScheduleService } from './services/schedules' import { migrateGlobalSkills } from './services/skills' @@ -85,6 +85,7 @@ app.use('/*', cors({ const db = initializeDatabase(DB_PATH) const auth = createAuth(db) const requireAuth = createAuthMiddleware(auth) +const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword()) import { DEFAULT_AGENTS_MD } from './constants' @@ -262,6 +263,8 @@ try { await gitAuthService.initialize(ipcServer, db) logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`) + await syncAdminFromEnv(auth, db) + opencodeServerManager.setDatabase(db) const openCodeStatus = await openCodeSupervisor.start() if (openCodeStatus.healthy) { @@ -270,12 +273,11 @@ try { logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`) } - await syncAdminFromEnv(auth, db) } catch (error) { logger.error('Failed to initialize workspace:', error) } -const scheduleService = new ScheduleService(db) +const scheduleService = new ScheduleService(db, openCodeClient) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) const notificationService = new NotificationService(db) @@ -299,28 +301,34 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { }) } +sseAggregator.setPendingActionsFetcher(openCodeClient) +sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) +sseAggregator.start() + void scheduleRunnerInstance.start() +const settingsService = new SettingsService(db) + app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) -app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(requireAuth)) +app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) -protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeSupervisor)) -protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeSupervisor)) +protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor)) +protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor)) protectedApi.route('/files', createFileRoutes()) -protectedApi.route('/providers', createProvidersRoutes(db, openCodeSupervisor)) -protectedApi.route('/oauth', createOAuthRoutes(openCodeSupervisor)) +protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor)) +protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor)) protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) protectedApi.route('/sse', createSSERoutes()) protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) -protectedApi.route('/memory', createMemoryRoutes(db)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) @@ -329,18 +337,17 @@ app.route('/api', protectedApi) app.post('/api/opencode/mcp/:name/auth', requireAuth, async (c) => { const serverName = c.req.param('name') const directory = c.req.query('directory') - return proxyMcpAuthStart(serverName, directory) + return openCodeClient.startMcpAuth(serverName, directory) }) app.post('/api/opencode/mcp/:name/auth/authenticate', requireAuth, async (c) => { const serverName = c.req.param('name') const directory = c.req.query('directory') - return proxyMcpAuthAuthenticate(serverName, directory) + return openCodeClient.authenticateMcp(serverName, directory) }) app.all('/api/opencode/*', requireAuth, async (c) => { - const request = c.req.raw - return proxyRequest(request) + return openCodeClient.forwardRaw(c.req.raw) }) const isProduction = ENV.SERVER.NODE_ENV === 'production' diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts new file mode 100644 index 00000000..9dbb2fde --- /dev/null +++ b/backend/src/routes/internal/index.ts @@ -0,0 +1,26 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import type { ScheduleService } from '../../services/schedules' +import type { NotificationService } from '../../services/notification' +import type { SettingsService } from '../../services/settings' +import { createScheduleRoutes } from '../schedules' +import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware' +import { createInternalNotificationRoutes } from './notifications' +import { createInternalSettingsRoutes } from './settings' + +export function createInternalRoutes( + db: Database, + scheduleService: ScheduleService, + notificationService: NotificationService, + settingsService: SettingsService, +) { + const app = new Hono() + app.use('/*', createInternalTokenMiddleware(db)) + app.route('/schedules', createScheduleRoutes(scheduleService)) + app.route('/notifications', createInternalNotificationRoutes(notificationService)) + app.route('/settings', createInternalSettingsRoutes(settingsService)) + const repos = new Hono() + repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) + app.route('/repos', repos) + return app +} diff --git a/backend/src/routes/internal/notifications.ts b/backend/src/routes/internal/notifications.ts new file mode 100644 index 00000000..991c2a8a --- /dev/null +++ b/backend/src/routes/internal/notifications.ts @@ -0,0 +1,57 @@ +import { Hono } from 'hono' +import { AssistantNotificationRequestSchema } from '@opencode-manager/shared/schemas' +import type { NotificationService } from '../../services/notification' +import { TokenBucketRateLimiter } from '../../utils/rate-limit' + +export function createInternalNotificationRoutes(notificationService: NotificationService) { + const app = new Hono() + const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 60_000 }) + + app.post('/send', async (c) => { + if (!notificationService.isConfigured()) { + return c.json({ error: 'Push notifications are not configured (missing VAPID env)' }, 503) + } + + const token = (c.req.header('authorization') ?? '').slice('Bearer '.length) + const limit = limiter.tryConsume(token || 'anon') + if (!limit.allowed) { + c.header('Retry-After', String(Math.ceil(limit.retryAfterMs / 1000))) + return c.json({ error: 'Rate limit exceeded' }, 429) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = AssistantNotificationRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) + } + + const userId = c.req.query('userId') ?? 'default' + + const payload = { + title: parsed.data.title, + body: parsed.data.body, + tag: parsed.data.tag ?? `assistant-${Date.now()}`, + data: { + eventType: 'assistant.message', + url: parsed.data.url ?? '/', + priority: parsed.data.priority, + }, + } + + const stats = await notificationService.sendToUser(userId, payload) + return c.json({ + delivered: stats.delivered, + expired: stats.expired, + failed: stats.failed, + noSubscriptions: stats.total === 0, + }) + }) + + return app +} diff --git a/backend/src/routes/internal/settings.ts b/backend/src/routes/internal/settings.ts new file mode 100644 index 00000000..a9de4c9a --- /dev/null +++ b/backend/src/routes/internal/settings.ts @@ -0,0 +1,35 @@ +import { Hono } from 'hono' +import { AssistantSettingsPatchSchema } from '@opencode-manager/shared/schemas' +import type { SettingsService } from '../../services/settings' +import type { UserPreferences } from '@opencode-manager/shared/types' + +export function createInternalSettingsRoutes(settingsService: SettingsService) { + const app = new Hono() + + app.get('/', (c) => { + const userId = c.req.query('userId') ?? 'default' + const settings = settingsService.getSettings(userId) + return c.json(settings) + }) + + app.patch('/', async (c) => { + const userId = c.req.query('userId') ?? 'default' + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = AssistantSettingsPatchSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400) + } + + const updated = settingsService.updateSettings(parsed.data as Partial, userId) + return c.json(updated) + }) + + return app +} diff --git a/backend/src/routes/mcp-oauth-proxy.ts b/backend/src/routes/mcp-oauth-proxy.ts index a193a649..6b84d0b2 100644 --- a/backend/src/routes/mcp-oauth-proxy.ts +++ b/backend/src/routes/mcp-oauth-proxy.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import type { OpenCodeClient } from '../services/opencode/client' import { z } from 'zod' import crypto from 'crypto' import path from 'path' @@ -6,7 +7,6 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { storeMcpOAuthFlow, consumeMcpOAuthFlow, deleteMcpOAuthFlow, markMcpOAuthFlowCompleted, markMcpOAuthFlowFailed, getMcpOAuthFlowResult } from '../services/mcp-oauth-state' import { logger } from '../utils/logger' import { getWorkspacePath } from '@opencode-manager/shared/config/env' -import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy' const StartSchema = z.object({ serverName: z.string(), @@ -132,8 +132,10 @@ async function registerClient( return result } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createMcpOauthProxyRoutes(requireAuth?: any) { + +import type { MiddlewareHandler } from 'hono' + +export function createMcpOauthProxyRoutes(openCodeClient: OpenCodeClient, requireAuth?: MiddlewareHandler) { const app = new Hono() if (requireAuth) { @@ -292,21 +294,15 @@ export function createMcpOauthProxyRoutes(requireAuth?: any) { markMcpOAuthFlowCompleted(state, flow.serverName) try { - let reconnectUrl = `${OPENCODE_SERVER_URL}/mcp/${encodeURIComponent(flow.serverName)}/connect` - if (flow.directory) { - const url = new URL(reconnectUrl) - url.searchParams.set('directory', flow.directory) - reconnectUrl = url.toString() - } - await fetch(reconnectUrl, { + await openCodeClient.forward({ method: 'POST', - headers: withOpenCodeAuth(), + path: `/mcp/${encodeURIComponent(flow.serverName)}/connect`, + directory: flow.directory, }) if (flow.directory) { - const globalReconnectUrl = `${OPENCODE_SERVER_URL}/mcp/${encodeURIComponent(flow.serverName)}/connect` - await fetch(globalReconnectUrl, { + await openCodeClient.forward({ method: 'POST', - headers: withOpenCodeAuth(), + path: `/mcp/${encodeURIComponent(flow.serverName)}/connect`, }) } } catch { diff --git a/backend/src/routes/memory.ts b/backend/src/routes/memory.ts deleted file mode 100644 index 68f75576..00000000 --- a/backend/src/routes/memory.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs' -import { join } from 'path' -import { logger } from '../utils/logger' -import { PluginMemoryService } from '../services/plugin-memory' -import { resolveProjectId } from '../services/project-id-resolver' -import { getRepoById } from '../db/queries' -import { getWorkspacePath, getConfigPath } from '@opencode-manager/shared/config/env' -import { parseJsonc } from '@opencode-manager/shared/utils' -import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy' -import { - CreateMemoryRequestSchema, - UpdateMemoryRequestSchema, - MemoryListQuerySchema, - KvListQuerySchema, - PluginConfigSchema, - CreateKvEntryRequestSchema, - UpdateKvEntryRequestSchema, - LoopStateSchema, - type PluginConfig, - type LoopState, -} from '@opencode-manager/shared/schemas' - -function resolveMemoryDataDir(): string { - return join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'memory') -} - -function resolvePluginConfigPath(): string { - return join(getConfigPath(), 'memory-config.jsonc') -} - -function resolveOldPluginConfigPath(): string { - return join(resolveMemoryDataDir(), 'config.json') -} - -function getDefaultPluginConfig(): PluginConfig { - return { - embedding: { - provider: 'local', - model: 'all-MiniLM-L6-v2', - dimensions: 384, - }, - dedupThreshold: 0.25, - } -} - -function loadPluginConfigFromDisk(): PluginConfig { - const configPath = resolvePluginConfigPath() - - if (!existsSync(configPath)) { - const oldPath = resolveOldPluginConfigPath() - if (existsSync(oldPath)) { - const configDir = getConfigPath() - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } - copyFileSync(oldPath, configPath) - } else { - return getDefaultPluginConfig() - } - } - - try { - const content = readFileSync(configPath, 'utf-8') - const parsed = parseJsonc(content) - const result = PluginConfigSchema.safeParse(parsed) - - if (!result.success) { - logger.error('Invalid plugin config:', result.error) - return getDefaultPluginConfig() - } - - return result.data - } catch (error) { - logger.error('Failed to load plugin config:', error) - return getDefaultPluginConfig() - } -} - -function savePluginConfigToDisk(config: PluginConfig): void { - const configPath = resolvePluginConfigPath() - const configDir = getConfigPath() - - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } - - // Read existing content to preserve comments - let existingContent = '' - if (existsSync(configPath)) { - try { - existingContent = readFileSync(configPath, 'utf-8') - } catch { - // File doesn't exist or can't be read, will create new - } - } - - // If we have existing content, preserve comments by extracting them - // and re-adding them to the new content - if (existingContent) { - // Extract comments from existing content - const lines = existingContent.split('\n') - const commentLines: string[] = [] - - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('//')) { - commentLines.push(line.match(/\/\/\s*(.*)/)?.[1] || '') - } - } - - // Create new JSON string - const newContent = JSON.stringify(config, null, 2) - - // If we found comments, try to preserve them - if (commentLines.length > 0) { - const newLines = newContent.split('\n') - const result: string[] = [] - - // Add comments at the beginning - for (const comment of commentLines) { - result.push(`// ${comment}`) - } - - result.push(...newLines) - writeFileSync(configPath, result.join('\n'), 'utf-8') - return - } - - writeFileSync(configPath, newContent, 'utf-8') - } else { - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') - } -} - -export function createMemoryRoutes(db: Database): Hono { - const app = new Hono() - const pluginMemory = new PluginMemoryService() - - app.get('/', async (c) => { - const query = c.req.query() - const parsed = MemoryListQuerySchema.safeParse({ - projectId: query.projectId, - scope: query.scope, - content: query.content, - limit: query.limit ? parseInt(query.limit, 10) : undefined, - offset: query.offset ? parseInt(query.offset, 10) : undefined, - }) - - if (!parsed.success) { - return c.json({ error: 'Invalid query parameters', details: parsed.error }, 400) - } - - const filters = parsed.data - - if (!filters.projectId) { - return c.json({ memories: [] }) - } - - const memories = pluginMemory.list(filters.projectId, { - scope: filters.scope, - content: filters.content, - limit: filters.limit, - offset: filters.offset, - }) - - return c.json({ memories }) - }) - - app.post('/', async (c) => { - const body = await c.req.json() - const parsed = CreateMemoryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - const id = pluginMemory.create(parsed.data) - const memory = pluginMemory.getById(id) - - if (!memory) { - return c.json({ error: 'Failed to retrieve created memory' }, 500) - } - - return c.json({ memory }, 201) - } catch (error) { - logger.error('Failed to create memory:', error) - return c.json({ error: 'Failed to create memory' }, 500) - } - }) - - app.get('/project-summary', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId parameter' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ projectId: null, stats: { total: 0, byScope: {} }, error: 'Repository not found' }, 404) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ projectId: null, stats: { total: 0, byScope: {} } }) - } - - const stats = pluginMemory.getStats(projectId) - const kvCount = pluginMemory.getKvCount(projectId) - - return c.json({ projectId, stats, kvCount }) - } catch (error) { - logger.error('Failed to get project summary:', error) - return c.json({ projectId: null, stats: { total: 0, byScope: {} }, error: 'Failed to get project summary' }, 500) - } - }) - - app.get('/stats', async (c) => { - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - try { - const stats = pluginMemory.getStats(projectId) - return c.json(stats) - } catch (error) { - logger.error('Failed to get memory stats:', error) - return c.json({ error: 'Failed to get stats' }, 500) - } - }) - - app.get('/resolve-project', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId parameter' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ projectId: null, error: 'Repository not found' }, 404) - } - - const projectId = await resolveProjectId(repo.fullPath) - - return c.json({ projectId }) - } catch (error) { - logger.error('Failed to resolve project ID:', error) - return c.json({ projectId: null, error: 'Failed to resolve project ID' }, 500) - } - }) - - app.get('/plugin-config', async (c) => { - try { - const config = loadPluginConfigFromDisk() - return c.json({ config }) - } catch (error) { - logger.error('Failed to get plugin config:', error) - return c.json({ error: 'Failed to get plugin config' }, 500) - } - }) - - app.put('/plugin-config', async (c) => { - try { - const body = await c.req.json() - const parsed = PluginConfigSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid config', details: parsed.error.flatten() }, 400) - } - - const config = parsed.data - config.dedupThreshold = Math.max(0.05, Math.min(0.4, config.dedupThreshold ?? 0.25)) - - savePluginConfigToDisk(config) - - return c.json({ success: true, config }) - } catch (error) { - logger.error('Failed to save plugin config:', error) - return c.json({ error: 'Failed to save plugin config' }, 500) - } - }) - - app.post('/test-embedding', async (c) => { - try { - const config = loadPluginConfigFromDisk() - - if (config.embedding.provider === 'local') { - const validModels = ['all-MiniLM-L6-v2'] - if (!validModels.includes(config.embedding.model)) { - return c.json({ - success: false, - error: `Invalid model: ${config.embedding.model}. Valid models: ${validModels.join(', ')}` - }, 400) - } - return c.json({ - success: true, - message: 'Local provider configured. Model will be loaded on server restart.', - dimensions: config.embedding.dimensions ?? 384, - }) - } - - const endpoints: Record = { - openai: 'https://api.openai.com/v1/embeddings', - voyage: 'https://api.voyageai.com/v1/embeddings', - } - - const extractHost = (url: string): string => { - const protocolEnd = url.indexOf('://') - if (protocolEnd === -1) return url - const pathStart = url.indexOf('/', protocolEnd + 3) - return pathStart === -1 ? url : url.slice(0, pathStart) - } - - const baseUrl = extractHost(config.embedding.baseUrl || '') - const endpoint = baseUrl - ? `${baseUrl}/v1/embeddings` - : endpoints[config.embedding.provider] ?? '' - - if (!endpoint) { - return c.json({ success: false, error: 'No endpoint configured' }, 400) - } - - if (!config.embedding.apiKey) { - return c.json({ success: false, error: 'API key not configured. Please save your API key first.' }, 400) - } - - const testBody = { - model: config.embedding.model, - input: ['test'], - } - - const headers: Record = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${config.embedding.apiKey}`, - } - - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify(testBody), - }) - - if (!response.ok) { - const errorText = await response.text() - return c.json({ - success: false, - error: `API error: ${response.status}`, - message: errorText, - }, 400) - } - - const data = await response.json() as { - data?: Array<{ embedding: number[] }> - embeddings?: Array<{ embedding: number[] }> - } - - const embeddings = data.data || data.embeddings - if (!embeddings || embeddings.length === 0 || !embeddings[0]) { - return c.json({ success: false, error: 'Invalid response from API' }, 400) - } - - const firstEmbedding = embeddings[0] - const actualDimensions = firstEmbedding.embedding.length - - return c.json({ - success: true, - message: `Embedding test successful. Generated ${actualDimensions}d embedding.`, - dimensions: actualDimensions, - }) - } catch (error) { - logger.error('Failed to test embedding config:', error) - return c.json({ - success: false, - error: 'Failed to test embedding configuration', - message: error instanceof Error ? error.message : 'Unknown error' - }, 500) - } - }) - - app.post('/reindex', async (c) => { - try { - const db = pluginMemory.getDb() - - if (!db) { - return c.json({ - error: 'Memory database not found. Make sure the memory plugin has been initialized.', - total: 0, - embedded: 0, - failed: 0 - }, 404) - } - - const memories = pluginMemory.listAll() - - if (memories.length === 0) { - return c.json({ - success: true, - message: 'No memories to reindex', - total: 0, - embedded: 0, - failed: 0 - }) - } - - try { - db.exec('DELETE FROM memory_embeddings') - } catch { - return c.json({ - success: true, - message: 'Cleared embeddings. Server restart required to regenerate embeddings with new model.', - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } - - return c.json({ - success: true, - message: `Cleared ${memories.length} embeddings. Server restart required to regenerate embeddings.`, - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } catch (error) { - logger.error('Failed to reindex memories:', error) - return c.json({ error: 'Failed to reindex memories', details: error instanceof Error ? error.message : 'Unknown error' }, 500) - } - }) - - app.get('/kv', async (c) => { - const query = c.req.query() - const parsed = KvListQuerySchema.safeParse({ - projectId: query.projectId, - prefix: query.prefix, - }) - - if (!parsed.success) { - return c.json({ error: 'Invalid query parameters', details: parsed.error }, 400) - } - - const { projectId, prefix } = parsed.data - const entries = pluginMemory.listKv(projectId, prefix) - return c.json({ entries }) - }) - - app.post('/kv', async (c) => { - const body = await c.req.json() - const parsed = CreateKvEntryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.setKv(parsed.data.projectId, parsed.data.key, parsed.data.data, parsed.data.ttlMs) - const entry = pluginMemory.getKv(parsed.data.projectId, parsed.data.key) - return c.json({ entry }, 201) - } catch (error) { - logger.error('Failed to create KV entry:', error) - return c.json({ error: 'Failed to create KV entry' }, 500) - } - }) - - app.put('/kv/:key', async (c) => { - const key = decodeURIComponent(c.req.param('key')) - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - const body = await c.req.json() - const parsed = UpdateKvEntryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.setKv(projectId, key, parsed.data.data, parsed.data.ttlMs) - const entry = pluginMemory.getKv(projectId, key) - return c.json({ entry }) - } catch (error) { - logger.error('Failed to update KV entry:', error) - return c.json({ error: 'Failed to update KV entry' }, 500) - } - }) - - app.delete('/kv/:key', async (c) => { - const key = decodeURIComponent(c.req.param('key')) - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - try { - pluginMemory.deleteKv(projectId, key) - return c.json({ success: true }) - } catch (error) { - logger.error('Failed to delete KV entry:', error) - return c.json({ error: 'Failed to delete KV entry' }, 500) - } - }) - - app.get('/loop/status', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ loops: [], projectId: null }) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ loops: [], projectId: null }) - } - - const entries = pluginMemory.listKv(projectId, 'loop:') - const loops = entries - .map(e => e.data) - .filter((data): data is Record => - data !== null && typeof data === 'object' && 'active' in data - ) - .map(data => { - const result = LoopStateSchema.safeParse(data) - return result.success ? result.data : null - }) - .filter((loop): loop is LoopState => loop !== null) - - return c.json({ loops, projectId }) - } catch (error) { - logger.error('Failed to get Loop:', error) - return c.json({ error: 'Failed to get Loop' }, 500) - } - }) - - app.post('/loop/cancel', async (c) => { - try { - const body = await c.req.json() - const { repoId, worktreeName, sessionId } = body - - if (!repoId || (!worktreeName && !sessionId)) { - return c.json({ error: 'Missing repoId or identifier (worktreeName or sessionId)' }, 400) - } - - const repo = getRepoById(db, parseInt(repoId, 10)) - - if (!repo) { - return c.json({ cancelled: false }) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ cancelled: false }) - } - - let worktreeNameToUse: string | undefined - - if (worktreeName) { - worktreeNameToUse = worktreeName - } else if (sessionId) { - const sessionMappingEntry = pluginMemory.getKv(projectId, `loop-session:${sessionId}`) - if (!sessionMappingEntry) { - return c.json({ cancelled: false }) - } - worktreeNameToUse = sessionMappingEntry.data as string - } - - if (!worktreeNameToUse) { - return c.json({ cancelled: false }) - } - - const kvEntry = pluginMemory.getKv(projectId, `loop:${worktreeNameToUse}`) - if (!kvEntry) { - return c.json({ cancelled: false }) - } - - const result = LoopStateSchema.safeParse(kvEntry.data) - - if (!result.success) { - logger.warn('Failed to parse Loop state for cancel:', result.error) - return c.json({ cancelled: false }) - } - - const state = result.data - - if (!state.active) { - return c.json({ cancelled: false }) - } - - const updatedState = { - ...state, - active: false, - terminationReason: 'cancelled', - completedAt: new Date().toISOString(), - } - - pluginMemory.setKv(projectId, `loop:${worktreeNameToUse}`, updatedState) - - try { - const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${state.sessionId}/abort`) - abortUrl.searchParams.set('directory', repo.fullPath) - await fetch(abortUrl.toString(), { method: 'POST', headers: withOpenCodeAuth() }) - } catch { - // Session may already be idle - } - - return c.json({ cancelled: true, worktreeName: state.worktreeName }) - } catch (error) { - logger.error('Failed to cancel Loop:', error) - return c.json({ error: 'Failed to cancel Loop' }, 500) - } - }) - - app.get('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - const memory = pluginMemory.getById(id) - - if (!memory) { - return c.json({ error: 'Memory not found' }, 404) - } - - return c.json({ memory }) - }) - - app.put('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - const body = await c.req.json() - const parsed = UpdateMemoryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.update(id, parsed.data) - const memory = pluginMemory.getById(id) - return c.json({ memory }) - } catch (error) { - logger.error('Failed to update memory:', error) - return c.json({ error: 'Failed to update memory' }, 500) - } - }) - - app.delete('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - try { - pluginMemory.delete(id) - return c.json({ success: true }) - } catch (error) { - logger.error('Failed to delete memory:', error) - return c.json({ error: 'Failed to delete memory' }, 500) - } - }) - - return app -} diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 571c2dd7..78463f55 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' +import type { OpenCodeClient } from '../services/opencode/client' import { z } from 'zod' -import { proxyRequest, OPENCODE_SERVER_URL } from '../services/proxy' import { logger } from '../utils/logger' import { OAuthAuthorizeRequestSchema, @@ -19,7 +19,7 @@ async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Pr await opencodeServerManager.reloadConfig() } -export function createOAuthRoutes(openCodeSupervisor?: OpenCodeSupervisor) { +export function createOAuthRoutes(openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() app.post('/:id/oauth/authorize', async (c) => { @@ -28,17 +28,12 @@ export function createOAuthRoutes(openCodeSupervisor?: OpenCodeSupervisor) { const body = await c.req.json() const validated = OAuthAuthorizeRequestSchema.parse(body) - // Proxy to OpenCode server - only method and inputs are supported - const response = await proxyRequest( - new Request( - `${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/authorize`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validated) - } - ) - ) + const response = await openCodeClient.forward({ + method: 'POST', + path: `/provider/${encodeURIComponent(providerId)}/oauth/authorize`, + body: JSON.stringify(validated), + headers: { 'Content-Type': 'application/json' }, + }) if (!response.ok) { const error = await response.text() @@ -65,17 +60,12 @@ export function createOAuthRoutes(openCodeSupervisor?: OpenCodeSupervisor) { const body = await c.req.json() const validated = OAuthCallbackRequestSchema.parse(body) - // Proxy to OpenCode server - const response = await proxyRequest( - new Request( - `${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/callback`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validated) - } - ) - ) + const response = await openCodeClient.forward({ + method: 'POST', + path: `/provider/${encodeURIComponent(providerId)}/oauth/callback`, + body: JSON.stringify(validated), + headers: { 'Content-Type': 'application/json' }, + }) if (!response.ok) { const error = await response.text() @@ -103,13 +93,10 @@ export function createOAuthRoutes(openCodeSupervisor?: OpenCodeSupervisor) { app.get('/auth-methods', async (c) => { try { - // Proxy to OpenCode server - const response = await proxyRequest( - new Request(`${OPENCODE_SERVER_URL}/provider/auth`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - }) - ) + const response = await openCodeClient.forward({ + method: 'GET', + path: '/provider/auth', + }) if (!response.ok) { const error = await response.text() diff --git a/backend/src/routes/providers.test.ts b/backend/src/routes/providers.test.ts index 06f8698e..21a50147 100644 --- a/backend/src/routes/providers.test.ts +++ b/backend/src/routes/providers.test.ts @@ -7,10 +7,11 @@ import { createProvidersRoutes } from './providers' import { join, dirname } from 'node:path' import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' function createTestApp(db: Database): Hono { const app = new Hono() - app.route('/providers', createProvidersRoutes(db, undefined)) + app.route('/providers', createProvidersRoutes(db, createStubOpenCodeClient())) return app } diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index 8b851e24..e14f7009 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -4,7 +4,7 @@ import path from 'path' import { AuthService } from '../services/auth' import { SetCredentialRequestSchema } from '../../../shared/src/schemas/auth' import { logger } from '../utils/logger' -import { setOpenCodeAuth, deleteOpenCodeAuth } from '../services/proxy' +import type { OpenCodeClient } from '../services/opencode/client' import { opencodeServerManager } from '../services/opencode-single-server' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import type { Database } from 'bun:sqlite' @@ -61,7 +61,7 @@ async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Pr await opencodeServerManager.reloadConfig() } -export function createProvidersRoutes(db: Database, openCodeSupervisor?: OpenCodeSupervisor) { +export function createProvidersRoutes(db: Database, openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const authService = new AuthService() @@ -129,7 +129,7 @@ export function createProvidersRoutes(db: Database, openCodeSupervisor?: OpenCod const body = await c.req.json() const validated = SetCredentialRequestSchema.parse(body) - const openCodeSuccess = await setOpenCodeAuth(providerId, validated.apiKey) + const openCodeSuccess = await openCodeClient.setProviderAuth(providerId, validated.apiKey) if (!openCodeSuccess) { logger.warn(`Failed to set OpenCode auth for ${providerId}, saving locally only`) } @@ -156,7 +156,7 @@ export function createProvidersRoutes(db: Database, openCodeSupervisor?: OpenCod try { const providerId = c.req.param('id') - const openCodeSuccess = await deleteOpenCodeAuth(providerId) + const openCodeSuccess = await openCodeClient.deleteProviderAuth(providerId) if (!openCodeSuccess) { logger.warn(`Failed to delete OpenCode auth for ${providerId}, removing locally only`) } diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index b7c3bf7a..559eee52 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -9,7 +9,7 @@ import { SettingsService } from '../services/settings' import { writeFileContent } from '../services/file-operations' import { opencodeServerManager } from '../services/opencode-single-server' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' -import { proxyToOpenCodeWithDirectory } from '../services/proxy' +import type { OpenCodeClient } from '../services/opencode/client' import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env' @@ -34,6 +34,7 @@ export function createRepoRoutes( database: Database, gitAuthService: GitAuthService, scheduleService: ScheduleService, + openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor, ) { const app = new Hono() @@ -373,11 +374,11 @@ app.get('/', async (c) => { return c.json({ error: 'Repo not found' }, 404) } - const response = await proxyToOpenCodeWithDirectory( - '/instance/dispose', - 'POST', - repo.fullPath - ) + const response = await openCodeClient.forward({ + method: 'POST', + path: '/instance/dispose', + directory: repo.fullPath, + }) if (!response.ok) { const errorText = await response.text() @@ -421,8 +422,11 @@ app.get('/', async (c) => { const body = await c.req.json().catch(() => ({})) const options = AssistantModeInitRequestSchema.parse(body) + const protocol = c.req.header('x-forwarded-proto') || 'http' + const host = c.req.header('host') || 'localhost:5003' + const apiBaseUrl = `${protocol}://${host}/api/internal` - const status = await ensureAssistantMode(repo, options) + const status = await ensureAssistantMode(repo, { db: database, apiBaseUrl }, options) return c.json(status) } catch (error: unknown) { logger.error('Failed to initialize assistant mode:', error) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3895a760..745fa2f6 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -6,7 +6,8 @@ import { resolve, dirname } from 'path' import type { Database } from 'bun:sqlite' import { SettingsService } from '../services/settings' import { writeFileContent, readFileContent, fileExists } from '../services/file-operations' -import { patchOpenCodeConfig, proxyToOpenCodeWithDirectory } from '../services/proxy' +import { patchConfigWithRecovery } from '../services/opencode/config-recovery' +import type { OpenCodeClient } from '../services/opencode/client' import { getOpenCodeConfigFilePath, getAgentsMdPath } from '@opencode-manager/shared/config/env' import { UserPreferencesSchema, @@ -20,6 +21,7 @@ import { } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' +import { sseAggregator } from '../services/sse-aggregator' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' @@ -28,6 +30,7 @@ import { encryptSecret } from '../utils/crypto' import { compareVersions, isValidVersion } from '../utils/version-utils' import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../services/opencode-import' import { relinkReposFromSessionDirectories } from '../services/repo' +import { ENV } from '@opencode-manager/shared/config/env' import { listManagedSkills, getSkill, @@ -219,7 +222,7 @@ async function extractOpenCodeError(response: Response, defaultError: string): P : defaultError } -export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService, openCodeSupervisor?: OpenCodeSupervisor) { +export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService, openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const settingsService = new SettingsService(db) @@ -234,19 +237,6 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) - app.get('/memory-plugin-status', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const configs = settingsService.getOpenCodeConfigs(userId) - const defaultConfig = configs.configs.find((cfg: { isDefault: boolean }) => cfg.isDefault) - const isEnabled = ((defaultConfig?.content?.plugin as string[] | undefined) ?? []).includes('@opencode-manager/memory') - return c.json({ memoryPluginEnabled: isEnabled }) - } catch (error) { - logger.error('Failed to get memory plugin status:', error) - return c.json({ error: 'Failed to get memory plugin status' }, 500) - } - }) - app.patch('/', async (c) => { try { const userId = c.req.query('userId') || 'default' @@ -371,7 +361,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json(config) } - const patchResult = await patchOpenCodeConfig(provisionalConfig.content) + const patchResult = await patchConfigWithRecovery(openCodeClient, provisionalConfig.content) if (!patchResult.success) { settingsService.deleteOpenCodeConfig(provisionalConfig.name, userId) return c.json({ @@ -448,7 +438,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic opencodeServerManager.clearStartupError() await restartOpenCode(openCodeSupervisor) } else { - const patchResult = await patchOpenCodeConfig(config.content) + const patchResult = await patchConfigWithRecovery(openCodeClient, config.content) if (!patchResult.success) { return c.json({ error: 'Config saved but failed to apply', @@ -459,7 +449,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } const contentToWrite = patchResult.removedFields && patchResult.removedFields.length > 0 - ? JSON.stringify(config.content, null, 2) + ? JSON.stringify(patchResult.appliedConfig ?? config.content, null, 2) : config.rawContent await writeFileContent(configPath, contentToWrite) @@ -526,7 +516,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json(config) } - const patchResult = await patchOpenCodeConfig(existingConfig.content) + const patchResult = await patchConfigWithRecovery(openCodeClient, existingConfig.content) if (!patchResult.success) { return c.json({ error: 'Config validation failed', @@ -1137,7 +1127,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json({ error: 'Invalid repoId' }, 400) } - const skills = await listManagedSkills(db, repoId) + const skills = await listManagedSkills(db, openCodeClient, repoId) return c.json(skills) } catch (error) { logger.error('Failed to list skills:', error) @@ -1159,7 +1149,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json({ error: 'repoId is required for project scope' }, 400) } - const skill = await getSkill(db, name, scope, repoId) + const skill = await getSkill(db, openCodeClient, name, scope, repoId) return c.json(skill) } catch (error) { logger.error('Failed to get skill:', error) @@ -1219,7 +1209,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json({ error: 'repoId is required for project scope' }, 400) } - const skill = await updateSkill(db, name, scope, validated, repoId) + const skill = await updateSkill(db, openCodeClient, name, scope, validated, repoId) try { await restartOpenCode(openCodeSupervisor) @@ -1406,11 +1396,11 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const body = await c.req.json() const { directory } = ConnectMcpDirectorySchema.parse(body) - const response = await proxyToOpenCodeWithDirectory( - `/mcp/${encodeURIComponent(serverName)}/connect`, - 'POST', - directory - ) + const response = await (openCodeClient).forward({ + method: 'POST', + path: `/mcp/${encodeURIComponent(serverName)}/connect`, + directory, + }) if (!response.ok) { const errorMsg = await extractOpenCodeError(response, 'Failed to connect MCP server') @@ -1433,11 +1423,11 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const body = await c.req.json() const { directory } = ConnectMcpDirectorySchema.parse(body) - const response = await proxyToOpenCodeWithDirectory( - `/mcp/${encodeURIComponent(serverName)}/disconnect`, - 'POST', - directory - ) + const response = await (openCodeClient).forward({ + method: 'POST', + path: `/mcp/${encodeURIComponent(serverName)}/disconnect`, + directory, + }) if (!response.ok) { const errorMsg = await extractOpenCodeError(response, 'Failed to disconnect MCP server') @@ -1460,11 +1450,11 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const body = await c.req.json() const { directory } = McpAuthDirectorySchema.parse(body) - const response = await proxyToOpenCodeWithDirectory( - `/mcp/${encodeURIComponent(serverName)}/auth/authenticate`, - 'POST', + const response = await (openCodeClient).forward({ + method: 'POST', + path: `/mcp/${encodeURIComponent(serverName)}/auth/authenticate`, directory, - ) + }) if (!response.ok) { const errorMsg = await extractOpenCodeError(response, 'Failed to authenticate MCP server') @@ -1487,11 +1477,11 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const body = await c.req.json() const { directory } = ConnectMcpDirectorySchema.parse(body) - const response = await proxyToOpenCodeWithDirectory( - `/mcp/${encodeURIComponent(serverName)}/auth`, - 'DELETE', - directory - ) + const response = await (openCodeClient).forward({ + method: 'DELETE', + path: `/mcp/${encodeURIComponent(serverName)}/auth`, + directory, + }) if (!response.ok) { const errorMsg = await extractOpenCodeError(response, 'Failed to remove MCP auth') @@ -1508,5 +1498,61 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) + const OpenCodeServerAuthBodySchema = z.object({ + password: z.union([z.string().min(8), z.null()]), + }) + + app.get('/opencode-server-auth', async (c) => { + try { + const hasStored = settingsService.hasStoredOpenCodeServerPassword() + const source = hasStored ? 'db' : ENV.OPENCODE.SERVER_PASSWORD ? 'env' : 'none' + const isSet = source !== 'none' + return c.json({ isSet, source }) + } catch (error) { + logger.error('Failed to get OpenCode server auth status:', error) + return c.json({ error: 'Failed to get OpenCode server auth status' }, 500) + } + }) + + app.patch('/opencode-server-auth', async (c) => { + try { + const body = await c.req.json() + const validated = OpenCodeServerAuthBodySchema.parse(body) + const previousPasswordState = settingsService.getStoredOpenCodeServerPasswordState() + + if (validated.password === null) { + settingsService.clearOpenCodeServerPassword() + } else if (validated.password) { + settingsService.setOpenCodeServerPassword(validated.password) + } + + try { + await opencodeServerManager.restart() + } catch (restartError) { + try { + settingsService.restoreOpenCodeServerPasswordState(previousPasswordState) + await opencodeServerManager.restart() + sseAggregator.reconnect() + } catch (restoreError) { + logger.error('Failed to restore OpenCode server auth runtime after restart failure:', restoreError) + } + throw restartError + } + + sseAggregator.reconnect() + + const hasStored = settingsService.hasStoredOpenCodeServerPassword() + const source = hasStored ? 'db' : ENV.OPENCODE.SERVER_PASSWORD ? 'env' : 'none' + const isSet = source !== 'none' + return c.json({ isSet, source }) + } catch (error) { + logger.error('Failed to update OpenCode server auth:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid request data', details: error.issues }, 400) + } + return c.json({ error: 'Failed to update OpenCode server auth' }, 500) + } + }) + return app } diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 77a6655b..4749ef30 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -12,12 +12,21 @@ import { ensureDirectoryExists, } from './file-operations' import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' -import { getReposPath } from '@opencode-manager/shared/config/env' +import { getReposPath, ENV } from '@opencode-manager/shared/config/env' +import type { Database } from 'bun:sqlite' +import { getOrCreateInternalToken } from './internal-token' const ASSISTANT_MODE_DIR = 'assistant' const ASSISTANT_MODE_RELATIVE_PATH = 'repos/assistant' const ASSISTANT_AGENTS_MD_FILENAME = 'AGENTS.md' const ASSISTANT_OPENCODE_CONFIG_FILENAME = 'opencode.json' +const ASSISTANT_OPENCODE_DIR = '.opencode' +const ASSISTANT_INTERNAL_TOKEN_FILENAME = 'internal-token' +const ASSISTANT_SKILLS_DIR = 'skills' +const ASSISTANT_SCHEDULES_SKILL_DIR = 'schedule-management' +const ASSISTANT_NOTIFICATIONS_SKILL_DIR = 'notifications' +const ASSISTANT_SETTINGS_SKILL_DIR = 'manager-settings' +const ASSISTANT_SKILL_FILENAME = 'SKILL.md' export function getAssistantModeDirectory(): string { const reposPath = getReposPath() @@ -32,6 +41,22 @@ export function getAssistantModeDirectory(): string { return resolvedAssistantDir } +function getInternalTokenPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_INTERNAL_TOKEN_FILENAME) +} + +function getSchedulesSkillPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SCHEDULES_SKILL_DIR, ASSISTANT_SKILL_FILENAME) +} + +function getNotificationsSkillPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_NOTIFICATIONS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) +} + +function getSettingsSkillPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SETTINGS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) +} + export function buildAssistantAgentsMd(): string { return `# Assistant Mode Instructions @@ -63,6 +88,327 @@ The agent MAY self-edit the following files within this workspace: 2. Update AGENTS.md when learning durable preferences 3. Maintain version control awareness 4. Document significant changes in commit messages + +## Schedule Management + +This workspace ships a workspace-scoped skill at \`.opencode/skills/schedule-management/SKILL.md\` that documents how to list, create, update, delete, run, inspect, and cancel schedule jobs and runs across any repo via the internal HTTP API. Load it whenever the user asks about schedules. + +## Notifications + +This workspace includes a skill at \`.opencode/skills/notifications/SKILL.md\` for sending push notifications to the user's registered devices via the internal HTTP API. Load it when you need to notify the user about important events. + +## Settings Management + +This workspace includes a skill at \`.opencode/skills/manager-settings/SKILL.md\` for reading and safely modifying user preferences via the internal HTTP API. Load it when you need to inspect or update UI settings. +` +} + +function toLocalhostInternalBaseUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.protocol = 'http' + url.hostname = 'localhost' + url.port = String(ENV.SERVER.PORT) + return url.toString().replace(/\/$/, '') +} + +export function buildSchedulesSkill(baseUrl: string): string { + const internalBaseUrl = toLocalhostInternalBaseUrl(baseUrl) + + return `--- +name: schedule-management +description: Manage schedule jobs and runs across any repo via the internal HTTP API +--- + +## When to Load + +Load this skill when the user asks about managing schedules, schedule jobs, schedule runs, or anything related to automated task execution across repos. + +## Authentication + +All API calls require a bearer token. Read the token from \`.opencode/internal-token\` (relative to the assistant workspace cwd) and pass it as: + +\`\`\` +Authorization: Bearer +\`\`\` + +## Base URL + +\`${internalBaseUrl}\` + +## Endpoints + +### GET /schedules/all +List all schedule jobs across all repos. + +\`\`\`bash +curl -H "Authorization: Bearer " ${internalBaseUrl}/schedules/all +\`\`\` + +### GET /schedules/all/runs +List all schedule runs across all repos with optional filtering. + +Query params: \`limit\`, \`offset\`, \`status\`, \`repoId\`, \`jobId\`, \`triggerSource\` + +\`\`\`bash +curl -H "Authorization: Bearer " "${internalBaseUrl}/schedules/all/runs?limit=20" +\`\`\` + +### GET /repos/:repoId/schedules +List all schedule jobs for a specific repo. + +\`\`\`bash +curl -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules +\`\`\` + +### POST /repos/:repoId/schedules +Create a new schedule job. + +Body matches \`CreateScheduleJobRequest\` schema (discriminated union with \`scheduleMode: 'interval' | 'cron'\`). + +\`\`\`bash +curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" \\ + -d '{"name":"my-job","prompt":"do something","scheduleMode":"interval","intervalMinutes":60}' \\ + ${internalBaseUrl}/repos/:repoId/schedules +\`\`\` + +### GET /repos/:repoId/schedules/:jobId +Get a specific schedule job. + +\`\`\`bash +curl -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId +\`\`\` + +### PATCH /repos/:repoId/schedules/:jobId +Update an existing schedule job. + +Body matches \`UpdateScheduleJobRequest\` schema. + +\`\`\`bash +curl -X PATCH -H "Authorization: Bearer " -H "Content-Type: application/json" \\ + -d '{"enabled":false}' \\ + ${internalBaseUrl}/repos/:repoId/schedules/:jobId +\`\`\` + +### DELETE /repos/:repoId/schedules/:jobId +Delete a schedule job. + +\`\`\`bash +curl -X DELETE -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId +\`\`\` + +### POST /repos/:repoId/schedules/:jobId/run +Manually trigger a schedule job. + +\`\`\`bash +curl -X POST -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId/run +\`\`\` + +### GET /repos/:repoId/schedules/:jobId/runs +List runs for a specific job. + +Query params: \`limit\` + +\`\`\`bash +curl -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId/runs?limit=20 +\`\`\` + +### GET /repos/:repoId/schedules/:jobId/runs/:runId +Get a specific schedule run. + +\`\`\`bash +curl -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId/runs/:runId +\`\`\` + +### POST /repos/:repoId/schedules/:jobId/runs/:runId/cancel +Cancel a running schedule run. + +\`\`\`bash +curl -X POST -H "Authorization: Bearer " ${internalBaseUrl}/repos/:repoId/schedules/:jobId/runs/:runId/cancel +\`\`\` + +## Safety + +Always confirm destructive operations (\`DELETE\` jobs, \`cancel\` runs) with the user before executing. +` +} + +export function buildNotificationsSkill(baseUrl: string): string { + const internalBaseUrl = toLocalhostInternalBaseUrl(baseUrl) + + return `--- +name: notifications +description: Send push notifications to the user's registered devices via the internal HTTP API +--- + +## When to Load + +Load this skill when you need to notify the user about important events, completed tasks, or questions that require their attention. + +## Authentication + +All API calls require a bearer token. Read the token from \`.opencode/internal-token\` (relative to the assistant workspace cwd) and pass it as: + +\`\`\` +Authorization: Bearer +\`\`\` + +## Base URL + +\`${internalBaseUrl}\` + +## Endpoint + +### POST /notifications/send + +Send a push notification to all of the user's registered devices. + +**Query Parameters:** +- \`userId\` (optional): User ID. Defaults to \`"default"\`. + +**Request Body:** +\`\`\`ts +{ + title: string // 1-120 characters + body: string // 1-500 characters + url?: string // Optional: deep link to navigate to (1-500 chars) + tag?: string // Optional: notification tag for deduplication (max 80 chars) + priority?: 'normal' | 'high' // Defaults to 'normal' +} +\`\`\` + +**Example:** +\`\`\`bash +curl -X POST -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"title":"Task Complete","body":"The build has finished successfully","url":"/repos/my-repo","priority":"high"}' \\ + "${internalBaseUrl}/notifications/send?userId=default" +\`\`\` + +**Response:** +\`\`\`ts +{ + delivered: number // Number of successfully delivered notifications + expired: number // Number of expired subscriptions removed + failed: number // Number of failed deliveries + noSubscriptions: boolean // True if user has no registered devices +} +\`\`\` + +## Rate Limiting + +The endpoint enforces a rate limit of **10 requests per minute per token**. If exceeded, you'll receive a \`429 Too Many Requests\` response with a \`Retry-After\` header. + +## Notes + +- Notifications are only sent if the user has registered devices (browser push subscriptions) +- If VAPID is not configured on the server, the endpoint returns \`503 Service Unavailable\` +- Use \`priority: 'high'\` for urgent notifications that should interrupt the user +` +} + +export function buildSettingsSkill(baseUrl: string): string { + const internalBaseUrl = toLocalhostInternalBaseUrl(baseUrl) + + return `--- +name: manager-settings +description: Read and modify safe user preferences via the internal HTTP API +--- + +## When to Load + +Load this skill when you need to inspect or update the user's UI preferences, theme, mode, or other non-sensitive settings. + +## Authentication + +All API calls require a bearer token. Read the token from \`.opencode/internal-token\` (relative to the assistant workspace cwd) and pass it as: + +\`\`\` +Authorization: Bearer +\`\`\` + +## Base URL + +\`${internalBaseUrl}\` + +## Endpoints + +### GET /settings + +Retrieve the user's full settings, including all preferences. + +**Query Parameters:** +- \`userId\` (optional): User ID. Defaults to \`"default"\`. + +**Example:** +\`\`\`bash +curl -H "Authorization: Bearer " "${internalBaseUrl}/settings?userId=default" +\`\`\` + +**Response:** +\`\`\`ts +{ + preferences: { + theme: 'dark' | 'light' | 'system', + mode: 'plan' | 'build', + defaultModel?: string, + defaultAgent?: string, + autoScroll: boolean, + expandDiffs: boolean, + expandToolCalls: boolean, + showReasoning: boolean, + simpleChatMode: boolean, + leaderKey?: string, + directShortcuts?: string[], + keyboardShortcuts: Record, + customCommands: Array<{ name: string; description: string; promptTemplate: string }>, + notifications?: { enabled: boolean; ... }, + repoOrder?: number[], + repoSortMode: 'recent' | 'manual' | 'name', + // ... other safe preferences + }, + updatedAt: number +} +\`\`\` + +### PATCH /settings + +Update a subset of safe user preferences. + +**Allowed Keys:** +The following preference keys can be modified: +- \`theme\`, \`mode\`, \`defaultModel\`, \`defaultAgent\` +- \`autoScroll\`, \`expandDiffs\`, \`expandToolCalls\`, \`showReasoning\` +- \`simpleChatMode\`, \`leaderKey\`, \`directShortcuts\` +- \`keyboardShortcuts\`, \`customCommands\`, \`notifications\` +- \`repoOrder\`, \`repoSortMode\` + +**DO NOT attempt to set:** +- \`gitCredentials\` - Git credentials must be managed via the full UI +- \`gitIdentity\` - Git identity must be managed via the full UI +- \`tts.apiKey\` - TTS credentials must be managed via the full UI +- \`stt.apiKey\` - STT credentials must be managed via the full UI +- \`lastKnownGoodConfig\` - Internal state, do not modify +- Any other keys not in the allowed list above + +**Request Body:** +Partial object with any of the allowed keys. + +**Example:** +\`\`\`bash +curl -X PATCH -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"theme":"dark","mode":"build"}' \\ + "${internalBaseUrl}/settings?userId=default" +\`\`\` + +**Response:** +Returns the updated settings object with the same structure as GET. + +## Safety + +- This API intentionally rejects any attempt to modify credentials, API keys, or other sensitive settings +- If you need to change credentials (Git, TTS, STT, etc.), guide the user to use the full UI +- The settings PATCH endpoint does NOT trigger OpenCode reload or restart ` } @@ -92,7 +438,8 @@ export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput { export async function ensureAssistantMode( repo: Repo, - options?: AssistantModeInitRequest + deps: { db: Database; apiBaseUrl: string }, + options?: AssistantModeInitRequest, ): Promise { const assistantDir = getAssistantModeDirectory() @@ -100,6 +447,8 @@ export async function ensureAssistantMode( const agentsMdPath = path.join(assistantDir, ASSISTANT_AGENTS_MD_FILENAME) const opencodeJsonPath = path.join(assistantDir, ASSISTANT_OPENCODE_CONFIG_FILENAME) + const tokenPath = getInternalTokenPath(assistantDir) + const skillPath = getSchedulesSkillPath(assistantDir) const agentsMdExists = await fileExists(agentsMdPath) const opencodeJsonExists = await fileExists(opencodeJsonPath) @@ -119,6 +468,41 @@ export async function ensureAssistantMode( await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) } + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SCHEDULES_SKILL_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_NOTIFICATIONS_SKILL_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SETTINGS_SKILL_DIR)) + + const token = getOrCreateInternalToken(deps.db) + const existingTokenContent = await fileExists(tokenPath) ? await readFileContent(tokenPath) : undefined + const tokenCreated = !existingTokenContent || existingTokenContent.trim() !== token + if (tokenCreated) { + await writeFileContent(tokenPath, token) + } + + const schedulesSkillContent = buildSchedulesSkill(deps.apiBaseUrl) + const existingSchedulesSkillContent = await fileExists(skillPath) ? await readFileContent(skillPath) : undefined + const schedulesSkillCreated = !existingSchedulesSkillContent || existingSchedulesSkillContent !== schedulesSkillContent + if (schedulesSkillCreated) { + await writeFileContent(skillPath, schedulesSkillContent) + } + + const notificationsSkillPath = getNotificationsSkillPath(assistantDir) + const notificationsSkillContent = buildNotificationsSkill(deps.apiBaseUrl) + const existingNotificationsSkillContent = await fileExists(notificationsSkillPath) ? await readFileContent(notificationsSkillPath) : undefined + const notificationsSkillCreated = !existingNotificationsSkillContent || existingNotificationsSkillContent !== notificationsSkillContent + if (notificationsSkillCreated) { + await writeFileContent(notificationsSkillPath, notificationsSkillContent) + } + + const settingsSkillPath = getSettingsSkillPath(assistantDir) + const settingsSkillContent = buildSettingsSkill(deps.apiBaseUrl) + const existingSettingsSkillContent = await fileExists(settingsSkillPath) ? await readFileContent(settingsSkillPath) : undefined + const settingsSkillCreated = !existingSettingsSkillContent || existingSettingsSkillContent !== settingsSkillContent + if (settingsSkillCreated) { + await writeFileContent(settingsSkillPath, settingsSkillContent) + } + return { repoId: repo.id, directory: assistantDir, @@ -135,14 +519,33 @@ export async function ensureAssistantMode( created: !opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig, }, }, + internalToken: { + path: tokenPath, + created: tokenCreated, + }, + schedulesSkill: { + path: skillPath, + created: schedulesSkillCreated, + }, + notificationsSkill: { + path: notificationsSkillPath, + created: notificationsSkillCreated, + }, + settingsSkill: { + path: settingsSkillPath, + created: settingsSkillCreated, + }, } } async function isLegacyAssistantOpenCodeConfig(opencodeJsonPath: string): Promise { try { const content = await readFileContent(opencodeJsonPath) - const config = JSON.parse(content) as { permission?: { allow?: unknown; ask?: unknown } } - return Array.isArray(config.permission?.allow) || Array.isArray(config.permission?.ask) + const config = JSON.parse(content) as { + permission?: { allow?: unknown; ask?: unknown } + } + if (Array.isArray(config.permission?.allow) || Array.isArray(config.permission?.ask)) return true + return false } catch { return false } @@ -153,9 +556,17 @@ export async function getAssistantModeStatus(repo: Repo): Promise { + ): Promise<{ delivered: number; expired: number; failed: number; total: number }> { const subscriptions = this.getSubscriptions(userId); const expiredEndpoints: string[] = []; + let delivered = 0 + let failed = 0 await Promise.allSettled( subscriptions.map(async (sub) => { @@ -286,6 +288,8 @@ export class NotificationService { "UPDATE push_subscriptions SET last_used_at = ? WHERE id = ?" ) .run(Date.now(), sub.id); + + delivered++ } catch (error) { const statusCode = (error as { statusCode?: number }).statusCode; @@ -293,6 +297,7 @@ export class NotificationService { expiredEndpoints.push(sub.endpoint); } else { logger.error(`Push delivery failed for ${sub.endpoint.slice(0, 50)}:`, error); + failed++ } } }) @@ -301,5 +306,12 @@ export class NotificationService { for (const endpoint of expiredEndpoints) { this.removeSubscription(endpoint); } + + return { + delivered, + expired: expiredEndpoints.length, + failed, + total: subscriptions.length, + } } } diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index 524fa863..9438dd33 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -1,4 +1,4 @@ -import { proxyToOpenCodeWithDirectory } from './proxy' +import type { OpenCodeClient } from './opencode/client' interface OpenCodeConfigResponse { model?: string @@ -63,29 +63,16 @@ function uniqueCandidates(candidates: Array): string[ return [...new Set(normalizedCandidates)] } -async function fetchOpenCodeConfig(directory?: string): Promise { - const response = await proxyToOpenCodeWithDirectory('/config', 'GET', directory) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(errorText || 'Failed to fetch OpenCode config') - } - - return await response.json() as OpenCodeConfigResponse +async function fetchOpenCodeConfig(client: OpenCodeClient, directory?: string): Promise { + return client.getJson('/config', { directory }) } -async function fetchOpenCodeProviders(directory?: string): Promise { - const response = await proxyToOpenCodeWithDirectory('/config/providers', 'GET', directory) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(errorText || 'Failed to fetch OpenCode providers') - } - - return await response.json() as OpenCodeProviderResponse +async function fetchOpenCodeProviders(client: OpenCodeClient, directory?: string): Promise { + return client.getJson('/config/providers', { directory }) } export async function resolveOpenCodeModel( + client: OpenCodeClient, directory: string | undefined, options?: { preferredModel?: string | null @@ -93,8 +80,8 @@ export async function resolveOpenCodeModel( }, ): Promise { const [config, providersResponse] = await Promise.all([ - fetchOpenCodeConfig(directory), - fetchOpenCodeProviders(directory), + fetchOpenCodeConfig(client, directory), + fetchOpenCodeProviders(client, directory), ]) const availableModels = buildAvailableModels(providersResponse) diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 7907a0d8..3d7f8e07 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -20,18 +20,15 @@ import { getWorkspacePath, getOpenCodeConfigFilePath, ENV } from '@opencode-mana import { parseJsonc } from '@opencode-manager/shared/utils' import type { Database } from 'bun:sqlite' import { compareVersions } from '../utils/version-utils' -import { patchOpenCodeConfig } from './proxy' +import { patchConfigWithRecovery } from './opencode/config-recovery' +import type { OpenCodeClient } from './opencode/client' +import { writeFileContent } from './file-operations' -const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT const OPENCODE_SERVER_HOST = ENV.OPENCODE.HOST -const OPENCODE_SERVER_PUBLIC_URL = ENV.OPENCODE.PUBLIC_URL -const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD -const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME -const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD - ? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}` - : '' +export const OPENCODE_SERVER_CONNECT_HOST = OPENCODE_SERVER_HOST === '0.0.0.0' ? '127.0.0.1' : OPENCODE_SERVER_HOST const MIN_OPENCODE_VERSION = '1.0.137' const MAX_STDERR_SIZE = 10240 +const HEALTH_CHECK_TIMEOUT_MS = 3000 type StartupValidationIssue = { path: string @@ -91,6 +88,10 @@ function formatStartupError(stderrOutput: string, fallback: string): string { // This allows proper mocking in tests const getOpenCodeServerDirectory = () => getWorkspacePath() const getOpenCodeConfigPath = () => getOpenCodeConfigFilePath() +const getOpenCodeServerPort = () => ENV.OPENCODE.PORT +const getOpenCodeServerHost = () => ENV.OPENCODE.HOST +const getOpenCodeServerPublicUrl = () => ENV.OPENCODE.PUBLIC_URL +const getOpenCodeServerUsername = () => ENV.OPENCODE.SERVER_USERNAME class OpenCodeServerManager { private static instance: OpenCodeServerManager @@ -101,6 +102,7 @@ class OpenCodeServerManager { private version: string | null = null private lastStartupError: string | null = null private opInProgress: boolean = false + private openCodeClient: OpenCodeClient | null = null private constructor() {} @@ -108,6 +110,31 @@ class OpenCodeServerManager { this.db = db } + setOpenCodeClient(client: OpenCodeClient) { + this.openCodeClient = client + } + + async rebuildClient(): Promise { + const password = this.getResolvedPassword() + const { createOpenCodeClient } = await import('./opencode/client') + this.openCodeClient = createOpenCodeClient(password) + } + + private getResolvedPassword(): string { + if (this.db) { + const settingsService = new SettingsService(this.db) + return settingsService.getOpenCodeServerPassword() + } + return ENV.OPENCODE.SERVER_PASSWORD + } + + private requireClient(): OpenCodeClient { + if (!this.openCodeClient) { + throw new Error('OpenCodeClient not configured on OpenCodeServerManager. Call setOpenCodeClient() during startup.') + } + return this.openCodeClient + } + static getInstance(): OpenCodeServerManager { if (!OpenCodeServerManager.instance) { OpenCodeServerManager.instance = new OpenCodeServerManager() @@ -154,7 +181,18 @@ class OpenCodeServerManager { return } + await this.rebuildClient() + const isDevelopment = ENV.SERVER.NODE_ENV !== 'production' + const password = this.getResolvedPassword() + const openCodeServerHost = getOpenCodeServerHost() + const isExposed = openCodeServerHost !== '127.0.0.1' && openCodeServerHost !== 'localhost' + if (isExposed && !password) { + 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.` + this.lastStartupError = msg + logger.error(msg) + throw new Error(msg) + } let gitCredentials: GitCredential[] = [] let gitIdentityEnv: Record = {} @@ -174,9 +212,10 @@ class OpenCodeServerManager { } } - const existingProcesses = await this.findProcessesByPort(OPENCODE_SERVER_PORT) + const openCodeServerPort = getOpenCodeServerPort() + const existingProcesses = await this.findProcessesByPort(openCodeServerPort) if (existingProcesses.length > 0) { - logger.info(`OpenCode server already running on port ${OPENCODE_SERVER_PORT}`) + logger.info(`OpenCode server already running on port ${openCodeServerPort}`) const healthy = await this.checkHealth() if (healthy) { if (isDevelopment) { @@ -276,7 +315,7 @@ class OpenCodeServerManager { this.serverProcess = spawn( 'opencode', - ['serve', '--port', OPENCODE_SERVER_PORT.toString(), '--hostname', OPENCODE_SERVER_HOST], + ['serve', '--port', openCodeServerPort.toString(), '--hostname', openCodeServerHost], { cwd: openCodeServerDirectory, detached: !isDevelopment, @@ -289,11 +328,11 @@ class OpenCodeServerManager { XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'), XDG_STATE_HOME: path.join(openCodeServerDirectory, '.opencode/state'), XDG_CONFIG_HOME: path.join(openCodeServerDirectory, '.config'), - ...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}), - ...(OPENCODE_SERVER_PASSWORD + ...(getOpenCodeServerPublicUrl() ? { OPENCODE_PUBLIC_URL: getOpenCodeServerPublicUrl() } : {}), + ...(password ? { - OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME, + OPENCODE_SERVER_PASSWORD: password, + OPENCODE_SERVER_USERNAME: getOpenCodeServerUsername(), } : {}), OPENCODE_CONFIG: openCodeConfigPath, @@ -480,10 +519,10 @@ class OpenCodeServerManager { try { const configPath = getOpenCodeConfigFilePath() const fileContent = await fs.readFile(configPath, 'utf-8') - const fileConfig = JSON.parse(fileContent) as Record + const fileConfig = parseJsonc(fileContent) as Record logger.info(`Read config from file for reload: ${configPath}`) - const patchResult = await patchOpenCodeConfig(fileConfig) + const patchResult = await patchConfigWithRecovery(this.requireClient(), fileConfig) if (!patchResult.success) { const errorMessage = patchResult.error || 'Failed to reload config' const validationIssues = patchResult.details || [] @@ -498,6 +537,11 @@ class OpenCodeServerManager { throw new ConfigReloadError(errorMessage, validationIssues, removedFields) } + if (patchResult.removedFields && patchResult.removedFields.length > 0 && patchResult.appliedConfig) { + await writeFileContent(configPath, JSON.stringify(patchResult.appliedConfig, null, 2)) + logger.info(`Persisted cleaned config to ${configPath} after removing fields: ${patchResult.removedFields.join(', ')}`) + } + logger.info('OpenCode configuration reloaded successfully') await new Promise(r => setTimeout(r, 500)) const healthy = await this.checkHealth() @@ -514,7 +558,7 @@ class OpenCodeServerManager { } getPort(): number { - return OPENCODE_SERVER_PORT + return getOpenCodeServerPort() } getVersion(): string | null { @@ -544,14 +588,14 @@ class OpenCodeServerManager { } async checkHealth(): Promise { + if (!this.openCodeClient) { + return false + } try { - const headers: Record = {} - if (OPENCODE_BASIC_AUTH) { - headers.Authorization = OPENCODE_BASIC_AUTH - } - const response = await fetch(`http://${OPENCODE_SERVER_HOST}:${OPENCODE_SERVER_PORT}/doc`, { - signal: AbortSignal.timeout(3000), - headers + const response = await this.openCodeClient.forward({ + method: 'GET', + path: '/doc', + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), }) return response.ok } catch { diff --git a/backend/src/services/opencode/auth.ts b/backend/src/services/opencode/auth.ts new file mode 100644 index 00000000..ae2e2b2b --- /dev/null +++ b/backend/src/services/opencode/auth.ts @@ -0,0 +1,18 @@ +import { ENV } from '@opencode-manager/shared/config/env' + +export type OpenCodePasswordResolver = () => string | Promise + +export function getOpenCodeBasicAuthHeader(): string | null +export function getOpenCodeBasicAuthHeader(password: string): string | null +export function getOpenCodeBasicAuthHeader(passwordResolver: OpenCodePasswordResolver): Promise +export function getOpenCodeBasicAuthHeader(source?: string | OpenCodePasswordResolver): string | null | Promise { + if (typeof source === 'function') { + return Promise.resolve(source()).then((password) => getOpenCodeBasicAuthHeader(password)) + } + + const password = source ?? ENV.OPENCODE.SERVER_PASSWORD + const username = ENV.OPENCODE.SERVER_USERNAME + if (!password) return null + const token = Buffer.from(`${username}:${password}`).toString('base64') + return `Basic ${token}` +} diff --git a/backend/src/services/opencode/client.ts b/backend/src/services/opencode/client.ts new file mode 100644 index 00000000..a4f69348 --- /dev/null +++ b/backend/src/services/opencode/client.ts @@ -0,0 +1,259 @@ +import { logger } from '../../utils/logger' +import { ENV } from '@opencode-manager/shared/config/env' +import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './auth' + +export interface ForwardRequest { + method: string + path: string + body?: string + headers?: Record + directory?: string + signal?: AbortSignal +} + +export interface JsonRequestOptions { + directory?: string + headers?: Record + signal?: AbortSignal +} + +export class UpstreamError extends Error { + constructor( + public readonly status: number, + public readonly bodyText: string, + message?: string, + ) { + super(message ?? `OpenCode upstream returned ${status}`) + this.name = 'UpstreamError' + } +} + +export interface OpenCodeClient { + forward(req: ForwardRequest): Promise + forwardRaw(request: Request): Promise + getJson(path: string, opts?: JsonRequestOptions): Promise + postJson(path: string, body: unknown, opts?: JsonRequestOptions): Promise + setProviderAuth(providerId: string, apiKey: string): Promise + deleteProviderAuth(providerId: string): Promise + startMcpAuth(serverName: string, directory?: string): Promise + authenticateMcp(serverName: string, directory?: string): Promise +} + +export interface FetchOpenCodeClientConfig { + baseUrl: string + basicAuth: string | null + passwordResolver?: OpenCodePasswordResolver + fetchFn?: typeof fetch +} + +export class FetchOpenCodeClient implements OpenCodeClient { + constructor(private readonly config: FetchOpenCodeClientConfig) {} + + private get fetchFn(): typeof fetch { + return this.config.fetchFn ?? fetch + } + + private async getBasicAuth(): Promise { + if (!this.config.passwordResolver) { + return this.config.basicAuth ?? '' + } + + return await getOpenCodeBasicAuthHeader(this.config.passwordResolver) ?? '' + } + + private async request(req: ForwardRequest): Promise { + const url = new URL(this.config.baseUrl + req.path) + + if (req.directory) { + url.searchParams.set('directory', req.directory) + } + + const headers: Record = { ...(req.headers ?? {}) } + const basicAuth = await this.getBasicAuth() + + if (basicAuth) { + headers.Authorization = basicAuth + } + + try { + const response = await this.fetchFn(url, { + method: req.method, + headers, + body: req.body, + signal: req.signal, + }) + + const filteredHeaders: Record = {} + const skipHeaders = new Set(['connection', 'transfer-encoding', 'content-encoding', 'content-length']) + response.headers.forEach((value, key) => { + if (!skipHeaders.has(key.toLowerCase())) { + filteredHeaders[key] = value + } + }) + + const noBodyStatuses = new Set([101, 204, 205, 304]) + if (noBodyStatuses.has(response.status)) { + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: filteredHeaders, + }) + } + + const body = await response.text() + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: filteredHeaders, + }) + } catch (error) { + logger.error(`Proxy request failed for ${req.path}:`, error) + return new Response(JSON.stringify({ error: 'Proxy request failed' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + async forward(req: ForwardRequest): Promise { + return this.request(req) + } + + async forwardRaw(request: Request): Promise { + const url = new URL(request.url) + const cleanPathname = url.pathname.replace(/^\/api\/opencode/, '') + + if (url.pathname.includes('/permissions/')) { + logger.info(`Proxying permission request: ${url.pathname}${url.search} -> ${cleanPathname}${url.search}`) + } + + const headers: Record = {} + request.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase() + if (!['host', 'connection', 'authorization'].includes(lowerKey)) { + headers[key] = value + } + }) + + const body = request.method !== 'GET' && request.method !== 'HEAD' + ? await request.text() + : undefined + + return this.request({ + method: request.method, + path: cleanPathname + url.search, + body, + headers, + }) + } + + async getJson(path: string, opts?: JsonRequestOptions): Promise { + const response = await this.request({ + method: 'GET', + path, + directory: opts?.directory, + headers: opts?.headers, + signal: opts?.signal, + }) + + if (!response.ok) { + const bodyText = await response.text() + throw new UpstreamError(response.status, bodyText) + } + + return (await response.json()) as T + } + + async postJson(path: string, body: unknown, opts?: JsonRequestOptions): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts?.headers ?? {}), + } + + const response = await this.request({ + method: 'POST', + path, + body: JSON.stringify(body), + headers, + directory: opts?.directory, + signal: opts?.signal, + }) + + if (!response.ok) { + const bodyText = await response.text() + throw new UpstreamError(response.status, bodyText) + } + + return (await response.json()) as T + } + + async setProviderAuth(providerId: string, apiKey: string): Promise { + const response = await this.request({ + method: 'PUT', + path: `/auth/${encodeURIComponent(providerId)}`, + body: JSON.stringify({ type: 'api', key: apiKey }), + headers: { 'Content-Type': 'application/json' }, + }) + + if (response.ok) { + logger.info(`Set OpenCode auth for provider: ${providerId}`) + return true + } + + if (response.status === 502) { + logger.error(`Failed to set OpenCode auth for provider: ${providerId}`) + return false + } + + logger.error(`Failed to set OpenCode auth: ${response.status} ${response.statusText}`) + return false + } + + async deleteProviderAuth(providerId: string): Promise { + const response = await this.request({ + method: 'DELETE', + path: `/auth/${encodeURIComponent(providerId)}`, + }) + + if (response.ok) { + logger.info(`Deleted OpenCode auth for provider: ${providerId}`) + return true + } + + if (response.status === 502) { + logger.error(`Failed to delete OpenCode auth for provider: ${providerId}`) + return false + } + + logger.error(`Failed to delete OpenCode auth: ${response.status} ${response.statusText}`) + return false + } + + async startMcpAuth(serverName: string, directory?: string): Promise { + return this.request({ + method: 'POST', + path: `/mcp/${encodeURIComponent(serverName)}/auth`, + headers: { 'Content-Type': 'application/json' }, + directory, + }) + } + + async authenticateMcp(serverName: string, directory?: string): Promise { + return this.request({ + method: 'POST', + path: `/mcp/${encodeURIComponent(serverName)}/auth/authenticate`, + headers: { 'Content-Type': 'application/json' }, + directory, + }) + } +} + +export function createOpenCodeClient(passwordOverride?: string | OpenCodePasswordResolver): OpenCodeClient { + const host = ENV.OPENCODE.HOST === '0.0.0.0' ? '127.0.0.1' : ENV.OPENCODE.HOST + const baseUrl = `http://${host}:${ENV.OPENCODE.PORT}` + const passwordResolver = typeof passwordOverride === 'function' ? passwordOverride : undefined + const password = typeof passwordOverride === 'string' ? passwordOverride : ENV.OPENCODE.SERVER_PASSWORD + const basicAuth = getOpenCodeBasicAuthHeader(password) + + return new FetchOpenCodeClient({ baseUrl, basicAuth, passwordResolver }) +} diff --git a/backend/src/services/proxy.ts b/backend/src/services/opencode/config-recovery.ts similarity index 50% rename from backend/src/services/proxy.ts rename to backend/src/services/opencode/config-recovery.ts index 1cc3c63b..2efe7a2f 100644 --- a/backend/src/services/proxy.ts +++ b/backend/src/services/opencode/config-recovery.ts @@ -1,63 +1,7 @@ -import { logger } from '../utils/logger' -import { ENV } from '@opencode-manager/shared/config/env' +import type { OpenCodeClient } from './client' +import { logger } from '../../utils/logger' import { parseJsonc } from '@opencode-manager/shared/utils' -export const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}` -const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD -const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME - -const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD - ? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}` - : '' - -export function withOpenCodeAuth(headers: Record = {}): Record { - if (OPENCODE_BASIC_AUTH) { - return { ...headers, Authorization: OPENCODE_BASIC_AUTH } - } - return headers -} - -export async function setOpenCodeAuth(providerId: string, apiKey: string): Promise { - try { - const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, { - method: 'PUT', - headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ type: 'api', key: apiKey }), - }) - - if (response.ok) { - logger.info(`Set OpenCode auth for provider: ${providerId}`) - return true - } - - logger.error(`Failed to set OpenCode auth: ${response.status} ${response.statusText}`) - return false - } catch (error) { - logger.error('Failed to set OpenCode auth:', error) - return false - } -} - -export async function deleteOpenCodeAuth(providerId: string): Promise { - try { - const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, { - method: 'DELETE', - headers: withOpenCodeAuth(), - }) - - if (response.ok) { - logger.info(`Deleted OpenCode auth for provider: ${providerId}`) - return true - } - - logger.error(`Failed to delete OpenCode auth: ${response.status} ${response.statusText}`) - return false - } catch (error) { - logger.error('Failed to delete OpenCode auth:', error) - return false - } -} - export type PatchConfigValidationIssue = { path: string message: string @@ -206,12 +150,16 @@ function parseErrorResponse(responseText: string): { details: PatchConfigValidat return { details, errorMessage } } -export async function patchOpenCodeConfig(config: Record): Promise { +export async function patchConfigWithRecovery( + client: OpenCodeClient, + config: Record, +): Promise { try { - const response = await fetch(`${OPENCODE_SERVER_URL}/config`, { + const response = await client.forward({ method: 'PATCH', - headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), + path: '/config', body: JSON.stringify(config), + headers: { 'Content-Type': 'application/json' }, }) if (response.ok) { @@ -254,10 +202,11 @@ export async function patchOpenCodeConfig(config: Record): Prom } logger.info(`Retrying config patch after removing ${removedFields.length} problematic field(s): ${removedFields.join(', ')}`) - const retryResponse = await fetch(`${OPENCODE_SERVER_URL}/config`, { + const retryResponse = await client.forward({ method: 'PATCH', - headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), + path: '/config', body: JSON.stringify(cleanedConfig), + headers: { 'Content-Type': 'application/json' }, }) if (retryResponse.ok) { @@ -286,156 +235,3 @@ export async function patchOpenCodeConfig(config: Record): Prom return { success: false, error: errorMessage } } } - -export async function proxyRequest(request: Request) { - const url = new URL(request.url) - - // Remove /api/opencode prefix from pathname before forwarding - const cleanPathname = url.pathname.replace(/^\/api\/opencode/, '') - const targetUrl = `${OPENCODE_SERVER_URL}${cleanPathname}${url.search}` - - if (url.pathname.includes('/permissions/')) { - logger.info(`Proxying permission request: ${url.pathname}${url.search} -> ${targetUrl}`) - } - - try { - const headers: Record = {} - request.headers.forEach((value, key) => { - if (!['host', 'connection', 'authorization'].includes(key.toLowerCase())) { - headers[key] = value - } - }) - - const response = await fetch(targetUrl, { - method: request.method, - headers: withOpenCodeAuth(headers), - body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, - }) - - const responseHeaders: Record = {} - const skipHeaders = new Set(['connection', 'transfer-encoding', 'content-encoding', 'content-length']) - response.headers.forEach((value, key) => { - if (!skipHeaders.has(key.toLowerCase())) { - responseHeaders[key] = value - } - }) - - const body = await response.text() - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }) - } catch (error) { - logger.error(`Proxy request failed for ${url.pathname}${url.search}:`, error) - return new Response(JSON.stringify({ error: 'Proxy request failed' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }) - } -} - -export async function proxyToOpenCodeWithDirectory( - path: string, - method: string, - directory: string | undefined, - body?: string, - headers?: Record -): Promise { - const url = new URL(`${OPENCODE_SERVER_URL}${path}`) - - if (directory) { - url.searchParams.set('directory', directory) - } - - try { - const response = await fetch(url.toString(), { - method, - headers: withOpenCodeAuth(headers || { 'Content-Type': 'application/json' }), - body, - }) - - const responseHeaders: Record = {} - const skipHeaders = new Set(['connection', 'transfer-encoding', 'content-encoding', 'content-length']) - response.headers.forEach((value, key) => { - if (!skipHeaders.has(key.toLowerCase())) { - responseHeaders[key] = value - } - }) - - const responseBody = await response.text() - return new Response(responseBody, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }) - } catch (error) { - logger.error(`Proxy to OpenCode failed for ${path}:`, error) - return new Response(JSON.stringify({ error: 'Proxy request failed' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }) - } -} - -export async function proxyMcpAuthStart( - serverName: string, - directory: string | undefined, -): Promise { - const path = `/mcp/${encodeURIComponent(serverName)}/auth` - const url = new URL(`${OPENCODE_SERVER_URL}${path}`) - - if (directory) { - url.searchParams.set('directory', directory) - } - - try { - const response = await fetch(url.toString(), { - method: 'POST', - headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), - }) - - const responseBody = await response.text() - return new Response(responseBody, { - status: response.status, - headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' }, - }) - } catch (error) { - logger.error(`MCP auth start failed for ${serverName}:`, error) - return new Response(JSON.stringify({ error: 'MCP auth start failed' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }) - } -} - -export async function proxyMcpAuthAuthenticate( - serverName: string, - directory: string | undefined, -): Promise { - const path = `/mcp/${encodeURIComponent(serverName)}/auth/authenticate` - const url = new URL(`${OPENCODE_SERVER_URL}${path}`) - - if (directory) { - url.searchParams.set('directory', directory) - } - - try { - const response = await fetch(url.toString(), { - method: 'POST', - headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), - }) - - const responseBody = await response.text() - return new Response(responseBody, { - status: response.status, - headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' }, - }) - } catch (error) { - logger.error(`MCP auth authenticate failed for ${serverName}:`, error) - return new Response(JSON.stringify({ error: 'MCP auth authenticate failed' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }) - } -} diff --git a/backend/src/services/plugin-memory.ts b/backend/src/services/plugin-memory.ts deleted file mode 100644 index 84db9f6a..00000000 --- a/backend/src/services/plugin-memory.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { Database } from 'bun:sqlite' -import path from 'path' -import { existsSync } from 'node:fs' -import { getWorkspacePath } from '@opencode-manager/shared/config/env' - -export interface PluginMemory { - id: number - projectId: string - scope: 'convention' | 'decision' | 'context' - content: string - filePath: string | null - accessCount: number - lastAccessedAt: number | null - createdAt: number - updatedAt: number -} - -interface DbMemoryRow { - id: number - project_id: string - scope: string - content: string - file_path: string | null - access_count: number - last_accessed_at: number | null - created_at: number - updated_at: number -} - -interface DbKvRow { - project_id: string - key: string - data: string - expires_at: number - created_at: number - updated_at: number -} - -interface MemoryFilters { - scope?: 'convention' | 'decision' | 'context' - content?: string - limit?: number - offset?: number -} - -function getPluginDbPath(): string { - return path.join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'memory', 'memory.db') -} - -function mapRowToMemory(row: DbMemoryRow): PluginMemory { - return { - id: row.id, - projectId: row.project_id, - scope: row.scope as PluginMemory['scope'], - content: row.content, - filePath: row.file_path, - accessCount: row.access_count, - lastAccessedAt: row.last_accessed_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -function mapRowToKvEntry(row: DbKvRow): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } { - let data: unknown = null - try { - data = JSON.parse(row.data) - } catch { - data = row.data - } - return { - key: row.key, - data, - createdAt: row.created_at, - updatedAt: row.updated_at, - expiresAt: row.expires_at, - } -} - -export class PluginMemoryService { - private db: Database | null = null - - getDb(): Database | null { - if (this.db) return this.db - - const dbPath = getPluginDbPath() - - if (!existsSync(dbPath)) { - return null - } - - try { - this.db = new Database(dbPath) - this.db.exec('PRAGMA journal_mode = WAL') - return this.db - } catch { - return null - } - } - - list(projectId: string, filters?: MemoryFilters): PluginMemory[] { - const db = this.getDb() - if (!db) return [] - - let sql = 'SELECT * FROM memories WHERE project_id = ?' - const params: (string | number)[] = [projectId] - - if (filters?.scope) { - sql += ' AND scope = ?' - params.push(filters.scope) - } - - if (filters?.content) { - sql += ' AND content LIKE ?' - params.push(`%${filters.content}%`) - } - - sql += ' ORDER BY updated_at DESC' - - if (filters?.limit) { - sql += ' LIMIT ?' - params.push(filters.limit) - } - - if (filters?.offset) { - sql += ' OFFSET ?' - params.push(filters.offset) - } - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbMemoryRow[] - return rows.map(mapRowToMemory) - } - - listAll(filters?: { projectId?: string; scope?: string; limit?: number; offset?: number }): PluginMemory[] { - const db = this.getDb() - if (!db) return [] - - let sql = 'SELECT * FROM memories WHERE 1=1' - const params: (string | number)[] = [] - - if (filters?.projectId) { - sql += ' AND project_id = ?' - params.push(filters.projectId) - } - - if (filters?.scope) { - sql += ' AND scope = ?' - params.push(filters.scope) - } - - sql += ' ORDER BY updated_at DESC' - - if (filters?.limit) { - sql += ' LIMIT ?' - params.push(filters.limit) - } - - if (filters?.offset) { - sql += ' OFFSET ?' - params.push(filters.offset) - } - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbMemoryRow[] - return rows.map(mapRowToMemory) - } - - getById(id: number): PluginMemory | undefined { - const db = this.getDb() - if (!db) return undefined - - const stmt = db.prepare('SELECT * FROM memories WHERE id = ?') - const row = stmt.get(id) as DbMemoryRow | undefined - return row ? mapRowToMemory(row) : undefined - } - - create(input: { projectId: string; scope: string; content: string }): number { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const now = Date.now() - const stmt = db.prepare(` - INSERT INTO memories (project_id, scope, content, access_count, created_at, updated_at) - VALUES (?, ?, ?, 0, ?, ?) - `) - const result = stmt.run(input.projectId, input.scope, input.content, now, now) - return result.lastInsertRowid as number - } - - update(id: number, input: { content?: string; scope?: string }): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const updates: string[] = [] - const params: (string | number)[] = [] - - if (input.content !== undefined) { - updates.push('content = ?') - params.push(input.content) - } - - if (input.scope !== undefined) { - updates.push('scope = ?') - params.push(input.scope) - } - - if (updates.length === 0) return - - updates.push('updated_at = ?') - params.push(Date.now()) - params.push(id) - - const sql = `UPDATE memories SET ${updates.join(', ')} WHERE id = ?` - const stmt = db.prepare(sql) - stmt.run(...params) - - try { - const deleteEmbeddings = db.prepare('DELETE FROM memory_embeddings WHERE memory_id = ?') - deleteEmbeddings.run(id) - } catch { - // table may not exist - } - } - - delete(id: number): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - try { - const deleteEmbeddings = db.prepare('DELETE FROM memory_embeddings WHERE memory_id = ?') - deleteEmbeddings.run(id) - } catch { - // table may not exist - } - - const stmt = db.prepare('DELETE FROM memories WHERE id = ?') - stmt.run(id) - } - - getStats(projectId: string): { projectId: string; total: number; byScope: Record } { - const db = this.getDb() - if (!db) { - return { projectId, total: 0, byScope: {} } - } - - const totalStmt = db.prepare('SELECT COUNT(*) as count FROM memories WHERE project_id = ?') - const totalResult = totalStmt.get(projectId) as { count: number } - const total = totalResult.count - - const byScopeStmt = db.prepare('SELECT scope, COUNT(*) as count FROM memories WHERE project_id = ? GROUP BY scope') - const byScopeRows = byScopeStmt.all(projectId) as { scope: string; count: number }[] - const byScope: Record = {} - for (const row of byScopeRows) { - byScope[row.scope] = row.count - } - - return { projectId, total, byScope } - } - - listKv(projectId: string, prefix?: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number }[] { - const db = this.getDb() - if (!db) return [] - - const now = Date.now() - let sql = 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND expires_at > ?' - const params: (string | number)[] = [projectId, now] - - if (prefix) { - sql += ' AND key LIKE ?' - params.push(`${prefix}%`) - } - - sql += ' ORDER BY updated_at DESC' - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbKvRow[] - return rows.map(mapRowToKvEntry) - } - - getKv(projectId: string, key: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } | undefined { - const db = this.getDb() - if (!db) return undefined - - const stmt = db.prepare( - 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND key = ? AND expires_at > ?' - ) - const row = stmt.get(projectId, key, Date.now()) as DbKvRow | undefined - return row ? mapRowToKvEntry(row) : undefined - } - - setKv(projectId: string, key: string, data: unknown, ttlMs?: number): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const now = Date.now() - const expiresAt = ttlMs ? now + ttlMs : Number.MAX_SAFE_INTEGER - const serializedData = JSON.stringify(data) - - const stmt = db.prepare(` - INSERT INTO project_kv (project_id, key, data, expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id, key) DO UPDATE SET - data = excluded.data, - expires_at = excluded.expires_at, - updated_at = excluded.updated_at - `) - stmt.run(projectId, key, serializedData, expiresAt, now, now) - } - - deleteKv(projectId: string, key: string): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const stmt = db.prepare('DELETE FROM project_kv WHERE project_id = ? AND key = ?') - stmt.run(projectId, key) - } - - getKvCount(projectId: string): number { - const db = this.getDb() - if (!db) return 0 - - const stmt = db.prepare('SELECT COUNT(*) as count FROM project_kv WHERE project_id = ? AND expires_at > ?') - const result = stmt.get(projectId, Date.now()) as { count: number } - return result.count - } - - close(): void { - if (this.db) { - this.db.close() - this.db = null - } - } -} diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 0da81157..97448898 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -34,7 +34,7 @@ import { computeNextRunAtForJob, } from './schedule-config' import { resolveOpenCodeModel } from './opencode-models' -import { proxyToOpenCodeWithDirectory } from './proxy' +import type { OpenCodeClient } from './opencode/client' import { sseAggregator, type SSEEvent } from './sse-aggregator' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' @@ -118,9 +118,13 @@ type SkillInfo = { content: string } -async function fetchSkillContent(slugs: string[], repoPath: string): Promise { +async function fetchSkillContent(slugs: string[], repoPath: string, openCodeClient: OpenCodeClient): Promise { try { - const response = await proxyToOpenCodeWithDirectory('/skill', 'GET', repoPath) + const response = await openCodeClient.forward({ + method: 'GET', + path: '/skill', + directory: repoPath, + }) if (!response.ok) { logger.warn(`Failed to fetch skills from OpenCode (${response.status}), falling back to name-only injection`) return [] @@ -159,11 +163,12 @@ async function fetchSkillContent(slugs: string[], repoPath: string): Promise { if (!skillMetadata || !skillMetadata.skillSlugs || skillMetadata.skillSlugs.length === 0) return prompt - const skillBlocks = await fetchSkillContent(skillMetadata.skillSlugs, repoPath) + const skillBlocks = await fetchSkillContent(skillMetadata.skillSlugs, repoPath, openCodeClient) const notesLine = skillMetadata.notes ? `\nSkill notes: ${skillMetadata.notes}` : '' if (skillBlocks.length === 0) { @@ -310,11 +315,9 @@ function getSessionStatusType(event: SSEEvent): string | null { } function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { - const clientId = `schedule-monitor-${sessionId}-${Date.now()}` let errorText: string | null = null let idle = false - const removeClient = sseAggregator.addClient(clientId, () => {}, [directory]) const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { if (eventDirectory !== directory) { return @@ -342,10 +345,7 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni return { getErrorText: () => errorText, isIdle: () => idle, - dispose: () => { - unsubscribe() - removeClient() - }, + dispose: unsubscribe, } } @@ -353,7 +353,10 @@ export class ScheduleService { private static activeRuns = new Set() private onJobChange: ((job: ScheduleJob | null, jobId: number) => void) | null = null - constructor(private readonly db: Database) {} + constructor( + private readonly db: Database, + private readonly openCodeClient: OpenCodeClient, + ) {} setJobChangeHandler(handler: ((job: ScheduleJob | null, jobId: number) => void) | null): void { this.onJobChange = handler @@ -479,19 +482,20 @@ export class ScheduleService { }) try { - const model = await resolveOpenCodeModel(repo.fullPath, { + const model = await resolveOpenCodeModel(this.openCodeClient, repo.fullPath, { preferredModel: job.model, }) const sessionTitle = buildSessionTitle(job) - const sessionResponse = await proxyToOpenCodeWithDirectory( - '/session', - 'POST', - repo.fullPath, - JSON.stringify({ + const sessionResponse = await this.openCodeClient.forward({ + method: 'POST', + path: '/session', + directory: repo.fullPath, + body: JSON.stringify({ title: sessionTitle, agent: job.agentSlug ?? undefined, }), - ) + headers: { 'Content-Type': 'application/json' }, + }) if (!sessionResponse.ok) { throw new ScheduleServiceError('Failed to create OpenCode session', 502) @@ -591,11 +595,11 @@ export class ScheduleService { return this.getRun(repoId, jobId, runId) } - const abortResponse = await proxyToOpenCodeWithDirectory( - `/session/${run.sessionId}/abort`, - 'POST', - repo.fullPath, - ) + const abortResponse = await this.openCodeClient.forward({ + method: 'POST', + path: `/session/${run.sessionId}/abort`, + directory: repo.fullPath, + }) if (!abortResponse.ok) { const errorText = await abortResponse.text() @@ -650,15 +654,16 @@ export class ScheduleService { const repo = this.assertRepo(input.repoId) try { - const promptResponse = await proxyToOpenCodeWithDirectory( - `/session/${input.sessionId}/message`, - 'POST', - repo.fullPath, - JSON.stringify({ - parts: [{ type: 'text', text: await buildPromptWithSkills(input.job.prompt, input.job.skillMetadata, repo.fullPath) }], + const promptResponse = await this.openCodeClient.forward({ + method: 'POST', + path: `/session/${input.sessionId}/message`, + directory: repo.fullPath, + body: JSON.stringify({ + parts: [{ type: 'text', text: await buildPromptWithSkills(input.job.prompt, input.job.skillMetadata, repo.fullPath, this.openCodeClient) }], model: input.model, }), - ) + headers: { 'Content-Type': 'application/json' }, + }) if (!promptResponse.ok) { const errorText = await promptResponse.text() @@ -1020,11 +1025,11 @@ export class ScheduleService { } private async listSessionMessages(directory: string, sessionId: string): Promise { - const messagesResponse = await proxyToOpenCodeWithDirectory( - `/session/${sessionId}/message`, - 'GET', + const messagesResponse = await this.openCodeClient.forward({ + method: 'GET', + path: `/session/${sessionId}/message`, directory, - ) + }) if (!messagesResponse.ok) { const errorText = await messagesResponse.text() @@ -1035,7 +1040,11 @@ export class ScheduleService { } private async getSessionStatuses(directory: string): Promise> { - const response = await proxyToOpenCodeWithDirectory('/session/status', 'GET', directory) + const response = await this.openCodeClient.forward({ + method: 'GET', + path: '/session/status', + directory, + }) if (!response.ok) { const errorText = await response.text() diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index a9336a83..f9c9c43c 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -4,6 +4,8 @@ import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' import { parseJsonc } from '@opencode-manager/shared/utils' import { z } from 'zod' +import { encryptSecret, decryptSecret } from '../utils/crypto' +import { ENV } from '@opencode-manager/shared/config/env' import type { UserPreferences, SettingsResponse, @@ -42,6 +44,12 @@ interface CreateOpenCodeConfigOptions { suppressAutoDefault?: boolean } +interface OpenCodeServerPasswordState { + value: string + createdAt: number + updatedAt: number +} + export class SettingsService { private static lastKnownGoodConfigContent: string | null = null @@ -597,4 +605,60 @@ export class SettingsService { return false } } + + getOpenCodeServerPassword(): string { + const row = this.db.prepare('SELECT value FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string } | undefined + if (!row) { + return ENV.OPENCODE.SERVER_PASSWORD + } + try { + return decryptSecret(row.value) + } catch (error) { + logger.error('Failed to decrypt opencode_server_password, falling back to env', error) + return ENV.OPENCODE.SERVER_PASSWORD + } + } + + hasStoredOpenCodeServerPassword(): boolean { + const row = this.db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get('opencode_server_password') + return Boolean(row) + } + + getStoredOpenCodeServerPasswordState(): OpenCodeServerPasswordState | null { + 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 + if (!row) { + return null + } + + return { + value: row.value, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } + + restoreOpenCodeServerPasswordState(state: OpenCodeServerPasswordState | null): void { + if (!state) { + this.clearOpenCodeServerPassword() + return + } + + this.db.prepare(` + INSERT INTO app_secrets (key, value, created_at, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at, updated_at = excluded.updated_at + `).run('opencode_server_password', state.value, state.createdAt, state.updatedAt) + } + + setOpenCodeServerPassword(password: string): void { + const now = Date.now() + const encrypted = encryptSecret(password) + this.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('opencode_server_password', encrypted, now, now) + } + + clearOpenCodeServerPassword(): void { + this.db.prepare('DELETE FROM app_secrets WHERE key = ?').run('opencode_server_password') + } } diff --git a/backend/src/services/skills.ts b/backend/src/services/skills.ts index 6b914131..9511e955 100644 --- a/backend/src/services/skills.ts +++ b/backend/src/services/skills.ts @@ -5,9 +5,18 @@ import type { SkillFileInfo, SkillScope, CreateSkillRequest, UpdateSkillRequest import { SKILL_NAME_REGEX } from '@opencode-manager/shared' import { getWorkspacePath } from '@opencode-manager/shared/config/env' import { getRepoById, listRepos } from '../db/queries' +import type { Repo } from '@opencode-manager/shared/types' import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent, deletePath, listDirectory } from './file-operations' +import type { OpenCodeClient } from './opencode/client' import { logger } from '../utils/logger' +interface OpenCodeSkillInfo { + name: string + description: string + location: string + content: string +} + function getGlobalSkillsPath(): string { return path.join(getWorkspacePath(), '.config', 'opencode', 'skills') } @@ -16,43 +25,47 @@ function getOldGlobalSkillsPath(): string { return path.join(os.homedir(), '.config', 'opencode', 'skills') } +function getProjectSkillsPath(repo: Repo): string { + return path.join(repo.fullPath, '.opencode', 'skills') +} + export async function migrateGlobalSkills(): Promise { const oldSkillsPath = getOldGlobalSkillsPath() const newSkillsPath = getGlobalSkillsPath() - + const oldSkillsExist = await fileExists(oldSkillsPath) if (!oldSkillsExist) { logger.debug('No old global skills found to migrate') return } - + const entries = await listDirectory(oldSkillsPath) const skillDirs = entries.filter(entry => entry.isDirectory) - + if (skillDirs.length === 0) { logger.debug('No skill directories found in old location') return } - + let migratedCount = 0 let skippedCount = 0 - + for (const entry of skillDirs) { const oldSkillPath = path.join(entry.path, 'SKILL.md') const newSkillPath = path.join(newSkillsPath, entry.name, 'SKILL.md') - + const alreadyMigrated = await fileExists(newSkillPath) if (alreadyMigrated) { skippedCount++ continue } - + const skillExists = await fileExists(oldSkillPath) if (!skillExists) { logger.warn(`Skill ${entry.name} has no SKILL.md file, skipping`) continue } - + try { const content = await readFileContent(oldSkillPath) await writeFileContent(newSkillPath, content) @@ -62,306 +75,167 @@ export async function migrateGlobalSkills(): Promise { logger.error(`Failed to migrate skill ${entry.name}:`, error) } } - + if (migratedCount > 0 || skippedCount > 0) { logger.info(`Skill migration complete: ${migratedCount} migrated, ${skippedCount} skipped (already existed)`) } } -interface ParsedSkillFile { - frontmatter: { - name: string - description: string - license?: string - compatibility?: string - metadata?: Record +function validateSkillName(name: string): void { + if (!SKILL_NAME_REGEX.test(name)) { + throw new Error('Invalid skill name. Must be lowercase alphanumeric with hyphens only.') } - body: string } -function parseSkillFile(content: string): ParsedSkillFile { - const firstDelim = content.indexOf('---') - if (firstDelim === -1) { - throw new Error('Invalid SKILL.md format: missing frontmatter delimiters') - } - const afterFirst = firstDelim + 3 - const secondDelim = content.indexOf('\n---', afterFirst) - if (secondDelim === -1) { - throw new Error('Invalid SKILL.md format: missing closing frontmatter delimiter') - } - const frontmatterStr = content.substring(afterFirst, secondDelim).trim() - const afterSecond = secondDelim + 4 - const body = content.substring(afterSecond).trim() - - const frontmatter: Record = {} - const lines = frontmatterStr.split('\n') - - let currentKey: string | null = null - const metadataObj: Record = {} - - for (const line of lines) { - const trimmedLine = line.trim() - if (!trimmedLine) continue - - const kvMatch = trimmedLine.match(/^([^:]+):\s*(.*)$/) - if (kvMatch) { - const key = kvMatch[1]?.trim() - const value = kvMatch[2]?.trim() - - if (key === 'metadata') { - currentKey = 'metadata' - continue - } - - if (key && value !== undefined) { - frontmatter[key] = value - currentKey = null - } - } else if (currentKey === 'metadata' && trimmedLine) { - const metaMatch = trimmedLine.match(/^\s*([^:]+):\s*(.*)$/) - if (metaMatch) { - const metaKey = metaMatch[1]?.trim() - const metaValue = metaMatch[2]?.trim() - if (metaKey && metaValue !== undefined) { - metadataObj[metaKey] = metaValue - } - } - } +function getSkillFilePath(db: Database, scope: SkillScope, name: string, repoId?: number): string { + validateSkillName(name) + if (scope === 'global') { + return path.join(getGlobalSkillsPath(), name, 'SKILL.md') } - - if (Object.keys(metadataObj).length > 0) { - frontmatter.metadata = metadataObj + if (!repoId) { + throw new Error('repoId is required for project-scoped skills') } - - return { - frontmatter: { - name: frontmatter.name as string || '', - description: frontmatter.description as string || '', - license: frontmatter.license as string | undefined, - compatibility: frontmatter.compatibility as string | undefined, - metadata: frontmatter.metadata as Record | undefined, - }, - body, + const repo = getRepoById(db, repoId) + if (!repo) { + throw new Error(`Repository with id ${repoId} not found`) } + return path.join(getProjectSkillsPath(repo), name, 'SKILL.md') } -function generateSkillFrontmatter( - name: string, - description: string, - license?: string, - compatibility?: string, - metadata?: Record -): string { - let frontmatter = `name: ${name}\ndescription: ${description}` - - if (license) { - frontmatter += `\nlicense: ${license}` - } - - if (compatibility) { - frontmatter += `\ncompatibility: ${compatibility}` - } - - if (metadata && Object.keys(metadata).length > 0) { - frontmatter += '\nmetadata:' - for (const [key, value] of Object.entries(metadata)) { - frontmatter += `\n ${key}: ${value}` +function buildSkillFileContent(name: string, description: string, body: string): string { + return `---\nname: ${name}\ndescription: ${description}\n---\n${body}` +} + +async function fetchOpenCodeSkills(openCodeClient: OpenCodeClient, directory: string): Promise { + try { + const response = await openCodeClient.forward({ + method: 'GET', + path: '/skill', + directory, + }) + if (!response.ok) { + logger.warn(`Failed to fetch skills from OpenCode (${response.status})`) + return [] } + return await response.json() as OpenCodeSkillInfo[] + } catch (error) { + logger.warn('Error fetching skills from OpenCode:', error) + return [] } - - return frontmatter } -function getSkillDirectoryPath(db: Database, scope: SkillScope, repoId?: number): string { - if (scope === 'global') { - return getGlobalSkillsPath() +function classifySkillLocation( + location: string, + globalPrefix: string, + repos: Repo[], +): { scope: SkillScope; repo?: Repo } | null { + if (location.startsWith(globalPrefix + path.sep)) { + return { scope: 'global' } } - - if (!repoId) { - throw new Error('repoId is required for project-scoped skills') - } - - const repo = getRepoById(db, repoId) - if (!repo) { - throw new Error(`Repository with id ${repoId} not found`) + for (const repo of repos) { + const projectPrefix = getProjectSkillsPath(repo) + if (location.startsWith(projectPrefix + path.sep)) { + return { scope: 'project', repo } + } } - - return path.join(repo.fullPath, '.opencode', 'skills') + return null } -function validateSkillName(name: string): void { - if (!SKILL_NAME_REGEX.test(name)) { - throw new Error('Invalid skill name. Must be lowercase alphanumeric with hyphens only.') +function toSkillFileInfo( + skill: OpenCodeSkillInfo, + classification: { scope: SkillScope; repo?: Repo }, +): SkillFileInfo { + return { + name: skill.name, + description: skill.description, + body: skill.content, + scope: classification.scope, + location: skill.location, + repoId: classification.repo?.id, + repoName: classification.repo?.localPath, } } -function getSkillFilePath(db: Database, scope: SkillScope, name: string, repoId?: number): string { - validateSkillName(name) - const skillsDir = getSkillDirectoryPath(db, scope, repoId) - return path.join(skillsDir, name, 'SKILL.md') -} +export async function listManagedSkills( + db: Database, + openCodeClient: OpenCodeClient, + repoId?: number, +): Promise { + const globalPrefix = getGlobalSkillsPath() + const allRepos = listRepos(db) -export async function listManagedSkills(db: Database, repoId?: number): Promise { - const skills: SkillFileInfo[] = [] - - const globalSkillsDir = getGlobalSkillsPath() - const globalSkillsExist = await fileExists(globalSkillsDir) - - if (globalSkillsExist) { - const entries = await listDirectory(globalSkillsDir) - for (const entry of entries) { - if (entry.isDirectory) { - const skillPath = path.join(entry.path, 'SKILL.md') - const skillExists = await fileExists(skillPath) - if (skillExists) { - const skillInfo = await readSkillFile(db, 'global', entry.name) - if (skillInfo) { - skills.push(skillInfo) - } - } - } - } + const targetRepos = repoId + ? allRepos.filter(r => r.id === repoId) + : allRepos + + if (repoId && targetRepos.length === 0) { + throw new Error(`Repository with id ${repoId} not found`) } - - if (repoId) { - const repo = getRepoById(db, repoId) - if (repo) { - const projectSkillsDir = path.join(repo.fullPath, '.opencode', 'skills') - const projectSkillsExist = await fileExists(projectSkillsDir) - - if (projectSkillsExist) { - const entries = await listDirectory(projectSkillsDir) - for (const entry of entries) { - if (entry.isDirectory) { - const skillPath = path.join(entry.path, 'SKILL.md') - const skillExists = await fileExists(skillPath) - if (skillExists) { - const skillInfo = await readSkillFile(db, 'project', entry.name, repoId) - if (skillInfo) { - skills.push(skillInfo) - } - } - } - } - } - } - } else { - const allRepos = listRepos(db) - for (const repo of allRepos) { - const projectSkillsDir = path.join(repo.fullPath, '.opencode', 'skills') - const projectSkillsExist = await fileExists(projectSkillsDir) - - if (projectSkillsExist) { - const entries = await listDirectory(projectSkillsDir) - for (const entry of entries) { - if (entry.isDirectory) { - const skillPath = path.join(entry.path, 'SKILL.md') - const skillExists = await fileExists(skillPath) - if (skillExists) { - const skillInfo = await readSkillFile(db, 'project', entry.name, repo.id) - if (skillInfo) { - skills.push(skillInfo) - } - } - } - } - } + + const directories = targetRepos.length > 0 + ? targetRepos.map(r => r.fullPath) + : [getWorkspacePath()] + + const seenLocations = new Set() + const result: SkillFileInfo[] = [] + + for (const directory of directories) { + const skills = await fetchOpenCodeSkills(openCodeClient, directory) + for (const skill of skills) { + if (seenLocations.has(skill.location)) continue + const classification = classifySkillLocation(skill.location, globalPrefix, allRepos) + if (!classification) continue + seenLocations.add(skill.location) + result.push(toSkillFileInfo(skill, classification)) } } - - return skills + + return result } export async function getSkill( db: Database, + openCodeClient: OpenCodeClient, name: string, scope: SkillScope, - repoId?: number + repoId?: number, ): Promise { - const skillPath = getSkillFilePath(db, scope, name, repoId) - const exists = await fileExists(skillPath) - - if (!exists) { + validateSkillName(name) + const skills = await listManagedSkills(db, openCodeClient, repoId) + const match = skills.find(s => + s.name === name && + s.scope === scope && + (scope === 'global' || s.repoId === repoId), + ) + if (!match) { throw new Error(`Skill "${name}" not found in ${scope} scope`) } - - const skillInfo = await readSkillFile(db, scope, name, repoId) - if (!skillInfo) { - throw new Error(`Failed to read skill "${name}"`) - } - - return skillInfo -} - -async function readSkillFile( - db: Database, - scope: SkillScope, - name: string, - repoId?: number -): Promise { - try { - const skillPath = getSkillFilePath(db, scope, name, repoId) - const content = await readFileContent(skillPath) - const parsed = parseSkillFile(content) - - const repo = repoId ? getRepoById(db, repoId) : null - - return { - name: parsed.frontmatter.name, - description: parsed.frontmatter.description, - body: parsed.body, - license: parsed.frontmatter.license, - compatibility: parsed.frontmatter.compatibility, - metadata: parsed.frontmatter.metadata, - scope, - location: skillPath, - repoId: scope === 'project' ? repoId : undefined, - repoName: repo?.localPath, - } - } catch (error) { - logger.error(`Failed to read skill ${name}:`, error) - return null - } + return match } export async function createSkill( db: Database, - input: CreateSkillRequest + input: CreateSkillRequest, ): Promise { - const { name, description, body, license, compatibility, metadata, scope, repoId } = input - + const { name, description, body, scope, repoId } = input + const skillPath = getSkillFilePath(db, scope, name, repoId) const exists = await fileExists(skillPath) - + if (exists) { throw new Error(`Skill "${name}" already exists in ${scope} scope`) } - - const skillDir = path.dirname(skillPath) - await ensureDirectoryExists(skillDir) - - const frontmatter = generateSkillFrontmatter( - name, - description, - license, - compatibility, - metadata - ) - - const content = `---\n${frontmatter}\n---\n${body}` - - await writeFileContent(skillPath, content) + + await ensureDirectoryExists(path.dirname(skillPath)) + await writeFileContent(skillPath, buildSkillFileContent(name, description, body)) logger.info(`Created skill "${name}" at ${skillPath}`) - + const repo = repoId ? getRepoById(db, repoId) : null - + return { name, description, body, - license, - compatibility, - metadata, scope, location: skillPath, repoId: scope === 'project' ? repoId : undefined, @@ -371,63 +245,35 @@ export async function createSkill( export async function updateSkill( db: Database, + openCodeClient: OpenCodeClient, name: string, scope: SkillScope, input: UpdateSkillRequest, - repoId?: number + repoId?: number, ): Promise { const skillPath = getSkillFilePath(db, scope, name, repoId) const exists = await fileExists(skillPath) - + if (!exists) { throw new Error(`Skill "${name}" not found in ${scope} scope`) } - - const existingContent = await readFileContent(skillPath) - const parsed = parseSkillFile(existingContent) - - const resolveField = (field: T | null | undefined, existing: T | undefined): T | undefined => { - if (field === null) return undefined - if (field === undefined) return existing - return field - } - const updatedFrontmatter = { - name: parsed.frontmatter.name, - description: input.description ?? parsed.frontmatter.description, - license: resolveField(input.license, parsed.frontmatter.license), - compatibility: resolveField(input.compatibility, parsed.frontmatter.compatibility), - metadata: resolveField(input.metadata, parsed.frontmatter.metadata), - } - - const updatedBody = input.body ?? parsed.body - - const frontmatter = generateSkillFrontmatter( - updatedFrontmatter.name, - updatedFrontmatter.description, - updatedFrontmatter.license, - updatedFrontmatter.compatibility, - updatedFrontmatter.metadata - ) - - const content = `---\n${frontmatter}\n---\n${updatedBody}` - - await writeFileContent(skillPath, content) + const existing = await getSkill(db, openCodeClient, name, scope, repoId) + + const description = input.description ?? existing.description + const body = input.body ?? existing.body + + await writeFileContent(skillPath, buildSkillFileContent(name, description, body)) logger.info(`Updated skill "${name}" at ${skillPath}`) - - const repo = repoId ? getRepoById(db, repoId) : null - + return { - name: updatedFrontmatter.name, - description: updatedFrontmatter.description, - body: updatedBody, - license: updatedFrontmatter.license, - compatibility: updatedFrontmatter.compatibility, - metadata: updatedFrontmatter.metadata, + name, + description, + body, scope, location: skillPath, - repoId: scope === 'project' ? repoId : undefined, - repoName: repo?.localPath, + repoId: existing.repoId, + repoName: existing.repoName, } } @@ -435,16 +281,15 @@ export async function deleteSkill( db: Database, name: string, scope: SkillScope, - repoId?: number + repoId?: number, ): Promise { const skillPath = getSkillFilePath(db, scope, name, repoId) const exists = await fileExists(skillPath) - + if (!exists) { throw new Error(`Skill "${name}" not found in ${scope} scope`) } - - const skillDir = path.dirname(skillPath) - await deletePath(skillDir) - logger.info(`Deleted skill "${name}" from ${skillDir}`) + + await deletePath(path.dirname(skillPath)) + logger.info(`Deleted skill "${name}" from ${path.dirname(skillPath)}`) } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 6971772f..b763495b 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -2,6 +2,7 @@ import { EventSource } from 'eventsource' import { logger } from '../utils/logger' import { ENV } from '@opencode-manager/shared/config/env' import { DEFAULTS } from '@opencode-manager/shared/config' +import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' type SSEClientCallback = (event: string, data: string) => void type SSEEventListener = (directory: string, event: SSEEvent) => void @@ -14,33 +15,73 @@ interface SSEClient { activeSessionId: string | null } -interface DirectoryConnection { - eventSource: EventSource | null - reconnectTimeout: ReturnType | null - reconnectDelay: number - isConnected: boolean -} - export interface SSEEvent { type: string properties: Record } +interface GlobalEventEnvelope { + directory?: string + project?: string + workspace?: string + payload: SSEEvent +} + +export interface PendingActionsFetcher { + getJson(path: string, opts?: { directory?: string; signal?: AbortSignal }): Promise +} + +interface PendingPermission { + id: string + sessionID: string + [key: string]: unknown +} + +interface PendingQuestion { + id: string + sessionID: string + [key: string]: unknown +} + const OPENCODE_PORT = ENV.OPENCODE.PORT -const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS, IDLE_GRACE_PERIOD_MS } = DEFAULTS.SSE +const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE class SSEAggregator { private static instance: SSEAggregator private clients: Map = new Map() - private connections: Map = new Map() private activeSessions: Map> = new Map() - private idleTimeouts: Map> = new Map() - private sessionStateVersion: Map = new Map() private eventListeners: Set = new Set() private subagentSessions: Map> = new Map() + private upstream: EventSource | null = null + private reconnectTimeout: ReturnType | null = null + private reconnectDelay: number = RECONNECT_DELAY_MS + private upstreamConnected = false + private everConnected = false + private started = false + private pendingActionsFetcher: PendingActionsFetcher | null = null + private passwordResolver: OpenCodePasswordResolver | null = null private constructor() {} + setPendingActionsFetcher(fetcher: PendingActionsFetcher | null): void { + this.pendingActionsFetcher = fetcher + } + + setPasswordResolver(resolver: OpenCodePasswordResolver | null): void { + this.passwordResolver = resolver + } + + reconnect(): void { + if (!this.started) return + logger.info('SSE forcing upstream reconnect (auth changed)') + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + this.reconnectDelay = RECONNECT_DELAY_MS + void this.connectUpstream() + } + static getInstance(): SSEAggregator { if (!SSEAggregator.instance) { SSEAggregator.instance = new SSEAggregator() @@ -48,6 +89,12 @@ class SSEAggregator { return SSEAggregator.instance } + start(): void { + if (this.started) return + this.started = true + void this.connectUpstream() + } + addClient(id: string, callback: SSEClientCallback, directories: string[]): () => void { const client: SSEClient = { id, @@ -57,16 +104,18 @@ class SSEAggregator { activeSessionId: null } this.clients.set(id, client) - + logger.info(`Client ${id} connected with directories: ${directories.length > 0 ? directories.join(', ') : '(none)'}`) - this.syncConnections() + + if (directories.length > 0) { + void this.replayPendingActionsForClient(id, directories) + } return () => this.removeClient(id) } removeClient(id: string): void { this.clients.delete(id) - this.syncConnections() } addDirectories(clientId: string, directories: string[]): boolean { @@ -75,9 +124,19 @@ class SSEAggregator { logger.warn(`addDirectories: client ${clientId} not found`) return false } - directories.forEach(dir => client.directories.add(dir)) + const newDirectories: string[] = [] + directories.forEach(dir => { + if (!client.directories.has(dir)) { + newDirectories.push(dir) + } + client.directories.add(dir) + }) logger.info(`Client ${clientId} subscribed to: ${directories.join(', ')}`) - this.syncConnections() + + if (newDirectories.length > 0) { + void this.replayPendingActionsForClient(clientId, newDirectories) + } + return true } @@ -89,114 +148,148 @@ class SSEAggregator { } directories.forEach(dir => client.directories.delete(dir)) logger.info(`Client ${clientId} unsubscribed from: ${directories.join(', ')}`) - this.syncConnections() return true } - private getRequiredDirectories(): Set { - const dirs = new Set() - this.clients.forEach(client => { - client.directories.forEach(dir => dirs.add(dir)) - }) - return dirs - } - - private syncConnections(): void { - const required = this.getRequiredDirectories() - - this.connections.forEach((_, dir) => { - if (!required.has(dir)) { - this.disconnectDirectory(dir) - } - }) + private async replayPendingActionsForClient(clientId: string, directories: string[]): Promise { + const fetcher = this.pendingActionsFetcher + if (!fetcher) return - required.forEach(dir => { - if (!this.connections.has(dir)) { - this.connectDirectory(dir) - } - }) + await Promise.allSettled(directories.map(directory => + this.replayPendingActionsForDirectory(clientId, directory, fetcher) + )) } - private connectDirectory(directory: string): void { - if (this.connections.has(directory)) return + private async replayPendingActionsForAllClients(): Promise { + const fetcher = this.pendingActionsFetcher + if (!fetcher) return - const conn: DirectoryConnection = { - eventSource: null, - reconnectTimeout: null, - reconnectDelay: RECONNECT_DELAY_MS, - isConnected: false - } - this.connections.set(directory, conn) + const tasks: Promise[] = [] + this.clients.forEach((client) => { + const directories = Array.from(client.directories) + if (directories.length === 0) return + tasks.push(this.replayPendingActionsForClient(client.id, directories)) + }) - this.establishConnection(directory) + if (tasks.length === 0) return + logger.info(`replay: replaying pending actions to ${tasks.length} client(s) after upstream reconnect`) + await Promise.allSettled(tasks) } - private establishConnection(directory: string): void { - const conn = this.connections.get(directory) - if (!conn) return - - if (conn.eventSource) { - conn.eventSource.close() - conn.eventSource = null + private async replayPendingActionsForDirectory( + clientId: string, + directory: string, + fetcher: PendingActionsFetcher, + ): Promise { + const [permissionsResult, questionsResult] = await Promise.allSettled([ + fetcher.getJson('/permission', { directory }), + fetcher.getJson('/question', { directory }), + ]) + + if (permissionsResult.status === 'rejected') { + logger.warn(`replay: failed to fetch pending permissions for ${directory}: ${String(permissionsResult.reason)}`) + } else { + this.emitPendingEventsToClient(clientId, directory, 'permission.asked', permissionsResult.value) } - const url = new URL(`http://127.0.0.1:${OPENCODE_PORT}/event`) - url.searchParams.set('directory', directory) - - logger.info(`SSE connecting to OpenCode: ${directory}`) - - const eventSource = new EventSource(url.toString()) - conn.eventSource = eventSource - - eventSource.onopen = () => { - logger.info(`SSE connected: ${directory}`) - conn.isConnected = true - conn.reconnectDelay = RECONNECT_DELAY_MS + if (questionsResult.status === 'rejected') { + logger.warn(`replay: failed to fetch pending questions for ${directory}: ${String(questionsResult.reason)}`) + } else { + this.emitPendingEventsToClient(clientId, directory, 'question.asked', questionsResult.value) } + } - eventSource.onerror = () => { - conn.isConnected = false + private emitPendingEventsToClient( + clientId: string, + directory: string, + type: 'permission.asked' | 'question.asked', + items: Array | null, + ): void { + if (!items || items.length === 0) return - if (conn.eventSource) { - conn.eventSource.close() - conn.eventSource = null - } + const client = this.clients.get(clientId) + if (!client || !client.directories.has(directory)) return - if (this.connections.has(directory)) { - this.scheduleReconnect(directory) + for (const item of items) { + const payload = JSON.stringify({ type, properties: item, directory }) + try { + client.callback('message', payload) + } catch (error) { + logger.error(`replay: failed to deliver ${type} to client ${clientId}:`, error) + return } } - eventSource.onmessage = (event) => { - this.broadcastToDirectory(directory, 'message', event.data) - } + logger.info(`replay: sent ${items.length} ${type} event(s) for ${directory} to client ${clientId}`) } - private disconnectDirectory(directory: string): void { - const conn = this.connections.get(directory) - if (!conn) return + private async connectUpstream(): Promise { + if (!this.started) return + if (this.upstream) { + this.upstream.close() + this.upstream = null + } - if (conn.reconnectTimeout) { - clearTimeout(conn.reconnectTimeout) + const url = `http://127.0.0.1:${OPENCODE_PORT}/global/event` + const wasConnectedBefore = this.everConnected + logger.info(`SSE connecting to OpenCode global stream: ${url}`) + + const authHeader = this.passwordResolver + ? await getOpenCodeBasicAuthHeader(this.passwordResolver) + : getOpenCodeBasicAuthHeader() + + if (!this.started) return + + const init: ConstructorParameters[1] = authHeader + ? { + fetch: (input, fetchInit) => + fetch(input, { + ...fetchInit, + headers: { + ...(fetchInit?.headers ?? {}), + Authorization: authHeader, + }, + }), + } + : undefined + + const es = new EventSource(url, init) + this.upstream = es + + es.onopen = () => { + logger.info('SSE global stream connected') + this.upstreamConnected = true + this.reconnectDelay = RECONNECT_DELAY_MS + this.everConnected = true + if (wasConnectedBefore) { + void this.replayPendingActionsForAllClients() + } } - if (conn.eventSource) { - conn.eventSource.close() + es.onerror = (event) => { + this.upstreamConnected = false + if (es === this.upstream) { + const code = (event as { code?: number }).code + const message = (event as { message?: string }).message + logger.warn(`SSE upstream error${code ? ` (code=${code})` : ''}${message ? `: ${message}` : ''}`) + es.close() + this.upstream = null + this.scheduleReconnect() + } } - this.connections.delete(directory) - logger.info(`SSE disconnected: ${directory}`) + es.onmessage = (event) => { + this.handleUpstreamMessage(event.data) + } } - private scheduleReconnect(directory: string): void { - const conn = this.connections.get(directory) - if (!conn || conn.reconnectTimeout) return - - conn.reconnectTimeout = setTimeout(() => { - conn.reconnectTimeout = null - conn.reconnectDelay = Math.min(conn.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS) - this.establishConnection(directory) - }, conn.reconnectDelay) + private scheduleReconnect(): void { + if (!this.started || this.reconnectTimeout) return + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null + this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS) + void this.connectUpstream() + }, this.reconnectDelay) } onEvent(listener: SSEEventListener): () => void { @@ -204,24 +297,30 @@ class SSEAggregator { return () => { this.eventListeners.delete(listener) } } - private broadcastToDirectory(directory: string, event: string, data: string): void { - let clientData = data - + private handleUpstreamMessage(data: string): void { + let envelope: GlobalEventEnvelope try { - const parsed = JSON.parse(data) as SSEEvent - this.handleEvent(directory, parsed) - this.eventListeners.forEach(listener => { - try { listener(directory, parsed) } catch { /* ignore listener errors */ } - }) - clientData = JSON.stringify({ ...parsed, directory }) + envelope = JSON.parse(data) as GlobalEventEnvelope } catch { - // Ignore parse errors + return } + if (!envelope.directory || !envelope.payload?.type) return + + const directory = envelope.directory + const parsed = envelope.payload + + this.handleEvent(directory, parsed) + + this.eventListeners.forEach(listener => { + try { listener(directory, parsed) } catch { /* ignore listener errors */ } + }) + + const clientData = JSON.stringify({ ...parsed, directory }) this.clients.forEach((client) => { if (client.directories.has(directory)) { try { - client.callback(event, clientData) + client.callback('message', clientData) } catch (error) { logger.error(`Failed to send to client ${client.id}:`, error) } @@ -235,11 +334,11 @@ class SSEAggregator { if (type === 'session.status') { const sessionID = properties.sessionID as string const status = properties.status as { type: string } - + if (!sessionID || !status) return const isActive = status.type === 'busy' || status.type === 'retry' || status.type === 'compact' - + if (isActive) { this.markSessionActive(directory, sessionID) } else if (status.type === 'idle') { @@ -274,106 +373,32 @@ class SSEAggregator { } } - private getStateVersion(directory: string): number { - return this.sessionStateVersion.get(directory) ?? 0 - } - - private incrementStateVersion(directory: string): number { - const newVersion = this.getStateVersion(directory) + 1 - this.sessionStateVersion.set(directory, newVersion) - return newVersion - } - private markSessionActive(directory: string, sessionID: string): void { - this.incrementStateVersion(directory) - - const existingTimeout = this.idleTimeouts.get(directory) - if (existingTimeout) { - clearTimeout(existingTimeout) - this.idleTimeouts.delete(directory) - } - let sessions = this.activeSessions.get(directory) if (!sessions) { sessions = new Set() this.activeSessions.set(directory, sessions) } sessions.add(sessionID) - + logger.info(`Session active: ${sessionID} in ${directory} (${sessions.size} active)`) } private markSessionIdle(directory: string, sessionID: string): void { - const existingTimeout = this.idleTimeouts.get(directory) - if (existingTimeout) { - clearTimeout(existingTimeout) - this.idleTimeouts.delete(directory) - } - const sessions = this.activeSessions.get(directory) if (sessions) { sessions.delete(sessionID) logger.info(`Session idle: ${sessionID} in ${directory} (${sessions.size} active)`) - + if (sessions.size === 0) { this.activeSessions.delete(directory) - this.scheduleIdleDisconnect(directory) } } } - private hasActiveViewers(directory: string): boolean { - for (const client of this.clients.values()) { - if (client.directories.has(directory)) { - return true - } - } - return false - } - - private scheduleIdleDisconnect(directory: string): void { - if (this.hasActiveViewers(directory)) { - logger.info(`Skipping idle disconnect for ${directory} - has active viewers`) - return - } - - const existingTimeout = this.idleTimeouts.get(directory) - if (existingTimeout) { - clearTimeout(existingTimeout) - } - - const versionAtSchedule = this.getStateVersion(directory) - logger.info(`Scheduling idle disconnect for ${directory} in ${IDLE_GRACE_PERIOD_MS}ms (version: ${versionAtSchedule})`) - - const timeout = setTimeout(() => { - this.idleTimeouts.delete(directory) - - const currentVersion = this.getStateVersion(directory) - if (currentVersion !== versionAtSchedule) { - logger.info(`Cancelled idle disconnect for ${directory} - state changed (${versionAtSchedule} -> ${currentVersion})`) - return - } - - const sessions = this.activeSessions.get(directory) - const hasViewers = this.hasActiveViewers(directory) - - if ((!sessions || sessions.size === 0) && !hasViewers) { - logger.info(`Idle disconnect: ${directory}`) - this.disconnectDirectory(directory) - } else if (hasViewers) { - logger.info(`Cancelled idle disconnect for ${directory} - has active viewers`) - } - }, IDLE_GRACE_PERIOD_MS) - - this.idleTimeouts.set(directory, timeout) - } - getConnectionStatus(): { connected: number; total: number } { - let connected = 0 - this.connections.forEach(conn => { - if (conn.isConnected) connected++ - }) - return { connected, total: this.connections.size } + const total = this.started ? 1 : 0 + return { connected: this.upstreamConnected ? 1 : 0, total } } getClientCount(): number { @@ -410,30 +435,7 @@ class SSEAggregator { } getActiveDirectories(): string[] { - return Array.from(this.connections.keys()) - } - - shutdown(): void { - this.idleTimeouts.forEach((timeout) => { - clearTimeout(timeout) - }) - this.idleTimeouts.clear() - this.activeSessions.clear() - this.subagentSessions.clear() - this.sessionStateVersion.clear() - - this.connections.forEach((conn, dir) => { - if (conn.reconnectTimeout) { - clearTimeout(conn.reconnectTimeout) - } - if (conn.eventSource) { - conn.eventSource.close() - } - logger.info(`SSE closed: ${dir}`) - }) - this.connections.clear() - this.clients.clear() - this.eventListeners.clear() + return Array.from(this.activeSessions.keys()) } getActiveSessions(): Record { @@ -444,6 +446,25 @@ class SSEAggregator { return result } + shutdown(): void { + this.started = false + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + if (this.upstream) { + this.upstream.close() + this.upstream = null + } + this.upstreamConnected = false + + this.activeSessions.clear() + this.subagentSessions.clear() + this.clients.clear() + this.eventListeners.clear() + } + broadcastToAll(event: string, data: string): void { this.clients.forEach((client) => { try { diff --git a/backend/src/utils/rate-limit.test.ts b/backend/src/utils/rate-limit.test.ts new file mode 100644 index 00000000..2b318cdd --- /dev/null +++ b/backend/src/utils/rate-limit.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { TokenBucketRateLimiter } from './rate-limit' + +describe('TokenBucketRateLimiter', () => { + it('allows first 10 calls within capacity', () => { + const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 6000 }) + const results: boolean[] = [] + + for (let i = 0; i < 10; i++) { + const result = limiter.tryConsume('test-key') + results.push(result.allowed) + } + + expect(results.every((r) => r)).toBe(true) + }) + + it('rejects 11th call within rate window', () => { + const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 6000 }) + + for (let i = 0; i < 10; i++) { + limiter.tryConsume('test-key') + } + + const result = limiter.tryConsume('test-key') + expect(result.allowed).toBe(false) + expect(result.retryAfterMs).toBeGreaterThan(0) + }) + + it('refills tokens after time advances', () => { + let currentTime = 0 + const limiter = new TokenBucketRateLimiter( + { capacity: 10, refillPerMs: 10000 }, + () => currentTime, + ) + + for (let i = 0; i < 10; i++) { + limiter.tryConsume('test-key') + } + + let result = limiter.tryConsume('test-key') + expect(result.allowed).toBe(false) + + currentTime = 5000 + result = limiter.tryConsume('test-key') + expect(result.allowed).toBe(true) + }) + + it('maintains independent buckets for distinct keys', () => { + const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 6000 }) + + for (let i = 0; i < 10; i++) { + limiter.tryConsume('key-a') + } + + const resultA = limiter.tryConsume('key-a') + const resultB = limiter.tryConsume('key-b') + + expect(resultA.allowed).toBe(false) + expect(resultB.allowed).toBe(true) + }) +}) diff --git a/backend/src/utils/rate-limit.ts b/backend/src/utils/rate-limit.ts new file mode 100644 index 00000000..7659704c --- /dev/null +++ b/backend/src/utils/rate-limit.ts @@ -0,0 +1,47 @@ +export interface RateLimiterOptions { + capacity: number + refillPerMs: number +} + +interface BucketState { + tokens: number + lastRefill: number +} + +export class TokenBucketRateLimiter { + private buckets: Map + + constructor( + private opts: RateLimiterOptions, + private now: () => number = Date.now, + ) { + this.buckets = new Map() + } + + tryConsume(key: string, cost = 1): { allowed: boolean; retryAfterMs: number } { + const bucket = this.buckets.get(key) ?? this.createBucket(key) + + const elapsed = this.now() - bucket.lastRefill + const refill = (elapsed / this.opts.refillPerMs) * this.opts.capacity + bucket.tokens = Math.min(bucket.tokens + refill, this.opts.capacity) + bucket.lastRefill = this.now() + + if (bucket.tokens >= cost) { + bucket.tokens -= cost + return { allowed: true, retryAfterMs: 0 } + } + + const tokensNeeded = cost - bucket.tokens + const retryAfterMs = (tokensNeeded / this.opts.capacity) * this.opts.refillPerMs + return { allowed: false, retryAfterMs } + } + + private createBucket(key: string): BucketState { + const state: BucketState = { + tokens: this.opts.capacity, + lastRefill: this.now(), + } + this.buckets.set(key, state) + return state + } +} diff --git a/backend/test/auth/internal-token-middleware.test.ts b/backend/test/auth/internal-token-middleware.test.ts new file mode 100644 index 00000000..03efd57a --- /dev/null +++ b/backend/test/auth/internal-token-middleware.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalTokenMiddleware } from '../../src/auth/internal-token-middleware' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import migration013 from '../../src/db/migrations/013-app-secrets' + +describe('internal-token-middleware', () => { + function createTestDb(): Database { + const db = new Database(':memory:') + migration013.up(db) + return db + } + + function createTestApp(db: Database) { + const app = new Hono() + app.use('/*', createInternalTokenMiddleware(db)) + app.get('/test', (c) => c.json({ ok: true })) + return app + } + + it('returns 401 when authorization header is missing', async () => { + const db = createTestDb() + const app = createTestApp(db) + const res = await app.request('/test') + expect(res.status).toBe(401) + const body = await res.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 401 when authorization header is not bearer scheme', async () => { + const db = createTestDb() + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: 'Basic abc123' }, + }) + expect(res.status).toBe(401) + }) + + it('returns 401 when token is wrong', async () => { + const db = createTestDb() + const validToken = getOrCreateInternalToken(db) + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: `Bearer ${validToken}wrong` }, + }) + expect(res.status).toBe(401) + }) + + it('returns 401 when token has different length', async () => { + const db = createTestDb() + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: 'Bearer short' }, + }) + expect(res.status).toBe(401) + }) + + it('returns 200 when token matches', async () => { + const db = createTestDb() + const token = getOrCreateInternalToken(db) + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ ok: true }) + }) +}) diff --git a/backend/test/helpers/assistant-workspace.ts b/backend/test/helpers/assistant-workspace.ts new file mode 100644 index 00000000..30eba229 --- /dev/null +++ b/backend/test/helpers/assistant-workspace.ts @@ -0,0 +1,43 @@ +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import type { Repo } from '@opencode-manager/shared/types' + +export async function createTempAssistantWorkspace() { + const workspacePath = await mkdtemp(path.join(tmpdir(), 'oc-assistant-')) + process.env.WORKSPACE_PATH = workspacePath + const reposPath = path.join(workspacePath, 'repos') + const assistantDir = path.join(reposPath, 'assistant') + return { + workspacePath, + reposPath, + assistantDir, + cleanup: () => rm(workspacePath, { recursive: true, force: true }), + } +} + +export function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +export const mockRepo: Repo = { + id: 1, + repoUrl: 'https://github.com/example/test-repo.git', + localPath: 'test-repo', + fullPath: '/tmp/test-repo', + sourcePath: '/tmp/test-repo/.git', + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + lastPulled: Date.now(), + lastAccessedAt: Date.now(), + openCodeConfigName: 'default', + isWorktree: false, + isLocal: false, +} diff --git a/backend/test/helpers/stub-opencode-client.ts b/backend/test/helpers/stub-opencode-client.ts new file mode 100644 index 00000000..6c92f0bd --- /dev/null +++ b/backend/test/helpers/stub-opencode-client.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +export function createStubOpenCodeClient(overrides: Partial = {}): OpenCodeClient { + return { + forward: vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })), + forwardRaw: vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })), + getJson: vi.fn(async () => ({}) as unknown), + postJson: vi.fn(async () => ({}) as unknown), + setProviderAuth: vi.fn(async () => true), + deleteProviderAuth: vi.fn(async () => true), + startMcpAuth: vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })), + authenticateMcp: vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })), + ...overrides, + } as OpenCodeClient +} diff --git a/backend/test/routes/git.test.ts b/backend/test/routes/git.test.ts index 0d51d8a8..549c6e04 100644 --- a/backend/test/routes/git.test.ts +++ b/backend/test/routes/git.test.ts @@ -6,6 +6,7 @@ import type { Database } from 'bun:sqlite' import type { Repo } from '../../../shared/src/types' import type { GitAuthService } from '../../src/services/git-auth' import type { ScheduleService } from '../../src/services/schedules' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' vi.mock('bun:sqlite', () => ({ Database: vi.fn(), @@ -59,7 +60,7 @@ describe('Git Routes', () => { mockGitAuthService = { getGitEnvironment: vi.fn().mockReturnValue({}), } as unknown as GitAuthService - app = createRepoRoutes(mockDatabase, mockGitAuthService, {} as unknown as ScheduleService) + app = createRepoRoutes(mockDatabase, mockGitAuthService, {} as unknown as ScheduleService, createStubOpenCodeClient()) }) describe('GET /:id/git/status', () => { diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts new file mode 100644 index 00000000..f58093fb --- /dev/null +++ b/backend/test/routes/internal-notifications.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from 'bun:test' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +describe('internal/notifications routes', () => { + let db: Database + let scheduleService: ScheduleService + let notificationService: NotificationService + let settingsService: SettingsService + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + const openCodeClient = createOpenCodeClient() + scheduleService = new ScheduleService(db, openCodeClient) + notificationService = new NotificationService(db) + settingsService = new SettingsService(db) + app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) + token = getOrCreateInternalToken(db) + }) + + it('POST /api/internal/notifications/send returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(401) + }) + + it('POST /api/internal/notifications/send returns 401 with invalid bearer token', async () => { + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: 'Bearer invalid-token', + }, + }) + expect(res.status).toBe(401) + }) + + it('POST /api/internal/notifications/send returns 503 when VAPID not configured', async () => { + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(503) + }) + + it('POST /api/internal/notifications/send returns 200 with valid request (no subscriptions)', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 0, expired: 0, failed: 0, total: 0 }) + + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { delivered: number; expired: number; failed: number; noSubscriptions: boolean } + expect(body.delivered).toBe(0) + expect(body.expired).toBe(0) + expect(body.failed).toBe(0) + expect(body.noSubscriptions).toBe(true) + }) + + it('POST /api/internal/notifications/send returns 400 on invalid body (missing title)', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('POST /api/internal/notifications/send returns 400 on title > 120 chars', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'a'.repeat(121), body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('POST /api/internal/notifications/send returns 400 on body > 500 chars', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'b'.repeat(501) }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('POST /api/internal/notifications/send returns 400 on url > 500 chars', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 0, expired: 0, failed: 0, total: 0 }) + + const res = await app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body', url: 'u'.repeat(501) }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('POST /api/internal/notifications/send returns 429 after 10 calls within rate window', async () => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 0, expired: 0, failed: 0, total: 0 }) + + const makeRequest = async () => { + return app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + } + + for (let i = 0; i < 10; i++) { + const res = await makeRequest() + expect(res.status).toBe(200) + } + + const res = await makeRequest() + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBeTruthy() + }) +}) diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts new file mode 100644 index 00000000..860860dd --- /dev/null +++ b/backend/test/routes/internal-schedules.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +describe('internal-schedules routes', () => { + let db: Database + let scheduleService: ScheduleService + let notificationService: NotificationService + let settingsService: SettingsService + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + const openCodeClient = createOpenCodeClient() + scheduleService = new ScheduleService(db, openCodeClient) + notificationService = new NotificationService(db) + settingsService = new SettingsService(db) + app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/schedules/all returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/schedules/all') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/schedules/all returns 200 with bearer token', async () => { + const res = await app.request('/api/internal/schedules/all', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { jobs: unknown[] } + expect(body).toHaveProperty('jobs') + expect(Array.isArray(body.jobs)).toBe(true) + }) + + it('GET /api/internal/schedules/all/runs returns 200 with bearer token', async () => { + const res = await app.request('/api/internal/schedules/all/runs', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { runs: unknown[] } + expect(body).toHaveProperty('runs') + }) + + it('POST /api/internal/repos/:id/schedules/:jobId/run returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/repos/1/schedules/1/run', { + method: 'POST', + }) + expect(res.status).toBe(401) + }) +}) diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts new file mode 100644 index 00000000..18c406f2 --- /dev/null +++ b/backend/test/routes/internal-settings.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +describe('internal/settings routes', () => { + let db: Database + let scheduleService: ScheduleService + let notificationService: NotificationService + let settingsService: SettingsService + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + const openCodeClient = createOpenCodeClient() + scheduleService = new ScheduleService(db, openCodeClient) + notificationService = new NotificationService(db) + settingsService = new SettingsService(db) + app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/settings returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/settings') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/settings returns 200 with bearer token', async () => { + const res = await app.request('/api/internal/settings', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { preferences: unknown; updatedAt: number } + expect(body).toHaveProperty('preferences') + expect(body).toHaveProperty('updatedAt') + }) + + it('GET /api/internal/settings returns merged defaults', async () => { + const res = await app.request('/api/internal/settings', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { preferences: { theme: string; mode: string } } + expect(body.preferences.theme).toBe('dark') + expect(body.preferences.mode).toBe('build') + }) + + it('PATCH /api/internal/settings returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ theme: 'dark' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(401) + }) + + it('PATCH /api/internal/settings with { theme: "dark" } persists and returns new settings', async () => { + const patchRes = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ theme: 'dark' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(patchRes.status).toBe(200) + + const getRes = await app.request('/api/internal/settings', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(getRes.status).toBe(200) + const body = await getRes.json() as { preferences: { theme: string } } + expect(body.preferences.theme).toBe('dark') + }) + + it('PATCH /api/internal/settings with { gitCredentials: [...] } returns 400 (strict reject)', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ gitCredentials: [{ name: 'test', token: 'secret' }] }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('PATCH /api/internal/settings with { tts: { apiKey: "secret" } } returns 400 (strict reject)', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ tts: { apiKey: 'secret' } }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) + + it('PATCH /api/internal/settings with { theme: "rainbow" } returns 400 (enum reject)', async () => { + const res = await app.request('/api/internal/settings', { + method: 'PATCH', + body: JSON.stringify({ theme: 'rainbow' }), + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }) + expect(res.status).toBe(400) + }) +}) diff --git a/backend/test/routes/memory.test.ts b/backend/test/routes/memory.test.ts deleted file mode 100644 index 953797bc..00000000 --- a/backend/test/routes/memory.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn().mockImplementation(() => ({})), -})) - -const mockListKv = vi.fn() - -vi.mock('../../src/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})) - -vi.mock('../../src/services/project-id-resolver', () => ({ - resolveProjectId: vi.fn(), -})) - -vi.mock('../../src/services/plugin-memory', () => ({ - PluginMemoryService: vi.fn().mockImplementation(() => ({ - listKv: mockListKv, - })), -})) - -vi.mock('../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), - getReposPath: vi.fn(() => '/tmp/test-repos'), - getOpenCodeConfigFilePath: vi.fn(() => '/tmp/test-workspace/.config/opencode.json'), - getAgentsMdPath: vi.fn(() => '/tmp/test-workspace/AGENTS.md'), - getDatabasePath: vi.fn(() => ':memory:'), - getConfigPath: vi.fn(() => '/tmp/test-workspace/config'), - ENV: { - SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, - AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, - WORKSPACE: { BASE_PATH: '/tmp/test-workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, - OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, - DATABASE: { PATH: ':memory:' }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, - }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, -})) - -vi.mock('@opencode-manager/shared/utils', () => ({ - parseJsonc: vi.fn(), -})) - -import { createMemoryRoutes } from '../../src/routes/memory' -import { resolveProjectId } from '../../src/services/project-id-resolver' -import { getRepoById } from '../../src/db/queries' - -const mockResolveProjectId = resolveProjectId as ReturnType -const mockGetRepoById = getRepoById as ReturnType - -describe('Memory Routes - Loop Status', () => { - let memoryApp: ReturnType - let testDb: any - - beforeEach(() => { - vi.clearAllMocks() - mockResolveProjectId.mockReset() - mockListKv.mockReset() - mockGetRepoById.mockReset() - - testDb = {} as any - memoryApp = createMemoryRoutes(testDb) - }) - - describe('GET /loop/status', () => { - it('should return 400 when repoId query param is missing', async () => { - const req = new Request('http://localhost/loop/status') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(400) - const json = await res.json() as Record - expect(json.error).toBe('Missing repoId') - }) - - it('should return 400 when repoId is not a valid number', async () => { - const req = new Request('http://localhost/loop/status?repoId=abc') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(400) - const json = await res.json() as Record - expect(json.error).toBe('Invalid repoId') - }) - - it('should return 200 with empty loops when repo is not found in DB', async () => { - mockGetRepoById.mockReturnValue(null) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toEqual([]) - }) - - it('should return 200 with empty loops when resolveProjectId returns null', async () => { - mockGetRepoById.mockReturnValue({ - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - }) - mockResolveProjectId.mockResolvedValue(null) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toEqual([]) - }) - - it('should return 200 with loops array for a valid repo with active loops', async () => { - const mockRepo = { - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - } - mockGetRepoById.mockReturnValue(mockRepo) - mockResolveProjectId.mockResolvedValue('test-project-id') - mockListKv.mockReturnValue([ - { - key: 'loop:test-worktree', - data: { - active: true, - sessionId: 'session-123', - worktreeName: 'test-worktree', - worktreeDir: '/tmp/worktrees/test', - iteration: 1, - maxIterations: 10, - startedAt: '2024-01-01T00:00:00.000Z', - prompt: 'Test prompt', - phase: 'coding', - audit: false, - errorCount: 0, - auditCount: 0, - }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - ]) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toHaveLength(1) - expect((json.loops as Array>)[0]?.active).toBe(true) - }) - - it('should filter out KV entries that do not have an active field', async () => { - const mockRepo = { - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - } - mockGetRepoById.mockReturnValue(mockRepo) - mockResolveProjectId.mockResolvedValue('test-project-id') - mockListKv.mockReturnValue([ - { - key: 'loop:test-worktree-1', - data: { - active: true, - sessionId: 'session-123', - worktreeName: 'test-worktree-1', - worktreeDir: '/tmp/test-worktree-1', - iteration: 1, - maxIterations: 10, - startedAt: new Date().toISOString(), - phase: 'coding', - errorCount: 0, - auditCount: 0, - completionPromise: null, - }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-2', - data: null, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-3', - data: 'string-data', - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-4', - data: { sessionId: 'session-abc' }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - ]) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toHaveLength(1) - expect((json.loops as Array>)[0]?.active).toBe(true) - }) - - it('should return 500 when an unexpected error is thrown', async () => { - mockGetRepoById.mockImplementation(() => { - throw new Error('Database error') - }) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(500) - const json = await res.json() as Record - expect(json.error).toBe('Failed to get Loop') - }) - }) -}) diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index 8de8f436..c0a6a2ce 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { Database } from 'bun:sqlite' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' const mockDb = { prepare: vi.fn(), @@ -58,7 +59,7 @@ describe('Repo Routes', () => { it('should return 404 when repo not found', async () => { vi.mocked(db.getRepoById).mockReturnValue(null) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/access', { method: 'POST' }) expect(res.status).toBe(404) @@ -81,7 +82,7 @@ describe('Repo Routes', () => { } vi.mocked(db.getRepoById).mockReturnValue(mockRepo) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/access', { method: 'POST' }) expect(res.status).toBe(200) @@ -107,7 +108,7 @@ describe('Repo Routes', () => { throw new Error('Database error') }) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/access', { method: 'POST' }) expect(res.status).toBe(500) @@ -120,7 +121,7 @@ describe('Repo Routes', () => { it('should return 404 when repo not found', async () => { vi.mocked(db.getRepoById).mockReturnValue(null) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/assistant-mode', { method: 'GET' }) expect(res.status).toBe(404) @@ -155,7 +156,7 @@ describe('Repo Routes', () => { vi.mocked(getAssistantModeStatus).mockResolvedValue(mockStatus) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/assistant-mode', { method: 'GET' }) expect(res.status).toBe(200) @@ -169,7 +170,7 @@ describe('Repo Routes', () => { it('should return 404 when repo not found', async () => { vi.mocked(db.getRepoById).mockReturnValue(null) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/assistant-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -208,7 +209,7 @@ describe('Repo Routes', () => { vi.mocked(ensureAssistantMode).mockResolvedValue(mockStatus) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/assistant-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -237,7 +238,7 @@ describe('Repo Routes', () => { vi.mocked(ensureAssistantMode).mockRejectedValue(new Error('Test error')) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) const res = await app.request('/1/assistant-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/backend/test/routes/settings-opencode-auth.test.ts b/backend/test/routes/settings-opencode-auth.test.ts new file mode 100644 index 00000000..d716b7f8 --- /dev/null +++ b/backend/test/routes/settings-opencode-auth.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { createSettingsRoutes } from '../../src/routes/settings' +import { encryptSecret } from '../../src/utils/crypto' +import { ENV } from '@opencode-manager/shared/config/env' +import { opencodeServerManager } from '../../src/services/opencode-single-server' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { GitAuthService } from '../../src/services/git-auth' + +vi.mock('bun:sqlite', () => ({ + Database: class Database {}, +})) + +vi.mock('../../src/services/opencode-single-server', () => ({ + opencodeServerManager: { + restart: vi.fn(), + reloadConfig: vi.fn(), + getVersion: vi.fn(), + fetchVersion: vi.fn(), + clearStartupError: vi.fn(), + reinitializeBinDirectory: vi.fn(), + }, + ConfigReloadError: class ConfigReloadError extends Error { + validationIssues = [] + removedFields = [] + }, +})) + +describe('OpenCode Server Auth Routes', () => { + let db: Database + let app: Hono + let originalPassword: string + const mockRestart = opencodeServerManager.restart as ReturnType + + beforeEach(() => { + originalPassword = ENV.OPENCODE.SERVER_PASSWORD + setEnvPassword('') + vi.clearAllMocks() + + db = createTestDb() + + const mockGitAuthService = {} as GitAuthService + const mockOpenCodeClient = {} as OpenCodeClient + const routes = createSettingsRoutes(db, mockGitAuthService, mockOpenCodeClient) + app = new Hono().route('/api/settings', routes) + }) + + afterEach(() => { + db.close() + setEnvPassword(originalPassword) + }) + + describe('GET /api/settings/opencode-server-auth', () => { + it('returns source none when no password is configured', async () => { + const response = await app.request('/api/settings/opencode-server-auth') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: false, source: 'none' }) + }) + + it('returns source env when only env password is configured', async () => { + setEnvPassword('envpassword123') + + const response = await app.request('/api/settings/opencode-server-auth') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: true, source: 'env' }) + }) + + it('returns source db when stored password exists', async () => { + insertPassword('testpassword123') + + const response = await app.request('/api/settings/opencode-server-auth') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: true, source: 'db' }) + }) + + it('returns source db when both stored and env passwords exist', async () => { + setEnvPassword('envpassword123') + insertPassword('testpassword123') + + const response = await app.request('/api/settings/opencode-server-auth') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: true, source: 'db' }) + }) + }) + + describe('PATCH /api/settings/opencode-server-auth', () => { + it('stores password encrypted, restarts server, and returns db source', async () => { + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'testpassword123' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: true, source: 'db' }) + expect(mockRestart).toHaveBeenCalledOnce() + + const row = db.prepare('SELECT value FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string } | undefined + expect(row).toBeDefined() + expect(row?.value).not.toBe('testpassword123') + }) + + it('clears stored password and returns none source without env fallback', async () => { + insertPassword('testpassword123') + + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: null }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: false, source: 'none' }) + expect(mockRestart).toHaveBeenCalledOnce() + expect(db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get('opencode_server_password')).toBeUndefined() + }) + + it('clears stored password and returns env source when env fallback exists', async () => { + setEnvPassword('envpassword123') + insertPassword('testpassword123') + + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: null }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ isSet: true, source: 'env' }) + expect(mockRestart).toHaveBeenCalledOnce() + }) + + it('returns 400 when password is shorter than 8 characters', async () => { + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'short' }), + }) + + expect(response.status).toBe(400) + expect(mockRestart).not.toHaveBeenCalled() + }) + + it('restores missing stored password when restart fails after storing a new password', async () => { + setEnvPassword('envpassword123') + mockRestart.mockRejectedValueOnce(new Error('restart failed')) + + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'testpassword123' }), + }) + + expect(response.status).toBe(500) + expect(mockRestart).toHaveBeenCalledTimes(2) + expect(db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get('opencode_server_password')).toBeUndefined() + + const statusResponse = await app.request('/api/settings/opencode-server-auth') + expect(await statusResponse.json()).toEqual({ isSet: true, source: 'env' }) + }) + + it('restores previous stored password when restart fails after clearing it', async () => { + insertPassword('testpassword123') + const previous = db.prepare('SELECT value FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string } + mockRestart.mockRejectedValueOnce(new Error('restart failed')) + + const response = await app.request('/api/settings/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: null }), + }) + + expect(response.status).toBe(500) + expect(mockRestart).toHaveBeenCalledTimes(2) + + const restored = db.prepare('SELECT value FROM app_secrets WHERE key = ?').get('opencode_server_password') as { value: string } | undefined + expect(restored?.value).toBe(previous.value) + }) + }) + + function insertPassword(password: string) { + const encrypted = encryptSecret(password) + const now = Date.now() + db.prepare(` + INSERT INTO app_secrets (key, value, created_at, updated_at) + VALUES (?, ?, ?, ?) + `).run('opencode_server_password', encrypted, now, now) + } + + function setEnvPassword(password: string) { + Object.defineProperty(ENV.OPENCODE, 'SERVER_PASSWORD', { + value: password, + configurable: true, + writable: true, + }) + } + + function createTestDb(): Database { + const secrets = new Map() + + return { + exec: vi.fn(), + close: vi.fn(), + prepare: vi.fn((sql: string) => ({ + get: (key: string) => { + if (sql.includes('SELECT value')) { + const secret = secrets.get(key) + return secret === undefined ? undefined : secret + } + if (sql.includes('SELECT 1 FROM app_secrets')) { + return secrets.has(key) ? { 1: 1 } : undefined + } + return undefined + }, + run: (key: string, value?: string, createdAt?: number, updatedAt?: number) => { + if (sql.includes('INSERT INTO app_secrets') && value !== undefined) { + const existing = secrets.get(key) + secrets.set(key, { + value, + created_at: createdAt ?? existing?.created_at ?? Date.now(), + updated_at: updatedAt ?? Date.now(), + }) + } + if (sql.includes('DELETE FROM app_secrets')) { + secrets.delete(key) + } + return { changes: 1 } + }, + all: vi.fn(() => []), + })), + } as unknown as Database + } +}) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index e9aa3753..14936cfd 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { execSync, spawnSync } from 'child_process' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' const mockGetSettings = vi.fn() const mockUpdateSettings = vi.fn() @@ -62,9 +63,21 @@ vi.mock('../../src/services/file-operations', () => ({ fileExists: vi.fn(), })) -vi.mock('../../src/services/proxy', () => ({ - patchOpenCodeConfig: vi.fn(), - proxyToOpenCodeWithDirectory: vi.fn(), +vi.mock('../../src/services/opencode/config-recovery', () => ({ + patchConfigWithRecovery: vi.fn(), +})) + +vi.mock('../../src/services/opencode/client', () => ({ + createOpenCodeClient: () => ({ + forward: vi.fn(), + forwardRaw: vi.fn(), + getJson: vi.fn(), + postJson: vi.fn(), + setProviderAuth: vi.fn(), + deleteProviderAuth: vi.fn(), + startMcpAuth: vi.fn(), + authenticateMcp: vi.fn(), + }), })) vi.mock('../../src/services/opencode-single-server', async (importOriginal) => { @@ -146,7 +159,7 @@ import { writeFileContent } from '../../src/services/file-operations' import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../../src/services/opencode-import' import { relinkReposFromSessionDirectories } from '../../src/services/repo' import { opencodeServerManager, ConfigReloadError } from '../../src/services/opencode-single-server' -import { patchOpenCodeConfig } from '../../src/services/proxy' +import { patchConfigWithRecovery } from '../../src/services/opencode/config-recovery' const mockExecSync = execSync as ReturnType const mockSpawnSync = spawnSync as ReturnType @@ -160,7 +173,7 @@ const mockSyncOpenCodeImport = syncOpenCodeImport as ReturnType const mockGetImportedSessionDirectories = getImportedSessionDirectories as ReturnType const mockRelinkReposFromSessionDirectories = relinkReposFromSessionDirectories as ReturnType const mockWriteFileContent = writeFileContent as ReturnType -const mockPatchOpenCodeConfig = patchOpenCodeConfig as ReturnType +const mockPatchConfigWithRecovery = patchConfigWithRecovery as ReturnType describe('Settings Routes - OpenCode Upgrade', () => { let settingsApp: ReturnType @@ -187,15 +200,15 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockGetImportedSessionDirectories.mockReset() mockRelinkReposFromSessionDirectories.mockReset() mockWriteFileContent.mockReset() - mockPatchOpenCodeConfig.mockReset() + mockPatchConfigWithRecovery.mockReset() testDb = {} as any - settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any) + settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, createStubOpenCodeClient()) mockReloadConfig.mockResolvedValue(undefined) mockRestart.mockResolvedValue(undefined) mockClearStartupError.mockReturnValue(undefined) - mockPatchOpenCodeConfig.mockResolvedValue({ success: true, appliedConfig: { $schema: 'https://opencode.ai/config.json' } }) + mockPatchConfigWithRecovery.mockResolvedValue({ success: true, appliedConfig: { $schema: 'https://opencode.ai/config.json' } } as any) mockWriteFileContent.mockResolvedValue(undefined) mockGetOpenCodeImportStatus.mockResolvedValue({ configSourcePath: null, @@ -229,7 +242,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { createdAt: 1, updatedAt: 1, }) - mockPatchOpenCodeConfig.mockResolvedValueOnce({ + mockPatchConfigWithRecovery.mockResolvedValueOnce({ success: false, error: 'command.review: Invalid field', details: [{ path: 'command.review', message: 'Invalid field' }], @@ -275,7 +288,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { createdAt: 1, updatedAt: 1, }) - mockPatchOpenCodeConfig.mockResolvedValueOnce({ + mockPatchConfigWithRecovery.mockResolvedValueOnce({ success: true, appliedConfig: { theme: 'dark' }, removedFields: ['command.review'], @@ -331,7 +344,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { createdAt: 1, updatedAt: 1, }) - mockPatchOpenCodeConfig.mockResolvedValueOnce({ + mockPatchConfigWithRecovery.mockResolvedValueOnce({ success: false, error: 'command.review: Invalid field', details: [{ path: 'command.review', message: 'Invalid field' }], @@ -360,7 +373,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { createdAt: 1, updatedAt: 1, }) - mockPatchOpenCodeConfig.mockResolvedValueOnce({ + mockPatchConfigWithRecovery.mockResolvedValueOnce({ success: true, appliedConfig: { theme: 'dark' }, removedFields: ['command.review'], diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts new file mode 100644 index 00000000..dd434859 --- /dev/null +++ b/backend/test/routes/sse.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest' +import { DEFAULTS } from '@opencode-manager/shared/config' + +const { HEARTBEAT_INTERVAL_MS } = DEFAULTS.SSE + +describe('SSE Routes', () => { + describe('HEARTBEAT_INTERVAL_MS', () => { + it('should be 30000ms (30 seconds)', () => { + expect(HEARTBEAT_INTERVAL_MS).toBe(30000) + }) + + it('should fire heartbeats at correct interval', async () => { + vi.useFakeTimers() + + const heartbeatSpy = vi.fn() + const intervalId = setInterval(() => { + heartbeatSpy() + }, HEARTBEAT_INTERVAL_MS) + + await vi.advanceTimersByTimeAsync(0) + expect(heartbeatSpy).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(30000) + expect(heartbeatSpy).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(40000) + expect(heartbeatSpy).toHaveBeenCalledTimes(3) + + clearInterval(intervalId) + vi.useRealTimers() + }) + + it('should fire two heartbeats within 70 seconds', async () => { + vi.useFakeTimers() + + const heartbeatSpy = vi.fn() + const intervalId = setInterval(() => { + heartbeatSpy() + }, HEARTBEAT_INTERVAL_MS) + + await vi.advanceTimersByTimeAsync(70000) + expect(heartbeatSpy).toHaveBeenCalledTimes(3) + + clearInterval(intervalId) + vi.useRealTimers() + }) + }) +}) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index 9fb0521f..064bd16d 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -1,189 +1,100 @@ -import { describe, expect, it, beforeEach, vi } from 'vitest' +import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import path from 'path' -import type { Repo } from '@opencode-manager/shared/types' -import { - ensureAssistantMode, - getAssistantModeStatus, - getAssistantModeDirectory, - buildAssistantOpenCodeConfig, -} from '../../src/services/assistant-mode' -import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' -import { getReposPath } from '@opencode-manager/shared/config/env' - -const mockRepo: Repo = { - id: 1, - repoUrl: 'https://github.com/example/test-repo.git', - localPath: 'test-repo', - fullPath: '/tmp/test-repo', - sourcePath: '/tmp/test-repo/.git', - branch: 'main', - defaultBranch: 'main', - cloneStatus: 'ready', - clonedAt: Date.now(), - lastPulled: Date.now(), - lastAccessedAt: Date.now(), - openCodeConfigName: 'default', - isWorktree: false, - isLocal: false, -} - -const fsMocks = vi.hoisted(() => ({ - writeFile: vi.fn(), - mkdir: vi.fn(), - access: vi.fn(), - stat: vi.fn(), - readFile: vi.fn(), -})) - -const { writeFile, mkdir, access, readFile } = fsMocks - -vi.mock('fs/promises', () => ({ - default: { - writeFile: fsMocks.writeFile, - readFile: fsMocks.readFile, - mkdir: fsMocks.mkdir, - access: fsMocks.access, - stat: fsMocks.stat, - }, -})) - -vi.mock('fs', () => ({ - promises: { - writeFile: fsMocks.writeFile, - readFile: fsMocks.readFile, - mkdir: fsMocks.mkdir, - access: fsMocks.access, - stat: fsMocks.stat, - }, -})) - -describe('getAssistantModeDirectory', () => { - it('returns the shared assistant path within repos root', () => { - const result = getAssistantModeDirectory() - expect(result).toBe(path.join(getReposPath(), 'assistant')) - }) - - it('resolves path correctly', () => { - const result = getAssistantModeDirectory() - expect(result).toContain(path.join('repos', 'assistant')) - }) -}) - -describe('buildAssistantOpenCodeConfig', () => { - it('returns valid OpenCode config', () => { - const config = buildAssistantOpenCodeConfig() - const result = OpenCodeConfigSchema.safeParse(config) - expect(result.success).toBe(true) - }) - - it('includes instructions for AGENTS.md', () => { - const config = buildAssistantOpenCodeConfig() - expect(config.instructions).toEqual(['AGENTS.md']) - }) - - it('has permission rules for the assistant workspace', () => { - const config = buildAssistantOpenCodeConfig() - expect(config.permission).toEqual({ - read: 'allow', - edit: 'allow', - glob: 'allow', - grep: 'allow', - list: 'allow', - bash: 'allow', - external_directory: 'ask', - }) +import { readFile, stat } from 'fs/promises' +import { Hono } from 'hono' +import { ensureAssistantMode, buildSchedulesSkill } from '../../src/services/assistant-mode' +import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } from '../../src/services/opencode/client' +import { ENV } from '@opencode-manager/shared/config/env' + +describe('buildSchedulesSkill', () => { + it('uses ENV.SERVER.PORT in the internal base URL', () => { + const skill = buildSchedulesSkill('https://example.com:443/api/internal') + expect(skill).toContain(`http://localhost:${ENV.SERVER.PORT}/api/internal`) + expect(skill).not.toContain(':443') }) }) describe('ensureAssistantMode', () => { - beforeEach(() => { - vi.clearAllMocks() - readFile.mockResolvedValue(JSON.stringify(buildAssistantOpenCodeConfig())) + let ws: Awaited> + let db: ReturnType + const apiBaseUrl = 'http://example.test:5003/api/internal' + const localApiBaseUrl = 'http://localhost:5003/api/internal' + + beforeEach(async () => { + ws = await createTempAssistantWorkspace() + db = createTestDb() }) - - it('creates the shared assistant workspace and files when missing', async () => { - access.mockRejectedValue(new Error('File not found')) - - const result = await ensureAssistantMode(mockRepo) - - expect(result.directory).toBe(path.join(getReposPath(), 'assistant')) - expect(result.relativePath).toBe('repos/assistant') - expect(result.files.agentsMd.exists).toBe(true) - expect(result.files.opencodeJson.exists).toBe(true) + afterEach(async () => { await ws.cleanup() }) + + it('creates AGENTS.md, opencode.json, internal-token, and SKILL.md on first run', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMd = await readFile(path.join(ws.assistantDir, 'AGENTS.md'), 'utf8') + const opencodeJson = await readFile(path.join(ws.assistantDir, 'opencode.json'), 'utf8') + const token = await readFile(path.join(ws.assistantDir, '.opencode/internal-token'), 'utf8') + const skill = await readFile(path.join(ws.assistantDir, '.opencode/skills/schedule-management/SKILL.md'), 'utf8') + + expect(agentsMd).toContain('schedule-management') + expect(JSON.parse(opencodeJson)).not.toHaveProperty('mcp') + expect(token).toMatch(/^[0-9a-f]{64}$/) + expect(skill).toContain('Authorization: Bearer') + expect(skill).toContain(localApiBaseUrl) + expect(skill).not.toContain(apiBaseUrl) }) - it('does not overwrite existing customized files by default', async () => { - access.mockResolvedValue(undefined) - mkdir.mockResolvedValue(undefined) - writeFile.mockResolvedValue(undefined) + it('does not rewrite the token file on a second run with the same db', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const tokenPath = path.join(ws.assistantDir, '.opencode/internal-token') + const firstToken = await readFile(tokenPath, 'utf8') + const firstStat = await stat(tokenPath) - const result = await ensureAssistantMode(mockRepo) + await new Promise(r => setTimeout(r, 10)) - expect(result.files.agentsMd.exists).toBe(true) - expect(result.files.agentsMd.created).toBe(false) - expect(result.files.opencodeJson.exists).toBe(true) - expect(result.files.opencodeJson.created).toBe(false) + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const secondToken = await readFile(tokenPath, 'utf8') + const secondStat = await stat(tokenPath) - expect(writeFile).not.toHaveBeenCalled() + expect(secondToken).toBe(firstToken) + expect(secondStat.mtimeMs).toBe(firstStat.mtimeMs) + expect(result.internalToken?.created).toBe(false) + expect(result.schedulesSkill?.created).toBe(false) }) +}) - it('overwrites only files whose overwrite option is true', async () => { - access.mockResolvedValue(undefined) - - const result = await ensureAssistantMode(mockRepo, { - overwriteAgentsMd: true, - overwriteOpenCodeConfig: true, - }) - - expect(result.files.agentsMd.created).toBe(true) - expect(result.files.opencodeJson.created).toBe(true) - }) +describe('assistant-mode end-to-end', () => { + let ws: Awaited> + let db: ReturnType - it('overwrites legacy invalid assistant opencode config', async () => { - access.mockResolvedValue(undefined) - readFile.mockResolvedValue(JSON.stringify({ - instructions: ['AGENTS.md'], - permission: { - allow: ['**/*'], - ask: ['../**/*'], - }, - })) - - const result = await ensureAssistantMode(mockRepo) - - expect(result.files.opencodeJson.created).toBe(true) - const content = writeFile.mock.calls[0]?.[1] - expect(Buffer.isBuffer(content)).toBe(true) - expect((content as Buffer).toString('utf8')).toContain('external_directory') + beforeEach(async () => { + ws = await createTempAssistantWorkspace() + db = createTestDb() }) + afterEach(async () => { await ws.cleanup() }) - it('returns a directory under the repos root', async () => { - access.mockRejectedValue(new Error('File not found')) - mkdir.mockResolvedValue(undefined) - writeFile.mockResolvedValue(undefined) + it('token written by ensureAssistantMode authenticates a request to /api/internal/schedules/all', async () => { + const apiBaseUrl = 'http://127.0.0.1:5003/api/internal' + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) - const result = await ensureAssistantMode(mockRepo) + const token = (await readFile(path.join(ws.assistantDir, '.opencode/internal-token'), 'utf8')).trim() - expect(result.directory).toBe(path.join(getReposPath(), 'assistant')) - expect(result.directory.startsWith(getReposPath())).toBe(true) - }) -}) - -describe('getAssistantModeStatus', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('reports existence for folder files', async () => { - access - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(undefined) + const scheduleService = new ScheduleService(db, createOpenCodeClient()) + const notificationService = new NotificationService(db) + const settingsService = new SettingsService(db) + const app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) - const result = await getAssistantModeStatus(mockRepo) + const unauth = await app.request('/api/internal/schedules/all') + expect(unauth.status).toBe(401) - expect(result.repoId).toBe(mockRepo.id) - expect(result.relativePath).toBe('repos/assistant') - expect(result.files.agentsMd.exists).toBe(true) - expect(result.files.opencodeJson.exists).toBe(true) + const authed = await app.request('/api/internal/schedules/all', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(authed.status).toBe(200) + const body = await authed.json() as { jobs: unknown[] } + expect(Array.isArray(body.jobs)).toBe(true) }) }) diff --git a/backend/test/services/internal-token.test.ts b/backend/test/services/internal-token.test.ts new file mode 100644 index 00000000..f175ab82 --- /dev/null +++ b/backend/test/services/internal-token.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { Database } from 'bun:sqlite' +import { getOrCreateInternalToken, rotateInternalToken } from '../../src/services/internal-token' +import migration013 from '../../src/db/migrations/013-app-secrets' + +describe('internal-token', () => { + function createTestDb(): Database { + const db = new Database(':memory:') + migration013.up(db) + return db + } + + it('getOrCreateInternalToken creates a token on first call and returns it', () => { + const db = createTestDb() + const token = getOrCreateInternalToken(db) + expect(token).toBeDefined() + expect(token.length).toBe(64) + expect(/^[0-9a-f]+$/.test(token)).toBe(true) + }) + + it('getOrCreateInternalToken returns the same token on subsequent calls', () => { + const db = createTestDb() + const token1 = getOrCreateInternalToken(db) + const token2 = getOrCreateInternalToken(db) + expect(token1).toBe(token2) + }) + + it('rotateInternalToken replaces the value', () => { + const db = createTestDb() + const token1 = getOrCreateInternalToken(db) + const token2 = rotateInternalToken(db) + expect(token1).not.toBe(token2) + const token3 = getOrCreateInternalToken(db) + expect(token3).toBe(token2) + }) +}) diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts index c56eb1a7..14802346 100644 --- a/backend/test/services/opencode-models.test.ts +++ b/backend/test/services/opencode-models.test.ts @@ -1,42 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { proxyToOpenCodeWithDirectory } = vi.hoisted(() => ({ - proxyToOpenCodeWithDirectory: vi.fn(), -})) - -vi.mock('../../src/services/proxy', () => ({ - proxyToOpenCodeWithDirectory, -})) - +import type { OpenCodeClient } from '../../src/services/opencode/client' import { resolveOpenCodeModel } from '../../src/services/opencode-models' -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) -} - describe('resolveOpenCodeModel', () => { beforeEach(() => { vi.clearAllMocks() }) it('returns the preferred model when it is available', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ model: 'openai/gpt-5' })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, - ], - default: { openai: 'gpt-5-mini' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ model: 'openai/gpt-5' }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferredModel: 'openai/gpt-5', }) @@ -48,20 +37,24 @@ describe('resolveOpenCodeModel', () => { }) it('falls back to the provider default when the preferred model is unavailable', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ model: 'openai/gpt-5.4' })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5.3-codex-spark': {}, 'gpt-5-mini': {} } }, - ], - default: { openai: 'gpt-5.3-codex-spark' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ model: 'openai/gpt-5.4' }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5.3-codex-spark': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5.3-codex-spark' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferredModel: 'openai/gpt-5.4', }) @@ -73,23 +66,27 @@ describe('resolveOpenCodeModel', () => { }) it('prefers the configured small model when requested', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ - model: 'openai/gpt-5', - small_model: 'openai/gpt-5-mini', - })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, - ], - default: { openai: 'gpt-5' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ + model: 'openai/gpt-5', + small_model: 'openai/gpt-5-mini', + }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferSmallModel: true, }) @@ -101,23 +98,27 @@ describe('resolveOpenCodeModel', () => { }) it('falls back to config.model when small_model is unavailable', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ - model: 'openai/gpt-5', - small_model: 'openai/gpt-5-unavailable', - })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, - ], - default: { openai: 'gpt-5-mini' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ + model: 'openai/gpt-5', + small_model: 'openai/gpt-5-unavailable', + }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferSmallModel: true, }) @@ -129,23 +130,27 @@ describe('resolveOpenCodeModel', () => { }) it('falls back to provider default only after all configured candidates fail', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ - model: 'openai/gpt-5-configured', - small_model: 'openai/gpt-5-small-unavailable', - })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {}, 'gpt-5-configured': {} } }, - ], - default: { openai: 'gpt-5-mini' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ + model: 'openai/gpt-5-configured', + small_model: 'openai/gpt-5-small-unavailable', + }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {}, 'gpt-5-configured': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferSmallModel: true, }) @@ -157,23 +162,27 @@ describe('resolveOpenCodeModel', () => { }) it('falls back to provider default when both small_model and model are unavailable', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ - model: 'openai/gpt-5-unavailable', - small_model: 'openai/gpt-5-also-unavailable', - })) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } }, - ], - default: { openai: 'gpt-5-mini' }, - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ + model: 'openai/gpt-5-unavailable', + small_model: 'openai/gpt-5-also-unavailable', + }) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project', { preferSmallModel: true, }) @@ -185,19 +194,23 @@ describe('resolveOpenCodeModel', () => { }) it('falls back to the first available model when defaults are missing', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({})) - } - - return Promise.resolve(jsonResponse({ - providers: [ - { id: 'anthropic', models: { 'claude-sonnet-4': {}, 'claude-haiku-4': {} } }, - ], - })) - }) - - const result = await resolveOpenCodeModel('/workspace/repos/sample-project') + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({}) + } + if (path === '/config/providers') { + return Promise.resolve({ + providers: [ + { id: 'anthropic', models: { 'claude-sonnet-4': {}, 'claude-haiku-4': {} } }, + ], + }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + const result = await resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project') expect(result).toEqual({ providerID: 'anthropic', @@ -207,15 +220,19 @@ describe('resolveOpenCodeModel', () => { }) it('throws when no configured models are available', async () => { - proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { - if (path === '/config') { - return Promise.resolve(jsonResponse({ model: 'openai/gpt-5' })) - } - - return Promise.resolve(jsonResponse({ providers: [], default: {} })) - }) - - await expect(resolveOpenCodeModel('/workspace/repos/sample-project')).rejects.toThrow( + const mockClient = { + getJson: vi.fn().mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve({ model: 'openai/gpt-5' }) + } + if (path === '/config/providers') { + return Promise.resolve({ providers: [], default: {} }) + } + throw new Error(`Unexpected path: ${path}`) + }), + } as unknown as OpenCodeClient + + await expect(resolveOpenCodeModel(mockClient, '/workspace/repos/sample-project')).rejects.toThrow( 'No configured OpenCode models are available', ) }) diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 7be27b67..6429d809 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -1,5 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest' +const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ + forward: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + forwardRaw: vi.fn(), + getJson: vi.fn(), + postJson: vi.fn(), + setProviderAuth: vi.fn(), + deleteProviderAuth: vi.fn(), + startMcpAuth: vi.fn(), + authenticateMcp: vi.fn(), +}))) + +const spawnMock = vi.hoisted(() => vi.fn(() => ({ + pid: 1234, + stderr: null, + on: vi.fn(), +}))) + vi.mock('bun:sqlite', () => ({ Database: vi.fn(), })) @@ -15,7 +32,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, - OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1', SERVER_PASSWORD: '', SERVER_USERNAME: 'opencode', PUBLIC_URL: '' }, DATABASE: { PATH: ':memory:' }, FILE_LIMITS: { MAX_SIZE_BYTES: 1024 * 1024, @@ -44,15 +61,22 @@ vi.mock('fs', () => ({ vi.mock('child_process', () => ({ execSync: vi.fn(), + spawn: spawnMock, +})) + +vi.mock('../../src/services/opencode/config-recovery', () => ({ + patchConfigWithRecovery: vi.fn(), })) -vi.mock('../../src/services/proxy', () => ({ - patchOpenCodeConfig: vi.fn(), +vi.mock('../../src/services/opencode/client', () => ({ + createOpenCodeClient: createOpenCodeClientMock, })) import { promises as fs } from 'fs' import { execSync } from 'child_process' import { ConfigReloadError } from '../../src/services/opencode-single-server' +import { encryptSecret } from '../../src/utils/crypto' +import { ENV } from '@opencode-manager/shared/config/env' vi.mock('../../src/utils/logger', () => ({ logger: { @@ -72,6 +96,100 @@ beforeAll(async () => { OpenCodeServerManager.resetInstance() }) +describe('OpenCodeServerManager - server auth', () => { + let originalHost: string + let originalPassword: string + + beforeEach(async () => { + vi.clearAllMocks() + execSyncMock.mockReset() + originalHost = ENV.OPENCODE.HOST + originalPassword = ENV.OPENCODE.SERVER_PASSWORD + setOpenCodeEnv({ host: '127.0.0.1', password: '' }) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + OpenCodeServerManager.resetInstance() + }) + + afterEach(async () => { + setOpenCodeEnv({ host: originalHost, password: originalPassword }) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + OpenCodeServerManager.resetInstance() + vi.clearAllMocks() + }) + + it('rebuilds the client with env password when no DB password is stored', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + + await opencodeServerManager.rebuildClient() + + expect(createOpenCodeClientMock).toHaveBeenCalledWith('envpassword123') + }) + + it('rebuilds the client with DB password before env password', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + opencodeServerManager.setDatabase(createPasswordDb('dbpassword123')) + + await opencodeServerManager.rebuildClient() + + expect(createOpenCodeClientMock).toHaveBeenCalledWith('dbpassword123') + }) + + it('fails startup when externally exposed without a resolved password', async () => { + setOpenCodeEnv({ host: '0.0.0.0', password: '' }) + execSyncMock.mockReturnValue(Buffer.from('1234\n')) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + opencodeServerManager.setDatabase(createPasswordDb(null)) + + await expect(opencodeServerManager.start()).rejects.toThrow('no password is configured') + + expect(execSyncMock).not.toHaveBeenCalledWith('lsof -ti:5551') + expect(spawnMock).not.toHaveBeenCalled() + expect(opencodeServerManager.getLastStartupError()).toContain('OPENCODE_HOST=0.0.0.0') + }) + + it('starts when externally exposed with a resolved password', async () => { + setOpenCodeEnv({ host: '0.0.0.0', password: 'envpassword123' }) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + + await opencodeServerManager.start() + + expect(spawnMock).toHaveBeenCalledWith( + 'opencode', + ['serve', '--port', '5551', '--hostname', '0.0.0.0'], + expect.objectContaining({ + env: expect.objectContaining({ + OPENCODE_SERVER_PASSWORD: 'envpassword123', + OPENCODE_SERVER_USERNAME: 'opencode', + }), + }) + ) + }) + + function setOpenCodeEnv(values: { host: string; password: string }) { + Object.defineProperty(ENV.OPENCODE, 'HOST', { value: values.host, configurable: true, writable: true }) + Object.defineProperty(ENV.OPENCODE, 'SERVER_PASSWORD', { value: values.password, configurable: true, writable: true }) + } + + function createPasswordDb(password: string | null) { + const encrypted = password ? encryptSecret(password) : null + + return { + prepare: vi.fn((sql: string) => ({ + get: (key?: string) => { + if (key === 'opencode_server_password' && sql.includes('SELECT value FROM app_secrets') && encrypted) { + return { value: encrypted } + } + return undefined + }, + run: vi.fn(), + all: vi.fn(() => []), + })), + } as any + } +}) + describe('OpenCodeServerManager - reinitializeBinDirectory', () => { beforeEach(() => { vi.clearAllMocks() @@ -250,13 +368,13 @@ describe('OpenCodeServerManager - reloadConfig', () => { const mockReadFile = vi.fn().mockResolvedValue(JSON.stringify({ command: { review: 'test' } })) fs.readFile = mockReadFile - const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') const mockPatchResult = { success: true } - vi.mocked(patchOpenCodeConfig).mockResolvedValue(mockPatchResult) - - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })) + vi.mocked(patchConfigWithRecovery).mockResolvedValue(mockPatchResult as any) const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + opencodeServerManager.setOpenCodeClient(createStubOpenCodeClient()) await opencodeServerManager.reloadConfig() @@ -264,8 +382,34 @@ describe('OpenCodeServerManager - reloadConfig', () => { expect.stringContaining('.config/opencode.json'), 'utf-8' ) - expect(patchOpenCodeConfig).toHaveBeenCalled() - - fetchSpy.mockRestore() + expect(patchConfigWithRecovery).toHaveBeenCalled() }) }) + +describe('OpenCodeServerManager - checkHealth', () => { + it('returns false when the upstream times out and aborts the upstream fetch', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + + let capturedSignal: AbortSignal | undefined + let aborted = false + const stubClient = createStubOpenCodeClient({ + forward: vi.fn(async (req: { signal?: AbortSignal }) => { + capturedSignal = req.signal + return await new Promise((resolve) => { + req.signal?.addEventListener('abort', () => { + aborted = true + resolve(new Response(JSON.stringify({ error: 'Proxy request failed' }), { status: 502 })) + }) + }) + }), + }) + opencodeServerManager.setOpenCodeClient(stubClient) + + const healthy = await opencodeServerManager.checkHealth() + + expect(healthy).toBe(false) + expect(capturedSignal).toBeDefined() + expect(aborted).toBe(true) + }, 5000) +}) diff --git a/backend/test/services/opencode/client.test.ts b/backend/test/services/opencode/client.test.ts new file mode 100644 index 00000000..b66825a1 --- /dev/null +++ b/backend/test/services/opencode/client.test.ts @@ -0,0 +1,439 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getWorkspacePath: vi.fn(() => '/test/workspace'), + getOpenCodeConfigFilePath: vi.fn(() => '/test/workspace/.config/opencode.json'), + getReposPath: vi.fn(() => '/test/workspace/repos'), + getAgentsMdPath: vi.fn(() => '/test/workspace/AGENTS.md'), + getDatabasePath: vi.fn(() => ':memory:'), + getConfigPath: vi.fn(() => '/test/workspace/config'), + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, + WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1', SERVER_PASSWORD: '', SERVER_USERNAME: 'opencode' }, + DATABASE: { PATH: ':memory:' }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, + }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, +})) + +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createOpenCodeClient, FetchOpenCodeClient, UpstreamError } from '../../../src/services/opencode/client' +import { ENV } from '@opencode-manager/shared/config/env' + +describe('OpenCodeClient', () => { + const baseUrl = 'http://127.0.0.1:5551' + const basicAuth = 'Basic dXNlcjpwYXNz' + + describe('forward', () => { + it('should resolve basic auth dynamically for each request', async () => { + const capturedAuthHeaders: Array = [] + const passwords = ['first-password', 'second-password'] + const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { + capturedAuthHeaders.push((init?.headers as Record).Authorization) + return new Response(JSON.stringify({}), { status: 200 }) + } + const client = new FetchOpenCodeClient({ + baseUrl, + basicAuth: null, + passwordResolver: () => passwords.shift() ?? '', + fetchFn: fetchFn as unknown as typeof fetch, + }) + + await client.forward({ method: 'GET', path: '/config' }) + await client.forward({ method: 'GET', path: '/config' }) + + expect(capturedAuthHeaders).toEqual([ + `Basic ${Buffer.from('opencode:first-password').toString('base64')}`, + `Basic ${Buffer.from('opencode:second-password').toString('base64')}`, + ]) + }) + + it('should build URL from baseUrl and path, inject auth when present, and strip hop-by-hop headers', async () => { + const mockResponse = new Response(JSON.stringify({ data: 'test' }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '100', + 'Content-Encoding': 'gzip', + 'Transfer-Encoding': 'chunked', + 'Connection': 'keep-alive', + }, + }) + + let capturedUrl: URL | undefined + let capturedInit: RequestInit | undefined + const fetchFn = async (input: URL | Request | string, init?: RequestInit) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + capturedInit = init + return mockResponse + } + + const client = new FetchOpenCodeClient({ baseUrl, basicAuth, fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.forward({ + method: 'POST', + path: '/config', + body: 'request-body-content', + headers: { 'X-Caller': 'caller-value' }, + }) + + expect(capturedUrl?.toString()).toBe(baseUrl + '/config') + expect(capturedInit?.method).toBe('POST') + expect(capturedInit?.body).toBe('request-body-content') + expect(capturedInit?.headers).toEqual(expect.objectContaining({ + Authorization: basicAuth, + 'X-Caller': 'caller-value', + })) + + const resultHeaders: Record = {} + result.headers.forEach((value, key) => { + resultHeaders[key] = value + }) + + expect(resultHeaders['content-length']).toBeUndefined() + expect(resultHeaders['content-encoding']).toBeUndefined() + expect(resultHeaders['transfer-encoding']).toBeUndefined() + expect(resultHeaders['connection']).toBeUndefined() + expect(resultHeaders['content-type']).toBe('application/json') + }) + + it('should return 502 JSON Response when fetchFn throws', async () => { + const fetchFn = async () => { + throw new Error('Network error') + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.forward({ + method: 'GET', + path: '/config', + }) + + expect(result.status).toBe(502) + const body = await result.json() + expect(body).toEqual({ error: 'Proxy request failed' }) + }) + + it('should honour directory by adding URL-encoded query param and preserve existing query string', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedUrl: URL | undefined + const fetchFn = async (input: URL | Request | string) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + await client.forward({ + method: 'GET', + path: '/config?foo=bar', + directory: '/test/dir', + }) + + expect(capturedUrl?.searchParams.get('directory')).toBe('/test/dir') + expect(capturedUrl?.searchParams.get('foo')).toBe('bar') + }) + + it('should forward an AbortSignal to fetchFn when provided', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedInit: RequestInit | undefined + const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { + capturedInit = init + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + const controller = new AbortController() + + await client.forward({ method: 'GET', path: '/doc', signal: controller.signal }) + + expect(capturedInit?.signal).toBe(controller.signal) + }) + + it('should return 502 Response when fetchFn rejects with AbortError', async () => { + const fetchFn = async (_input: URL | Request | string, init?: RequestInit) => { + const signal = init?.signal + if (signal?.aborted) { + throw Object.assign(new Error('Aborted'), { name: 'AbortError' }) + } + return new Response(JSON.stringify({}), { status: 200 }) + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + const controller = new AbortController() + controller.abort() + + const result = await client.forward({ method: 'GET', path: '/doc', signal: controller.signal }) + + expect(result.status).toBe(502) + const body = await result.json() + expect(body).toEqual({ error: 'Proxy request failed' }) + }) + }) + + describe('createOpenCodeClient', () => { + it('uses loopback connect host when OPENCODE_HOST binds externally', async () => { + const originalFetch = globalThis.fetch + Object.defineProperty(ENV.OPENCODE, 'HOST', { value: '0.0.0.0', configurable: true, writable: true }) + let capturedUrl: URL | undefined + const fetchFn = async (input: URL | Request | string) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + return new Response(JSON.stringify({}), { status: 200 }) + } + Object.defineProperty(globalThis, 'fetch', { value: fetchFn, configurable: true, writable: true }) + + try { + const client = createOpenCodeClient('testpassword') + await client.forward({ method: 'GET', path: '/doc' }) + + expect(capturedUrl?.origin).toBe('http://127.0.0.1:5551') + } finally { + Object.defineProperty(ENV.OPENCODE, 'HOST', { value: '127.0.0.1', configurable: true, writable: true }) + Object.defineProperty(globalThis, 'fetch', { value: originalFetch, configurable: true, writable: true }) + } + }) + }) + + describe('forwardRaw', () => { + it('should strip /api/opencode prefix from path, preserve search string, and strip host/connection/authorization headers', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedUrl: URL | undefined + let capturedInit: RequestInit | undefined + const fetchFn = async (input: URL | Request | string, init?: RequestInit) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + capturedInit = init + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const request = new Request('http://localhost:5003/api/opencode/config?query=1', { + method: 'GET', + headers: { + 'Host': 'localhost:5003', + 'Connection': 'keep-alive', + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'value', + }, + }) + + await client.forwardRaw(request) + + expect(capturedUrl?.pathname).toBe('/config') + expect(capturedUrl?.searchParams.get('query')).toBe('1') + + expect(capturedInit?.headers).toEqual(expect.objectContaining({ + 'x-custom-header': 'value', + })) + expect((capturedInit?.headers as Record)['host']).toBeUndefined() + expect((capturedInit?.headers as Record)['connection']).toBeUndefined() + expect((capturedInit?.headers as Record)['authorization']).toBeUndefined() + }) + + it('should read body for POST but not for GET/HEAD', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + const capturedBodies: unknown[] = [] + const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { + capturedBodies.push(init?.body) + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const postRequest = new Request('http://localhost:5003/api/opencode/test', { + method: 'POST', + body: 'test body', + }) + + await client.forwardRaw(postRequest) + + const getRequest = new Request('http://localhost:5003/api/opencode/test', { + method: 'GET', + }) + + await client.forwardRaw(getRequest) + + const headRequest = new Request('http://localhost:5003/api/opencode/test', { + method: 'HEAD', + }) + + await client.forwardRaw(headRequest) + + expect(capturedBodies[0]).toBe('test body') + expect(capturedBodies[1]).toBeUndefined() + expect(capturedBodies[2]).toBeUndefined() + }) + }) + + describe('getJson', () => { + it('should return parsed body on 200', async () => { + const mockData = { config: 'test' } + const mockResponse = new Response(JSON.stringify(mockData), { status: 200 }) + const fetchFn = async () => mockResponse + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.getJson('/config') + expect(result).toEqual(mockData) + }) + + it('should throw UpstreamError on 404 with status and bodyText', async () => { + const mockResponse = new Response('Not found', { status: 404 }) + const fetchFn = async () => { + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + let caughtErr: UpstreamError | undefined + try { + await client.getJson('/not-found') + } catch (err) { + caughtErr = err as UpstreamError + } + + expect(caughtErr).toBeDefined() + expect(caughtErr?.status).toBe(404) + expect(caughtErr?.bodyText).toBe('Not found') + }) + }) + + describe('postJson', () => { + it('should post JSON with Content-Type header and merge caller headers', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { status: 200 }) + let capturedInit: RequestInit | undefined + const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { + capturedInit = init + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + await client.postJson('/test', { data: 'test' }, { + headers: { 'X-Custom': 'value' }, + }) + + expect(capturedInit?.headers).toEqual({ + 'Content-Type': 'application/json', + 'X-Custom': 'value', + }) + expect(capturedInit?.body).toBe(JSON.stringify({ data: 'test' })) + }) + + it('should throw UpstreamError on 500', async () => { + const mockResponse = new Response('Internal error', { status: 500 }) + const fetchFn = async () => mockResponse + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + await expect(client.postJson('/test', {})).rejects.toThrow(UpstreamError) + }) + }) + + describe('setProviderAuth', () => { + it('should return true on 200', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + const fetchFn = async () => mockResponse + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.setProviderAuth('test-provider', 'api-key') + expect(result).toBe(true) + }) + + it('should return false on 401', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 401 }) + const fetchFn = async () => mockResponse + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.setProviderAuth('test-provider', 'api-key') + expect(result).toBe(false) + }) + + it('should return false when fetchFn throws', async () => { + const fetchFn = async () => { + throw new Error('Network error') + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.setProviderAuth('test-provider', 'api-key') + expect(result).toBe(false) + }) + }) + + describe('deleteProviderAuth', () => { + it('should return true on 200 with DELETE method and no body', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedInit: RequestInit | undefined + const fetchFn = async (_: URL | Request | string, init?: RequestInit) => { + capturedInit = init + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.deleteProviderAuth('test-provider') + expect(result).toBe(true) + expect(capturedInit?.method).toBe('DELETE') + expect(capturedInit?.body).toBeUndefined() + }) + + it('should return false on 401', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 401 }) + const fetchFn = async () => mockResponse + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.deleteProviderAuth('test-provider') + expect(result).toBe(false) + }) + + it('should return false when fetchFn throws', async () => { + const fetchFn = async () => { + throw new Error('Network error') + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.deleteProviderAuth('test-provider') + expect(result).toBe(false) + }) + }) + + describe('startMcpAuth', () => { + it('should build path with encoded serverName and directory param', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedUrl: URL | undefined + const fetchFn = async (input: URL | Request | string) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.startMcpAuth('foo bar', '/dir') + + expect(capturedUrl?.pathname).toBe('/mcp/foo%20bar/auth') + expect(capturedUrl?.searchParams.get('directory')).toBe('/dir') + expect(result).toBeInstanceOf(Response) + }) + }) + + describe('authenticateMcp', () => { + it('should build path with auth/authenticate suffix', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 200 }) + let capturedUrl: URL | undefined + const fetchFn = async (input: URL | Request | string) => { + capturedUrl = input instanceof URL ? input : new URL(input.toString()) + return mockResponse + } + const client = new FetchOpenCodeClient({ baseUrl, basicAuth: '', fetchFn: fetchFn as unknown as typeof fetch }) + + const result = await client.authenticateMcp('name', undefined) + + expect(capturedUrl?.pathname).toBe('/mcp/name/auth/authenticate') + expect(capturedUrl?.searchParams.has('directory')).toBe(false) + expect(result).toBeInstanceOf(Response) + }) + }) +}) diff --git a/backend/test/services/opencode/config-recovery.test.ts b/backend/test/services/opencode/config-recovery.test.ts new file mode 100644 index 00000000..5ae64bb4 --- /dev/null +++ b/backend/test/services/opencode/config-recovery.test.ts @@ -0,0 +1,303 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getWorkspacePath: vi.fn(() => '/test/workspace'), + getOpenCodeConfigFilePath: vi.fn(() => '/test/workspace/.config/opencode.json'), + getReposPath: vi.fn(() => '/test/workspace/repos'), + getAgentsMdPath: vi.fn(() => '/test/workspace/AGENTS.md'), + getDatabasePath: vi.fn(() => ':memory:'), + getConfigPath: vi.fn(() => '/test/workspace/config'), + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, + WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, + DATABASE: { PATH: ':memory:' }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, + }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, +})) + +vi.mock('../../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import { patchConfigWithRecovery } from '../../../src/services/opencode/config-recovery' +import type { OpenCodeClient, ForwardRequest } from '../../../src/services/opencode/client' + +function createStubClient( + responses: Array<{ status: number; text: string }>, + capturedRequests?: ForwardRequest[], +): OpenCodeClient { + let callIndex = 0 + return { + + async forward(req: ForwardRequest) { + capturedRequests?.push(req) + const response = responses[callIndex++] ?? responses[responses.length - 1]! + return new Response(response.text, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }) + }, + + async forwardRaw(_request: Request) { + throw new Error('not used') + }, + + async getJson(_path: string) { + throw new Error('not used') + }, + + async postJson(_path: string, _body: unknown) { + throw new Error('not used') + }, + + async setProviderAuth(_providerId: string, _apiKey: string) { + throw new Error('not used') + }, + + async deleteProviderAuth(_providerId: string) { + throw new Error('not used') + }, + + async startMcpAuth(_serverName: string, _directory?: string) { + throw new Error('not used') + }, + + async authenticateMcp(_serverName: string, _directory?: string) { + throw new Error('not used') + }, + } +} + +describe('patchConfigWithRecovery', () => { + it('should return success on 200 response with single forward call', async () => { + const config = { agent: { name: 'test' } } + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 200, text: '{}' }, + ], captured) + + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(true) + expect(result.appliedConfig).toBe(config) + expect(result.error).toBeUndefined() + expect(captured).toHaveLength(1) + }) + + it('should recover by removing command.review on 400 with structured errors', async () => { + const errorResponse = { + success: false, + data: { command: { review: 'some value' } }, + errors: [ + { path: ['command', 'review'], message: 'Invalid command review field' }, + ], + } + + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: JSON.stringify(errorResponse) }, + { status: 200, text: '{}' }, + ], captured) + + const config = { command: { review: 'test', other: 'value' }, agent: { name: 'test' } } + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(true) + expect(result.removedFields).toContain('command.review') + expect(result.details).toHaveLength(1) + expect(captured).toHaveLength(2) + + const retryBody = JSON.parse(captured[1]!.body!) as { command?: { review?: unknown; other?: unknown }; agent?: unknown } + expect(retryBody.command?.review).toBeUndefined() + expect(retryBody.command?.other).toBe('value') + expect(retryBody.agent).toEqual({ name: 'test' }) + + expect(result.appliedConfig).toBeDefined() + expect((result.appliedConfig as { command?: { review?: unknown } }).command?.review).toBeUndefined() + }) + + it('should recover from ConfigInvalidError data.issues shape', async () => { + const errorResponse = { + name: 'ConfigInvalidError', + data: { + issues: [ + { path: ['command', 'review'], message: 'Invalid review' }, + ], + }, + } + + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: JSON.stringify(errorResponse) }, + { status: 200, text: '{}' }, + ], captured) + + const config = { command: { review: 'test' } } + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(true) + expect(result.removedFields).toContain('command.review') + expect(captured).toHaveLength(2) + + const retryBody = JSON.parse(captured[1]!.body!) as { command?: { review?: unknown } } + expect(retryBody.command?.review).toBeUndefined() + + expect((result.appliedConfig as { command?: { review?: unknown } }).command?.review).toBeUndefined() + }) + + it('should NOT retry if path depth > 3', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['a', 'b', 'c', 'd'], message: 'Too deep' }, + ], + } + + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: JSON.stringify(errorResponse) }, + ], captured) + + const config = { a: { b: { c: { d: 'value' } } } } + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(false) + expect(result.removedFields).toBeUndefined() + expect(captured).toHaveLength(1) + expect(result.details).toHaveLength(1) + expect(result.details?.[0]?.message).toBe('Too deep') + }) + + it('should NOT retry if path is root', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['root'], message: 'Invalid configuration' }, + ], + } + + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: JSON.stringify(errorResponse) }, + ], captured) + + const config = { invalid: 'config' } + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(false) + expect(result.removedFields).toBeUndefined() + expect(captured).toHaveLength(1) + expect(result.details).toHaveLength(1) + expect(result.details?.[0]?.path).toBe('root') + }) + + it('should return retry errors when retry also fails', async () => { + const initialError = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Initial error' }, + ], + } + + const retryError = { + success: false, + data: {}, + errors: [ + { path: ['agent'], message: 'Retry error - agent invalid' }, + ], + } + + let callCount = 0 + const client: OpenCodeClient = { + async forward(_req: ForwardRequest) { + callCount++ + if (callCount === 1) { + return new Response(JSON.stringify(initialError), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify(retryError), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + }, + async forwardRaw(_request: Request) { + throw new Error('not used') + }, + async getJson(_path: string) { + throw new Error('not used') + }, + async postJson(_path: string, _body: unknown) { + throw new Error('not used') + }, + async setProviderAuth(providerId: string, apiKey: string) { + throw new Error('not used') + }, + async deleteProviderAuth(providerId: string) { + throw new Error('not used') + }, + async startMcpAuth(serverName: string, directory?: string) { + throw new Error('not used') + }, + async authenticateMcp(serverName: string, directory?: string) { + throw new Error('not used') + }, + } + + const config = { command: { review: 'test' } } + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(false) + expect(result.removedFields).toContain('command.review') + expect(result.details).toHaveLength(1) + expect(result.details?.[0]?.message).toBe('Retry error - agent invalid') + expect(callCount).toBe(2) + }) + + it('should return error with Parse error on unparseable response', async () => { + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: 'not valid json at all' }, + ], captured) + + const config = {} + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(false) + expect(result.error).toContain('Parse error') + expect(captured).toHaveLength(1) + }) + + it('should return error on 502 from client.forward', async () => { + const error502Response = { error: 'Proxy request failed' } + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 502, text: JSON.stringify(error502Response) }, + ], captured) + + const config = {} + const result = await patchConfigWithRecovery(client, config) + + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + expect(captured).toHaveLength(1) + }) +}) diff --git a/backend/test/services/proxy.test.ts b/backend/test/services/proxy.test.ts deleted file mode 100644 index 74093bb7..00000000 --- a/backend/test/services/proxy.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn(), -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - getWorkspacePath: vi.fn(() => '/test/workspace'), - getOpenCodeConfigFilePath: vi.fn(() => '/test/workspace/.config/opencode.json'), - getReposPath: vi.fn(() => '/test/workspace/repos'), - getAgentsMdPath: vi.fn(() => '/test/workspace/AGENTS.md'), - getDatabasePath: vi.fn(() => ':memory:'), - getConfigPath: vi.fn(() => '/test/workspace/config'), - ENV: { - SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, - AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, - WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, - OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, - DATABASE: { PATH: ':memory:' }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, - }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, -})) - -vi.mock('fs', () => ({ - promises: { - mkdir: vi.fn(), - access: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - stat: vi.fn(), - chmod: vi.fn(), - unlink: vi.fn(), - rm: vi.fn(), - readdir: vi.fn(), - }, -})) - -vi.mock('child_process', () => ({ - execSync: vi.fn(), -})) - -vi.mock('../../src/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})) - -const createMockFetch = (response: Response) => { - return vi.fn().mockResolvedValue(response) as unknown as typeof fetch -} - -describe('proxy service', () => { - describe('patchOpenCodeConfig', () => { - const originalFetch = global.fetch - - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - global.fetch = originalFetch - }) - - describe('when OpenCode returns 400 with structured errors', () => { - it('should parse errors array and extract path and message', async () => { - const mockResponse = { - success: false, - data: { command: { review: 'some value' } }, - errors: [ - { path: ['command', 'review'], message: 'Invalid command review field' }, - { path: ['agent', 'temperature'], message: 'Temperature must be between 0 and 2' } - ] - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(mockResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.details).toHaveLength(2) - expect(result.details?.[0]).toEqual({ path: 'command.review', message: 'Invalid command review field' }) - expect(result.details?.[1]).toEqual({ path: 'agent.temperature', message: 'Temperature must be between 0 and 2' }) - expect(result.error).toBe('command.review: Invalid command review field; agent.temperature: Temperature must be between 0 and 2') - }) - - it('should handle errors with string path format', async () => { - const mockResponse = { - success: false, - data: {}, - errors: [ - { path: 'command.review', message: 'Invalid field' } - ] - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(mockResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.details).toHaveLength(1) - expect(result.details?.[0]).toEqual({ path: 'command.review', message: 'Invalid field' }) - }) - - it('should handle errors with missing path (defaults to root)', async () => { - const mockResponse = { - success: false, - data: {}, - errors: [ - { message: 'Configuration is invalid' } - ] - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(mockResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({}) - - expect(result.success).toBe(false) - expect(result.details).toHaveLength(1) - expect(result.details?.[0]).toEqual({ path: 'root', message: 'Configuration is invalid' }) - }) - - it('should parse nested data.issues from runtime validation payloads', async () => { - const mockResponse = { - success: false, - data: { - issues: [ - { path: ['command', 'review'], message: 'Invalid review command' }, - { path: ['provider', 'openai', 'models'], message: 'Expected object' } - ] - } - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(mockResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.details).toEqual([ - { path: 'command.review', message: 'Invalid review command' }, - { path: 'provider.openai.models', message: 'Expected object' } - ]) - expect(result.error).toBe('command.review: Invalid review command; provider.openai.models: Expected object') - }) - }) - - describe('when OpenCode returns 400 with only data (no errors)', () => { - it('should not use data as error message source', async () => { - const mockResponse = { - success: false, - data: { command: { review: 'some long value that should not be the error message' } } - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(mockResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.error).not.toContain('some long value') - expect(result.details).toEqual([]) - }) - }) - - describe('when OpenCode returns 400 with unstructured text', () => { - it('should create bounded fallback message without giant config blobs', async () => { - const longConfig = 'x'.repeat(1000) - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => longConfig - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ config: longConfig }) - - expect(result.success).toBe(false) - expect(result.error?.length).toBeLessThan(400) - expect(result.details).toEqual([]) - }) - - it('should handle JSON parse errors gracefully', async () => { - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => 'not valid json at all' - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({}) - - expect(result.success).toBe(false) - expect(result.error).toContain('Parse error') - }) - }) - - describe('retry logic with removable paths', () => { - it('should retry after removing a valid nested path like command.review', async () => { - const errorResponse = { - success: false, - data: {}, - errors: [ - { path: ['command', 'review'], message: 'Invalid field' } - ] - } - - let callCount = 0 - global.fetch = vi.fn().mockImplementation(async () => { - callCount++ - if (callCount === 1) { - return { - ok: false, - status: 400, - text: async () => JSON.stringify(errorResponse) - } as unknown as Response - } - return { - ok: true, - text: async () => '{}' - } as unknown as Response - }) as unknown as typeof fetch - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ - command: { review: 'test', other: 'value' }, - agent: { name: 'test' } - }) - - expect(result.success).toBe(true) - expect(result.removedFields).toContain('command.review') - expect(result.details).toHaveLength(1) - }) - - it('should not retry if any path is non-removable (root level)', async () => { - const errorResponse = { - success: false, - data: {}, - errors: [ - { path: ['root'], message: 'Invalid configuration' } - ] - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(errorResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ invalid: 'config' }) - - expect(result.success).toBe(false) - expect(result.removedFields).toEqual(undefined) - expect(result.error).toContain('Invalid configuration') - }) - - it('should not retry if any path exceeds depth limit', async () => { - const errorResponse = { - success: false, - data: {}, - errors: [ - { path: ['a', 'b', 'c', 'd'], message: 'Too deep' } - ] - } - - global.fetch = createMockFetch({ - ok: false, - status: 400, - text: async () => JSON.stringify(errorResponse) - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ a: { b: { c: { d: 'value' } } } }) - - expect(result.success).toBe(false) - expect(result.removedFields).toEqual(undefined) - }) - }) - - describe('retry failure handling', () => { - it('should return retry-specific details when retry fails with structured errors', async () => { - const initialError = { - success: false, - data: {}, - errors: [ - { path: ['command', 'review'], message: 'Initial error' } - ] - } - - const retryError = { - success: false, - data: {}, - errors: [ - { path: ['agent'], message: 'Retry error - agent invalid' } - ] - } - - let callCount = 0 - global.fetch = vi.fn().mockImplementation(async () => { - callCount++ - if (callCount === 1) { - return { - ok: false, - status: 400, - text: async () => JSON.stringify(initialError) - } as unknown as Response - } - return { - ok: false, - status: 400, - text: async () => JSON.stringify(retryError) - } as unknown as Response - }) as unknown as typeof fetch - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.removedFields).toContain('command.review') - expect(result.details).toHaveLength(1) - expect(result.details?.[0]?.message).toBe('Retry error - agent invalid') - expect(result.error).toContain('Retry error') - }) - - it('should fall back to initial details if retry response is unstructured', async () => { - const initialError = { - success: false, - data: {}, - errors: [ - { path: ['command', 'review'], message: 'Initial error' } - ] - } - - let callCount = 0 - global.fetch = vi.fn().mockImplementation(async () => { - callCount++ - if (callCount === 1) { - return { - ok: false, - status: 400, - text: async () => JSON.stringify(initialError) - } as unknown as Response - } - return { - ok: false, - status: 500, - text: async () => 'Internal Server Error' - } as unknown as Response - }) as unknown as typeof fetch - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(false) - expect(result.removedFields).toContain('command.review') - expect(result.details?.[0]?.message).toBe('Initial error') - }) - }) - - describe('path deduplication', () => { - it('should deduplicate paths before removal', async () => { - const errorResponse = { - success: false, - data: {}, - errors: [ - { path: ['command', 'review'], message: 'Error 1' }, - { path: ['command', 'review'], message: 'Error 2' } - ] - } - - let callCount = 0 - global.fetch = vi.fn().mockImplementation(async () => { - callCount++ - if (callCount === 1) { - return { - ok: false, - status: 400, - text: async () => JSON.stringify(errorResponse) - } as unknown as Response - } - return { - ok: true, - text: async () => '{}' - } as unknown as Response - }) as unknown as typeof fetch - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(true) - expect(result.removedFields).toHaveLength(1) - expect(result.removedFields?.[0]).toBe('command.review') - }) - }) - - describe('successful patch without errors', () => { - it('should return success without details', async () => { - global.fetch = createMockFetch({ - ok: true, - text: async () => '{}' - } as Response) - - const { patchOpenCodeConfig } = await import('../../src/services/proxy') - const result = await patchOpenCodeConfig({ command: { review: 'test' } }) - - expect(result.success).toBe(true) - expect(result.error).toBeUndefined() - expect(result.details).toBeUndefined() - expect(result.removedFields).toBeUndefined() - }) - }) - }) -}) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index f29d37a2..9f51df08 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -22,8 +22,7 @@ const mocks = vi.hoisted(() => ({ computeNextRunAtForJob: vi.fn(), resolveOpenCodeModel: vi.fn(), - proxyToOpenCodeWithDirectory: vi.fn(), - addClient: vi.fn(), + forward: vi.fn(), onEvent: vi.fn(), loggerError: vi.fn(), })) @@ -59,13 +58,10 @@ vi.mock('../../src/services/opencode-models', () => ({ resolveOpenCodeModel: mocks.resolveOpenCodeModel, })) -vi.mock('../../src/services/proxy', () => ({ - proxyToOpenCodeWithDirectory: mocks.proxyToOpenCodeWithDirectory, -})) + vi.mock('../../src/services/sse-aggregator', () => ({ sseAggregator: { - addClient: mocks.addClient, onEvent: mocks.onEvent, }, })) @@ -90,6 +86,7 @@ vi.mock('croner', () => ({ })) import { ScheduleRunner, ScheduleService } from '../../src/services/schedules' +import type { ForwardRequest, OpenCodeClient } from '../../src/services/opencode/client' function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -102,6 +99,23 @@ function textResponse(body: string, status: number = 200): Response { return new Response(body, { status }) } +function createOpenCodeClientStub(): OpenCodeClient { + return { + forward: mocks.forward, + forwardRaw: vi.fn(async () => new Response('', { status: 200 })), + getJson: vi.fn(async () => ({}) as unknown), + postJson: vi.fn(async () => ({}) as unknown), + setProviderAuth: vi.fn(async () => true), + deleteProviderAuth: vi.fn(async () => true), + startMcpAuth: vi.fn(async () => new Response('', { status: 200 })), + authenticateMcp: vi.fn(async () => new Response('', { status: 200 })), + } as OpenCodeClient +} + +function routeForward(handler: (req: ForwardRequest) => Promise | Response) { + mocks.forward.mockImplementation((req: ForwardRequest) => Promise.resolve(handler(req))) +} + const repo = { id: 42, fullPath: '/workspace/repos/sample-project', @@ -155,7 +169,6 @@ describe('ScheduleService', () => { mocks.getRunningScheduleRunByJob.mockReturnValue(null) mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5-mini' }) - mocks.addClient.mockReturnValue(vi.fn()) mocks.onEvent.mockReturnValue(vi.fn()) mocks.getScheduleRunById.mockReturnValue({ ...baseRun, @@ -166,7 +179,7 @@ describe('ScheduleService', () => { }) it('starts a run immediately and completes it after polling session messages', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runWithSession: ScheduleRun = { ...baseRun, sessionId: 'ses-run-1', @@ -175,7 +188,7 @@ describe('ScheduleService', () => { } mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-run-1' })) } @@ -222,8 +235,48 @@ describe('ScheduleService', () => { ) }) + it('sends session and message JSON POSTs with Content-Type: application/json', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub()) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-content-type', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/session' && method === 'POST') { + return jsonResponse({ id: 'ses-content-type' }) + } + if (path === '/session/ses-content-type/message' && method === 'POST') { + return textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }] })) + } + throw new Error(`Unexpected forward request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/session', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }), + ) + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/session/ses-content-type/message', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }), + ) + }) + }) + it('completes a run immediately when the prompt endpoint returns JSON', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runWithSession: ScheduleRun = { ...baseRun, sessionId: 'ses-run-2', @@ -233,7 +286,7 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-run-2' })) } @@ -264,7 +317,7 @@ describe('ScheduleService', () => { }) it('rejects a new run when the job already has a running entry', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) mocks.getRunningScheduleRunByJob.mockReturnValue({ ...baseRun, @@ -279,7 +332,7 @@ describe('ScheduleService', () => { }) it('surfaces setup failures when the model cannot be resolved', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) mocks.resolveOpenCodeModel.mockRejectedValueOnce(new Error('No configured models are available.')) mocks.updateScheduleRun.mockReturnValue({ @@ -307,7 +360,7 @@ describe('ScheduleService', () => { }) it('marks the run failed when prompt submission is rejected after session creation', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runWithSession: ScheduleRun = { ...baseRun, sessionId: 'ses-run-6', @@ -317,7 +370,7 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-run-6' })) } @@ -349,7 +402,7 @@ describe('ScheduleService', () => { }) it('cancels an in-progress run by aborting the linked session', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-run-3', @@ -365,7 +418,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runningRun) mocks.updateScheduleRun.mockReturnValue(cancelledRun) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-3/message' && method === 'GET') { return Promise.resolve(jsonResponse([])) } @@ -380,10 +433,12 @@ describe('ScheduleService', () => { const result = await service.cancelRun(42, 7, 5) expect(result).toEqual(cancelledRun) - expect(mocks.proxyToOpenCodeWithDirectory).toHaveBeenCalledWith( - '/session/ses-run-3/abort', - 'POST', - repo.fullPath, + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/session/ses-run-3/abort', + method: 'POST', + directory: repo.fullPath, + }), ) expect(mocks.updateScheduleRun).toHaveBeenCalledWith( expect.anything(), @@ -395,7 +450,7 @@ describe('ScheduleService', () => { }) it('rejects cancellation for runs that already finished', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) mocks.getScheduleRunById.mockReturnValue({ ...baseRun, @@ -411,7 +466,7 @@ describe('ScheduleService', () => { }) it('cancels a running entry without a linked session', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runningRun: ScheduleRun = { ...baseRun, sessionId: null, @@ -430,11 +485,11 @@ describe('ScheduleService', () => { const result = await service.cancelRun(42, 7, 5) expect(result).toEqual(cancelledRun) - expect(mocks.proxyToOpenCodeWithDirectory).not.toHaveBeenCalled() + expect(mocks.forward).not.toHaveBeenCalled() }) it('surfaces abort failures when cancellation cannot reach OpenCode', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-run-7', @@ -442,7 +497,7 @@ describe('ScheduleService', () => { } mocks.getScheduleRunById.mockReturnValue(runningRun) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-7/message' && method === 'GET') { return Promise.resolve(jsonResponse([])) } @@ -461,7 +516,7 @@ describe('ScheduleService', () => { }) it('marks orphaned idle runs as failed during recovery', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const orphanedRun: ScheduleRun = { ...baseRun, triggerSource: 'schedule', @@ -471,7 +526,7 @@ describe('ScheduleService', () => { } mocks.listRunningScheduleRuns.mockReturnValue([orphanedRun]) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-4/message' && method === 'GET') { return Promise.resolve(jsonResponse([ { @@ -506,7 +561,7 @@ describe('ScheduleService', () => { }) it('finalizes interrupted runs without a linked session during recovery', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) mocks.listRunningScheduleRuns.mockReturnValue([ { @@ -531,7 +586,7 @@ describe('ScheduleService', () => { }) it('completes recoverable runs when the assistant already finished', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const completedRun: ScheduleRun = { ...baseRun, triggerSource: 'schedule', @@ -540,7 +595,7 @@ describe('ScheduleService', () => { } mocks.listRunningScheduleRuns.mockReturnValue([completedRun]) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-8/message' && method === 'GET') { return Promise.resolve(jsonResponse([ { @@ -568,7 +623,7 @@ describe('ScheduleService', () => { }) it('resumes recoverable runs when the session is still active', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const resumedRun: ScheduleRun = { ...baseRun, triggerSource: 'schedule', @@ -579,7 +634,7 @@ describe('ScheduleService', () => { mocks.listRunningScheduleRuns.mockReturnValue([resumedRun]) mocks.getScheduleRunById.mockReturnValue(resumedRun) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-9/message' && method === 'GET') { messageRequests += 1 @@ -622,7 +677,7 @@ describe('ScheduleService', () => { }) it('lists jobs and runs through the persistence layer', () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const listedRun = { ...baseRun, status: 'completed', finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0) } mocks.listScheduleJobsByRepo.mockReturnValue([job]) @@ -635,7 +690,7 @@ describe('ScheduleService', () => { }) it('creates and updates jobs using normalized persistence input', () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const createdJob = { ...job, id: 8, name: 'Daily release summary' } const updatedJob = { ...job, name: 'Updated release summary' } @@ -660,7 +715,7 @@ describe('ScheduleService', () => { }) it('throws when deleting or loading missing records', () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) mocks.deleteScheduleJob.mockReturnValue(false) mocks.getScheduleRunById.mockReturnValue(null) @@ -670,7 +725,7 @@ describe('ScheduleService', () => { }) it('cancels by finalizing the run when the assistant already completed', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-run-5', @@ -684,7 +739,7 @@ describe('ScheduleService', () => { } mocks.getScheduleRunById.mockReturnValueOnce(runningRun).mockReturnValueOnce(completedRun) - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + routeForward(({ path, method }) => { if (path === '/session/ses-run-5/message' && method === 'GET') { return Promise.resolve(jsonResponse([ { @@ -711,7 +766,7 @@ describe('ScheduleService', () => { describe('skill injection in prompt', () => { it('appends skill content to the prompt when skillSlugs are set', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release', 'code-review'], notes: undefined }, @@ -728,7 +783,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedPromptBody: string | undefined - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string, _dir: string, body?: string) => { + routeForward(({ path, method, body }) => { if (path === '/skill' && method === 'GET') { return Promise.resolve(jsonResponse([ { name: 'git-release', description: 'Git release workflow', location: '/path/SKILL.md', content: 'Release instructions here' }, @@ -761,7 +816,7 @@ describe('ScheduleService', () => { }) it('appends skill notes when provided', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const jobWithSkillsAndNotes: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: 'Focus on changelog' }, @@ -778,7 +833,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedPromptBody: string | undefined - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string, _dir: string, body?: string) => { + routeForward(({ path, method, body }) => { if (path === '/skill' && method === 'GET') { return Promise.resolve(jsonResponse([ { name: 'git-release', description: 'Git release workflow', location: '/path/SKILL.md', content: 'Release instructions here' }, @@ -809,7 +864,7 @@ describe('ScheduleService', () => { }) it('does not modify the prompt when skillSlugs is empty', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const jobWithEmptySkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: [], notes: 'some notes' }, @@ -826,7 +881,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedPromptBody: string | undefined - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string, _dir: string, body?: string) => { + routeForward(({ path, method, body }) => { if (path === '/session' && method === 'POST') { return Promise.resolve(jsonResponse({ id: 'ses-skills-3' })) } @@ -850,7 +905,7 @@ describe('ScheduleService', () => { }) it('falls back to name-only injection when skill endpoint fails', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: undefined }, @@ -867,7 +922,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedPromptBody: string | undefined - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string, _dir: string, body?: string) => { + routeForward(({ path, method, body }) => { if (path === '/skill' && method === 'GET') { return Promise.resolve(new Response('error', { status: 500 })) } @@ -894,7 +949,7 @@ describe('ScheduleService', () => { }) it('falls back gracefully when a skill slug is not found in the list', async () => { - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const jobWithUnknownSkill: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['unknown-skill'], notes: undefined }, @@ -911,7 +966,7 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedPromptBody: string | undefined - mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string, _dir: string, body?: string) => { + routeForward(({ path, method, body }) => { if (path === '/skill' && method === 'GET') { return Promise.resolve(jsonResponse([])) } @@ -968,7 +1023,7 @@ describe('ScheduleRunner', () => { mocks.listRunningScheduleRuns.mockReturnValue([]) mocks.listEnabledScheduleJobs.mockReturnValue([mockJob]) - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runner = new ScheduleRunner(service) await runner.start() @@ -1002,7 +1057,7 @@ describe('ScheduleRunner', () => { mocks.listRunningScheduleRuns.mockReturnValue([]) mocks.listEnabledScheduleJobs.mockReturnValue([mockJob]) - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runner = new ScheduleRunner(service) await runner.start() @@ -1034,7 +1089,7 @@ describe('ScheduleRunner', () => { mocks.listRunningScheduleRuns.mockReturnValue([]) mocks.listEnabledScheduleJobs.mockReturnValue([]) - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runner = new ScheduleRunner(service) await runner.start() @@ -1065,7 +1120,7 @@ describe('ScheduleRunner', () => { mocks.listRunningScheduleRuns.mockReturnValue([]) mocks.listEnabledScheduleJobs.mockReturnValue([mockJob]) - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runner = new ScheduleRunner(service) await runner.start() @@ -1096,7 +1151,7 @@ describe('ScheduleRunner', () => { mocks.listRunningScheduleRuns.mockReturnValue([]) mocks.listEnabledScheduleJobs.mockReturnValue([mockJob]) - const service = new ScheduleService({} as never) + const service = new ScheduleService({} as never, createOpenCodeClientStub()) const runner = new ScheduleRunner(service) await runner.start() diff --git a/backend/test/services/skills.test.ts b/backend/test/services/skills.test.ts index 10d71222..75096c38 100644 --- a/backend/test/services/skills.test.ts +++ b/backend/test/services/skills.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest' import { join } from 'path' import { tmpdir } from 'os' -import { mkdtemp, rm } from 'fs/promises' +import { mkdtemp, rm, readFile } from 'fs/promises' +import type { OpenCodeClient } from '../../src/services/opencode/client' vi.mock('../../src/db/queries', () => ({ getRepoById: vi.fn(), @@ -17,6 +18,19 @@ vi.mock('../../src/db/queries', () => ({ deleteRepo: vi.fn(), })) +function createMockClient(skills: Array<{ name: string; description: string; location: string; content: string }>): OpenCodeClient { + return { + forward: vi.fn(async () => new Response(JSON.stringify(skills), { status: 200 })), + forwardRaw: vi.fn(), + getJson: vi.fn(), + postJson: vi.fn(), + setProviderAuth: vi.fn(), + deleteProviderAuth: vi.fn(), + startMcpAuth: vi.fn(), + authenticateMcp: vi.fn(), + } as unknown as OpenCodeClient +} + describe('SkillService', () => { let tempDir: string @@ -31,48 +45,48 @@ describe('SkillService', () => { vi.clearAllMocks() }) - const mockDb = null as unknown as any + const mockDb = null as unknown as never - test('generates correct YAML frontmatter format', async () => { + test('writes correct YAML frontmatter format', async () => { const { createSkill, deleteSkill } = await import('../../src/services/skills') const name = `test-skill-${Date.now()}` - const input = { + const result = await createSkill(mockDb, { name, description: 'A test skill', body: '## Test Body\n\nContent here', scope: 'global' as const, - license: 'MIT', - compatibility: 'opencode', - metadata: { key: 'value' }, - } + }) try { - const result = await createSkill(mockDb, input) - expect(result.name).toBe(name) expect(result.description).toBe('A test skill') expect(result.body).toBe('## Test Body\n\nContent here') - expect(result.license).toBe('MIT') - expect(result.compatibility).toBe('opencode') - expect(result.metadata).toEqual({ key: 'value' }) + const fileContent = await readFile(result.location, 'utf-8') + expect(fileContent).toBe(`---\nname: ${name}\ndescription: A test skill\n---\n## Test Body\n\nContent here`) } finally { await deleteSkill(mockDb, name, 'global').catch(() => {}) } }) - test('parses frontmatter and body correctly', async () => { + test('reads skill via opencode API', async () => { const { createSkill, getSkill, deleteSkill } = await import('../../src/services/skills') const name = `parse-test-${Date.now()}` - const input = { + const created = await createSkill(mockDb, { name, description: 'Test description', body: '## Body\n\nSome content', scope: 'global' as const, - } + }) try { - await createSkill(mockDb, input) - const skill = await getSkill(mockDb, name, 'global') + const client = createMockClient([{ + name, + description: 'Test description', + location: created.location, + content: '## Body\n\nSome content', + }]) + + const skill = await getSkill(mockDb, client, name, 'global') expect(skill.name).toBe(name) expect(skill.description).toBe('Test description') @@ -89,15 +103,13 @@ describe('SkillService', () => { for (const baseName of validNames) { const name = `${baseName}-${Date.now()}` - const input = { - name, - description: 'Test', - body: 'Body', - scope: 'global' as const, - } - try { - await expect(createSkill(mockDb, input)).resolves.toBeDefined() + await expect(createSkill(mockDb, { + name, + description: 'Test', + body: 'Body', + scope: 'global' as const, + })).resolves.toBeDefined() createdNames.push(name) } catch { // Ignore failures, just cleanup what was created @@ -114,29 +126,25 @@ describe('SkillService', () => { const invalidNames = ['My-Skill', '--bad', 'bad-', 'has spaces', 'has_underscore', 'has.dot', 'UPPERCASE'] for (const name of invalidNames) { - const input = { + await expect(createSkill(mockDb, { name: `${name}-${Date.now()}`, description: 'Test', body: 'Body', scope: 'global' as const, - } - - await expect(createSkill(mockDb, input)).rejects.toThrow('Invalid skill name') + })).rejects.toThrow('Invalid skill name') } }) test('creates skill file at correct path', async () => { const { createSkill, deleteSkill } = await import('../../src/services/skills') const name = `new-skill-${Date.now()}` - const input = { - name, - description: 'A new skill', - body: 'Skill body content', - scope: 'global' as const, - } - try { - const result = await createSkill(mockDb, input) + const result = await createSkill(mockDb, { + name, + description: 'A new skill', + body: 'Skill body content', + scope: 'global' as const, + }) expect(result.name).toBe(name) expect(result.scope).toBe('global') @@ -149,48 +157,21 @@ describe('SkillService', () => { test('throws error on duplicate name', async () => { const { createSkill, deleteSkill } = await import('../../src/services/skills') const name = `duplicate-${Date.now()}` - const input = { - name, - description: 'First', - body: 'Body', - scope: 'global' as const, - } try { - await createSkill(mockDb, input) + await createSkill(mockDb, { + name, + description: 'First', + body: 'Body', + scope: 'global' as const, + }) - const duplicate = { + await expect(createSkill(mockDb, { name, description: 'Second', body: 'Body', scope: 'global' as const, - } - - await expect(createSkill(mockDb, duplicate)).rejects.toThrow('already exists') - } finally { - await deleteSkill(mockDb, name, 'global').catch(() => {}) - } - }) - - test('reads skill correctly', async () => { - const { createSkill, getSkill, deleteSkill } = await import('../../src/services/skills') - const name = `read-test-${Date.now()}` - const input = { - name, - description: 'Read test description', - body: 'Read test body', - scope: 'global' as const, - license: 'Apache-2.0', - } - - try { - await createSkill(mockDb, input) - const skill = await getSkill(mockDb, name, 'global') - - expect(skill.name).toBe(name) - expect(skill.description).toBe('Read test description') - expect(skill.body).toBe('Read test body') - expect(skill.license).toBe('Apache-2.0') + })).rejects.toThrow('already exists') } finally { await deleteSkill(mockDb, name, 'global').catch(() => {}) } @@ -198,34 +179,44 @@ describe('SkillService', () => { test('throws error for missing skill', async () => { const { getSkill } = await import('../../src/services/skills') - await expect(getSkill(mockDb, 'nonexistent', 'global')).rejects.toThrow('not found') + const client = createMockClient([]) + await expect(getSkill(mockDb, client, 'nonexistent', 'global')).rejects.toThrow('not found') }) - test('updates only changed fields', async () => { + test('updates only changed fields, preserving body', async () => { const { createSkill, updateSkill, deleteSkill } = await import('../../src/services/skills') const name = `update-test-${Date.now()}` - const input = { + + const created = await createSkill(mockDb, { name, description: 'Original description', body: 'Original body', scope: 'global' as const, - license: 'MIT', - } + }) try { - await createSkill(mockDb, input) + const client = createMockClient([{ + name, + description: 'Original description', + location: created.location, + content: 'Original body', + }]) const updated = await updateSkill( mockDb, + client, name, 'global', { description: 'Updated description' }, - undefined + undefined, ) expect(updated.description).toBe('Updated description') expect(updated.body).toBe('Original body') - expect(updated.license).toBe('MIT') + + const fileContent = await readFile(created.location, 'utf-8') + expect(fileContent).toContain('description: Updated description') + expect(fileContent).toContain('Original body') } finally { await deleteSkill(mockDb, name, 'global').catch(() => {}) } @@ -233,25 +224,26 @@ describe('SkillService', () => { test('throws error for missing skill on update', async () => { const { updateSkill } = await import('../../src/services/skills') + const client = createMockClient([]) await expect( - updateSkill(mockDb, 'nonexistent', 'global', { description: 'Test' }, undefined) + updateSkill(mockDb, client, 'nonexistent', 'global', { description: 'Test' }, undefined), ).rejects.toThrow('not found') }) test('deletes skill directory', async () => { const { createSkill, deleteSkill, getSkill } = await import('../../src/services/skills') const name = `delete-test-${Date.now()}` - const input = { + + await createSkill(mockDb, { name, description: 'To be deleted', body: 'Body', scope: 'global' as const, - } - - await createSkill(mockDb, input) + }) await deleteSkill(mockDb, name, 'global') - await expect(getSkill(mockDb, name, 'global')).rejects.toThrow('not found') + const client = createMockClient([]) + await expect(getSkill(mockDb, client, name, 'global')).rejects.toThrow('not found') }) test('throws error for missing skill on delete', async () => { @@ -259,32 +251,36 @@ describe('SkillService', () => { await expect(deleteSkill(mockDb, 'nonexistent', 'global')).rejects.toThrow('not found') }) - test('lists global skills', async () => { + test('lists global skills via opencode API', async () => { const { createSkill, listManagedSkills, deleteSkill } = await import('../../src/services/skills') const name1 = `list-test-1-${Date.now()}` const name2 = `list-test-2-${Date.now()}` try { - await createSkill(mockDb, { + const created1 = await createSkill(mockDb, { name: name1, description: 'Test 1', body: 'Body', scope: 'global' as const, }) - await createSkill(mockDb, { + const created2 = await createSkill(mockDb, { name: name2, description: 'Test 2', body: 'Body', scope: 'global' as const, }) - const skills = await listManagedSkills(mockDb) + const client = createMockClient([ + { name: name1, description: 'Test 1', location: created1.location, content: 'Body' }, + { name: name2, description: 'Test 2', location: created2.location, content: 'Body' }, + ]) + const skills = await listManagedSkills(mockDb, client) const createdSkills = skills.filter(s => [name1, name2].includes(s.name)) expect(createdSkills.length).toBe(2) expect(createdSkills.map(s => s.name)).toEqual( - expect.arrayContaining([name1, name2]) + expect.arrayContaining([name1, name2]), ) } finally { await deleteSkill(mockDb, name1, 'global').catch(() => {}) @@ -292,10 +288,10 @@ describe('SkillService', () => { } }) - test('should handle body content containing --- (horizontal rules)', async () => { + test('preserves body content containing --- (horizontal rules)', async () => { const { createSkill, getSkill, deleteSkill } = await import('../../src/services/skills') const name = `hr-test-${Date.now()}` - + const bodyWithHR = `This is the skill body. --- @@ -306,29 +302,50 @@ This is after a horizontal rule. Another section.` + const created = await createSkill(mockDb, { + name, + description: 'Test horizontal rules in body', + body: bodyWithHR, + scope: 'global' as const, + }) + try { - await createSkill(mockDb, { + const client = createMockClient([{ name, description: 'Test horizontal rules in body', - body: bodyWithHR, - scope: 'global' as const, - }) + location: created.location, + content: bodyWithHR, + }]) - const skill = await getSkill(mockDb, name, 'global') + const skill = await getSkill(mockDb, client, name, 'global') expect(skill).not.toBeNull() - expect(skill!.body).toContain('---') - expect(skill!.body).toContain('This is after a horizontal rule.') - expect(skill!.body).toContain('Another section.') + expect(skill.body).toContain('---') + expect(skill.body).toContain('This is after a horizontal rule.') + expect(skill.body).toContain('Another section.') } finally { await deleteSkill(mockDb, name, 'global').catch(() => {}) } }) - test('lists skills from all repos when no repoId is provided', async () => { + test('returns empty list when opencode API returns nothing', async () => { const { listManagedSkills } = await import('../../src/services/skills') - - const skills = await listManagedSkills(mockDb) - + const client = createMockClient([]) + const skills = await listManagedSkills(mockDb, client) expect(Array.isArray(skills)).toBe(true) + expect(skills.length).toBe(0) + }) + + test('filters out skills outside managed directories', async () => { + const { listManagedSkills } = await import('../../src/services/skills') + const client = createMockClient([ + { + name: 'external-skill', + description: 'From .claude', + location: '/some/other/path/.claude/skills/external/SKILL.md', + content: 'body', + }, + ]) + const skills = await listManagedSkills(mockDb, client) + expect(skills.length).toBe(0) }) }) diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts new file mode 100644 index 00000000..0e6ac900 --- /dev/null +++ b/backend/test/services/sse-aggregator.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@opencode-manager/shared/config/env', () => ({ + ENV: { + OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, + }, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' + +interface CapturedEvent { + event: string + data: string +} + +function createCapturingClient() { + const events: CapturedEvent[] = [] + const callback = (event: string, data: string) => { + events.push({ event, data }) + } + return { callback, events } +} + +function makeFetcher(map: Record): PendingActionsFetcher { + return { + async getJson(path: string, opts?: { directory?: string }): Promise { + const directory = opts?.directory ?? '' + const entry = map[directory] ?? {} + if (path === '/permission') return (entry.permissions ?? []) as T + if (path === '/question') return (entry.questions ?? []) as T + throw new Error(`unexpected path: ${path}`) + }, + } +} + +async function flushReplay(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } +} + +describe('SSEAggregator pending replay on connect', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + }) + + it('replays pending permissions and questions to a new client per subscribed directory', async () => { + const fetcher = makeFetcher({ + '/repo/a': { + permissions: [ + { id: 'perm-1', sessionID: 'sess-a' }, + { id: 'perm-2', sessionID: 'sess-a' }, + ], + questions: [{ id: 'q-1', sessionID: 'sess-a', questions: [] }], + }, + '/repo/b': { + permissions: [{ id: 'perm-3', sessionID: 'sess-b' }], + questions: [], + }, + }) + sseAggregator.setPendingActionsFetcher(fetcher) + + const { callback, events } = createCapturingClient() + sseAggregator.addClient('client-1', callback, ['/repo/a', '/repo/b']) + + await flushReplay() + + expect(events).toHaveLength(4) + + const parsed = events.map(e => JSON.parse(e.data) as { type: string; properties: { id: string }; directory: string }) + + expect(parsed.filter(p => p.type === 'permission.asked' && p.directory === '/repo/a').map(p => p.properties.id)).toEqual([ + 'perm-1', + 'perm-2', + ]) + expect(parsed.filter(p => p.type === 'question.asked' && p.directory === '/repo/a').map(p => p.properties.id)).toEqual(['q-1']) + expect(parsed.filter(p => p.type === 'permission.asked' && p.directory === '/repo/b').map(p => p.properties.id)).toEqual([ + 'perm-3', + ]) + expect(parsed.filter(p => p.type === 'question.asked' && p.directory === '/repo/b')).toHaveLength(0) + }) + + it('does not replay when no fetcher is configured', async () => { + const { callback, events } = createCapturingClient() + sseAggregator.addClient('client-2', callback, ['/repo/a']) + + await flushReplay() + + expect(events).toHaveLength(0) + }) + + it('does not replay to other clients', async () => { + const fetcher = makeFetcher({ + '/repo/a': { permissions: [{ id: 'perm-1', sessionID: 'sess-a' }] }, + }) + sseAggregator.setPendingActionsFetcher(fetcher) + + const clientA = createCapturingClient() + const clientB = createCapturingClient() + + sseAggregator.addClient('a', clientA.callback, ['/repo/a']) + sseAggregator.addClient('b', clientB.callback, []) + + await flushReplay() + + expect(clientA.events).toHaveLength(1) + expect(clientB.events).toHaveLength(0) + }) + + it('replays only newly added directories on addDirectories', async () => { + const fetcher = makeFetcher({ + '/repo/a': { permissions: [{ id: 'perm-1', sessionID: 'sess-a' }] }, + '/repo/b': { permissions: [{ id: 'perm-2', sessionID: 'sess-b' }] }, + }) + sseAggregator.setPendingActionsFetcher(fetcher) + + const { callback, events } = createCapturingClient() + sseAggregator.addClient('client-3', callback, ['/repo/a']) + await flushReplay() + + const initialCount = events.length + expect(initialCount).toBe(1) + + sseAggregator.addDirectories('client-3', ['/repo/a', '/repo/b']) + await flushReplay() + + const newEvents = events.slice(initialCount) + const parsed = newEvents.map(e => JSON.parse(e.data) as { type: string; directory: string; properties: { id: string } }) + expect(parsed).toHaveLength(1) + const [first] = parsed + expect(first?.directory).toBe('/repo/b') + expect(first?.properties.id).toBe('perm-2') + }) + + it('survives upstream fetch failures for one directory and still replays the others', async () => { + const fetcher: PendingActionsFetcher = { + async getJson(path: string, opts?: { directory?: string }): Promise { + if (opts?.directory === '/repo/broken') { + throw new Error('upstream down') + } + if (path === '/permission' && opts?.directory === '/repo/ok') { + return [{ id: 'perm-ok', sessionID: 's' }] as unknown as T + } + return [] as unknown as T + }, + } + sseAggregator.setPendingActionsFetcher(fetcher) + + const { callback, events } = createCapturingClient() + sseAggregator.addClient('client-4', callback, ['/repo/broken', '/repo/ok']) + await flushReplay() + + const parsed = events.map(e => JSON.parse(e.data) as { directory: string; properties: { id: string } }) + expect(parsed).toHaveLength(1) + const [first] = parsed + expect(first?.directory).toBe('/repo/ok') + expect(first?.properties.id).toBe('perm-ok') + }) + + it('does not deliver replay events to a client that no longer subscribes to that directory', async () => { + let resolvePermissions: (val: unknown[]) => void = () => {} + const fetcher: PendingActionsFetcher = { + async getJson(path: string): Promise { + if (path === '/permission') { + return new Promise((resolve) => { + resolvePermissions = resolve as (val: unknown[]) => void + }) + } + return [] as unknown as T + }, + } + sseAggregator.setPendingActionsFetcher(fetcher) + + const { callback, events } = createCapturingClient() + sseAggregator.addClient('client-5', callback, ['/repo/a']) + + sseAggregator.removeDirectories('client-5', ['/repo/a']) + resolvePermissions([{ id: 'late', sessionID: 's' }]) + + await flushReplay() + + expect(events).toHaveLength(0) + }) +}) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 33fadcd0..8089f65c 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -6,7 +6,18 @@ export default defineConfig({ environment: 'node', setupFiles: ['./test/setup.ts'], include: ['test/**/*.{test,spec}.{ts,tsx}', 'src/**/*.test.ts'], - exclude: ['**/node_modules/**', '**/dist/**'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + 'test/services/assistant-mode.test.ts', + 'test/services/internal-token.test.ts', + 'test/auth/internal-token-middleware.test.ts', + 'test/routes/internal-schedules.test.ts', + 'test/routes/internal-notifications.test.ts', + 'test/routes/internal-settings.test.ts', + 'src/db/model-state.test.ts', + 'src/routes/providers.test.ts', + ], coverage: { provider: 'v8', reporter: ['text', 'html'], diff --git a/docker-compose.yml b/docker-compose.yml index 88f4caf7..0be7cb50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - HOST=0.0.0.0 - PORT=5003 - OPENCODE_SERVER_PORT=5551 + - OPENCODE_HOST=127.0.0.1 - DATABASE_PATH=/app/data/opencode.db - WORKSPACE_PATH=/workspace - PROCESS_START_WAIT_MS=2000 diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index ebc8c48c..7eaf2d44 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -126,7 +126,6 @@ The container entrypoint (`scripts/docker-entrypoint.sh`) automatically: 2. **Verifies OpenCode** is installed (installed at build time, fallback install if missing) 3. **Upgrades OpenCode** if below minimum version (1.0.137) 4. **Validates AUTH_SECRET** is set (required for startup) -5. **Validates memory plugin** installation (installed from npm during Docker build) ## Port Configuration @@ -387,3 +386,44 @@ docker exec -it opencode-manager vi /workspace/.config/opencode/AGENTS.md ### Precedence Global instructions merge with repository-specific `AGENTS.md` files. Repository instructions take precedence. + +## Exposing the OpenCode Server (Advanced) + +By default, the OpenCode server binds to `127.0.0.1` inside the container and is **not reachable from outside the container**. This is the correct and safe default for nearly all users. + +### When to Expose Externally + +You only need to expose the OpenCode server on an external interface if you have a specific use case that requires other services or machines to connect directly to it. + +### How to Expose Safely + +To expose the OpenCode server on the host network: + +1. **Set `OPENCODE_HOST=0.0.0.0`** in your environment +2. **Add port `5551:5551`** to the compose ports +3. **Set `OPENCODE_SERVER_PASSWORD`** — this is **required**; the managed OpenCode server will refuse to start without it + +Example compose override: + +```yaml +services: + app: + ports: + - "5551:5551" + environment: + - OPENCODE_HOST=0.0.0.0 + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:?Set OPENCODE_SERVER_PASSWORD before exposing OpenCode on port 5551} +``` + +### Password Configuration + +The password can be configured in two ways: + +1. **Environment variable:** Set `OPENCODE_SERVER_PASSWORD` in your `.env` file or compose environment +2. **Via UI:** Use Settings → OpenCode → Server Auth to set a password at runtime + +**DB-stored passwords take precedence over the environment variable.** If you set a password via the UI, it will override the env var. + +### Startup Guard + +If you set `OPENCODE_HOST=0.0.0.0` (or any non-localhost host) without configuring a password (either via env var or UI), the managed OpenCode server will refuse to start with an error message explaining how to fix it. The OpenCode Manager UI/API may remain available so you can configure a password and restart the managed server. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index d16b5fee..0640ea63 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -94,6 +94,8 @@ When configured, users can enable push notifications in Settings → Notificatio |----------|-------------|---------| | `OPENCODE_SERVER_PORT` | Port for the OpenCode CLI server | `5551` | | `OPENCODE_HOST` | OpenCode server bind address | `127.0.0.1` | +| `OPENCODE_SERVER_PASSWORD` | Basic Auth password required when binding OpenCode to a non-loopback host. Can also be set via UI (Settings → OpenCode → Server Auth). DB-stored passwords override this env var. | - | +| `OPENCODE_SERVER_USERNAME` | Basic Auth username | `opencode` | ## Timeouts diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md new file mode 100644 index 00000000..c2b6b770 --- /dev/null +++ b/docs/features/assistant-internal-api.md @@ -0,0 +1,133 @@ +# Assistant Internal API + +The Assistant Internal API provides capabilities for OpenCode agents to interact with the manager backend via a secure bearer-token API. + +## Authentication + +All endpoints require a bearer token. The token can be found at: +- `.opencode/internal-token` (relative to the assistant workspace cwd) + +Include the token in requests: +``` +Authorization: Bearer +``` + +## Endpoints + +### Notifications + +**POST `/api/internal/notifications/send`** + +Send push notifications to the user's registered devices. + +**Request Body:** +```ts +{ + title: string // 1-120 characters + body: string // 1-500 characters + url?: string // Optional: deep link (1-500 chars) + tag?: string // Optional: notification tag (max 80 chars) + priority?: 'normal' | 'high' +} +``` + +**Query Parameters:** +- `userId` (optional): Defaults to `"default"` + +**Response:** +```ts +{ + delivered: number + expired: number + failed: number + noSubscriptions: boolean +} +``` + +**Rate Limiting:** 10 requests per minute per token. Returns `429 Too Many Requests` with `Retry-After` header when exceeded. + +**Status Codes:** +- `200`: Notification sent +- `400`: Invalid request body +- `401`: Missing or invalid bearer token +- `429`: Rate limit exceeded +- `503`: Push notifications not configured (missing VAPID) + +### Settings + +**GET `/api/internal/settings`** + +Retrieve the user's full settings and preferences. + +**Query Parameters:** +- `userId` (optional): Defaults to `"default"` + +**Response:** +```ts +{ + preferences: { + theme: 'dark' | 'light' | 'system', + mode: 'plan' | 'build', + defaultModel?: string, + defaultAgent?: string, + autoScroll: boolean, + expandDiffs: boolean, + expandToolCalls: boolean, + showReasoning: boolean, + simpleChatMode: boolean, + leaderKey?: string, + directShortcuts?: string[], + keyboardShortcuts: Record, + customCommands: Array<{ name: string; description: string; promptTemplate: string }>, + notifications?: { enabled: boolean; ... }, + repoOrder?: number[], + repoSortMode: 'recent' | 'manual' | 'name', + gitCredentials?: [...], // Read-only + gitIdentity?: {...}, // Read-only + tts?: {...}, // Read-only + stt?: {...}, // Read-only + }, + updatedAt: number +} +``` + +**PATCH `/api/internal/settings`** + +Update a subset of safe user preferences. + +**Allowed Keys:** +The following preference keys can be modified: +- `theme`, `mode`, `defaultModel`, `defaultAgent` +- `autoScroll`, `expandDiffs`, `expandToolCalls`, `showReasoning` +- `simpleChatMode`, `leaderKey`, `directShortcuts` +- `keyboardShortcuts`, `customCommands`, `notifications` +- `repoOrder`, `repoSortMode` + +**Restricted Keys:** +The following keys are **NOT** allowed and will be rejected: +- `gitCredentials` - Git credentials must be managed via the full UI +- `gitIdentity` - Git identity must be managed via the full UI +- `tts.apiKey` - TTS credentials must be managed via the full UI +- `stt.apiKey` - STT credentials must be managed via the full UI +- `lastKnownGoodConfig` - Internal state, do not modify + +**Request Body:** +Partial object with any of the allowed keys. + +**Response:** +Returns the updated settings object. + +**Status Codes:** +- `200`: Settings updated +- `400`: Invalid request body or disallowed key +- `401`: Missing or invalid bearer token + +## Skills + +The assistant workspace includes three skills that document these capabilities: + +1. **Schedule Management** (`.opencode/skills/schedule-management/SKILL.md`) +2. **Notifications** (`.opencode/skills/notifications/SKILL.md`) +3. **Manager Settings** (`.opencode/skills/manager-settings/SKILL.md`) + +These skills are automatically provisioned when assistant mode is initialized and contain detailed examples and usage patterns. diff --git a/docs/features/memory.md b/docs/features/memory.md deleted file mode 100644 index 0dc438bf..00000000 --- a/docs/features/memory.md +++ /dev/null @@ -1,74 +0,0 @@ -# Memory Plugin - -`@opencode-manager/memory` is an **optional** OpenCode plugin that stores and recalls project knowledge across sessions using vector embeddings and semantic search. - -[![npm](https://img.shields.io/npm/v/@opencode-manager/memory)](https://www.npmjs.com/package/@opencode-manager/memory) - -!!! note "Not Required" - This plugin is entirely optional. OpenCode Manager works fully without it — install it only if you want persistent project knowledge and semantic search capabilities. - -!!! tip "Works with Standalone OpenCode" - This plugin can also be used with standalone OpenCode installations outside of OpenCode Manager. Simply install the package and add it to your `opencode.json` plugins array. - -!!! info "Standalone Repository" - The memory plugin has moved to its own repository. For complete documentation including configuration, tools, agents, CLI reference, loops, and Docker sandbox, visit the **[opencode-memory repository](https://github.com/chriswritescode-dev/opencode-memory)**. - ---- - -## Installation - -```bash -pnpm add @opencode-manager/memory -``` - -Register the plugin in your `opencode.json`: - -```json -{ - "plugin": ["@opencode-manager/memory"] -} -``` - -The local embedding model downloads automatically on install. For API-based embeddings (OpenAI or Voyage), see the [configuration reference](https://github.com/chriswritescode-dev/opencode-memory#configuration). - ---- - -## Features - -- **Semantic Memory Search** — Store and retrieve project memories using vector embeddings -- **Multiple Memory Scopes** — Categorize memories as convention, decision, or context -- **Automatic Deduplication** — Prevents duplicates via exact match and semantic similarity -- **Compaction Context Injection** — Injects conventions and decisions into session compaction -- **Automatic Memory Injection** — Injects relevant memories into user messages via semantic search -- **Project KV Store** — Ephemeral key-value storage with TTL management -- **Bundled Agents** — Code, Architect, Auditor, and Librarian agents preconfigured for memory-aware workflows -- **CLI Tools** — Export, import, list, stats, cleanup, upgrade, status, and cancel via `ocm-mem` -- **Iterative Development Loops** — Autonomous coding/auditing loop with worktree isolation and session rotation -- **Docker Sandbox** — Run loops inside isolated Docker containers -- **TUI Sidebar** — Monitor loops and memory status directly in the OpenCode terminal interface - ---- - -## Configuration - -The plugin configuration lives at `~/.config/opencode/memory-config.jsonc`. It is created automatically on first run. - -For the full configuration reference including embedding providers, loop settings, sandbox options, and more, see the [standalone repository README](https://github.com/chriswritescode-dev/opencode-memory#configuration). - ---- - -## OpenCode Manager Integration - -When the memory plugin is installed, OpenCode Manager's web UI provides: - -- **Memory Browser** — View, search, create, edit, and delete project memories -- **Loop Status** — Monitor active development loops and their progress -- **Plugin Configuration** — Enable/disable the plugin and adjust settings -- **KV Store Viewer** — Browse ephemeral key-value entries - ---- - -## Links - -- **GitHub**: [chriswritescode-dev/opencode-memory](https://github.com/chriswritescode-dev/opencode-memory) -- **npm**: [@opencode-manager/memory](https://www.npmjs.com/package/@opencode-manager/memory) diff --git a/docs/features/overview.md b/docs/features/overview.md index 98c2742a..cb392f92 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -61,15 +61,6 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI [Learn more →](mcp.md) -### Memory Plugin (Optional) - -- Semantic Search — Store and retrieve project knowledge using vector embeddings -- Memory Scopes — Categorize as convention, decision, or context -- Iterative Development Loops — Autonomous coding/auditing with worktree isolation -- Bundled Agents — Code, Architect, Librarian, and Auditor agents - -[Learn more →](memory.md) | [Full documentation →](https://github.com/chriswritescode-dev/opencode-memory) - ### Text-to-Speech - **Browser TTS** - Built-in Web Speech API support diff --git a/docs/index.md b/docs/index.md index 860a2ab7..f3a80767 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,6 @@ Mobile-first web interface for [OpenCode](https://opencode.ai) AI agents. Manage, control, and code from any device - your phone, tablet, or desktop. - ![OpenCode Manager Demo](images/ocmgr-demo.gif) ## Quick Start @@ -19,37 +18,52 @@ Open [http://localhost:5003](http://localhost:5003) and create your admin accoun ## What is OpenCode Manager? -OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing you to: +OpenCode Manager is a mobile-first web interface for [OpenCode](https://opencode.ai) AI agents. It combines repository management, chat/session control, Git and file tools, schedules, AI configuration, MCP server management, push notifications, and full PWA support into a single responsive application. -- **Manage repositories** - Clone repos or discover existing local repos from a parent folder -- **Chat with AI** - Real-time streaming chat with file mentions and slash commands -- **Run recurring jobs** - Schedule repo reviews, health checks, and other reusable prompts -- **View diffs** - See code changes with syntax highlighting -- **Control from anywhere** - Mobile-first PWA with push notifications -- **Configure AI** - Manage models, providers, and MCP servers +- **Repository management** — Clone, discover, and manage multiple Git repos with SSH authentication and worktree support +- **Chat & sessions** — Real-time SSE streaming with slash commands, `@file` mentions, and Plan/Build modes +- **Schedules** — Recurring repo jobs with reusable prompts, run history, and linked sessions +- **AI configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents +- **MCP & Skills** — MCP server management and skill support +- **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) +- **Mobile & notifications** — Installable PWA with push notifications and mobile-first navigation + +## How It Works + +OpenCode Manager runs as a pnpm workspace: + +- The Bun/Hono backend initializes SQLite, Better Auth, settings, schedules, notifications, and an OpenCode client. +- A supervised OpenCode server handles agent sessions while the backend proxies API calls and streams events over SSE. +- The React/Vite frontend uses React Router and TanStack Query to render repositories, sessions, schedules, settings, and mobile navigation. +- The shared package keeps config, schemas, and TypeScript types aligned between backend and frontend. ## Key Features -- **Multi-Repository Support** - Clone repos, discover local repo folders, and reconnect existing OpenCode chats -- **Git Integration** - View diffs, manage branches, create PRs directly from the UI -- **Real-time Chat** - Stream responses with file mentions and custom slash commands -- **Scheduled Repo Jobs** - Run recurring prompts with linked sessions, logs, and reviewable output -- **Mobile-First PWA** - Install as an app on any device with push notifications -- **Push Notifications** - Get background alerts for agent events when app is closed -- **AI Configuration** - Configure models, providers, OAuth, and custom agents -- **MCP Servers** - Add local or remote MCP servers with OAuth support -- **Memory Plugin (Optional)** — Persistent project knowledge with semantic search ([GitHub](https://github.com/chriswritescode-dev/opencode-memory)) +- **Repositories & Git** — Multi-repo management, local discovery, SSH auth, worktrees, unified diffs, branch/commit management — [Learn more](features/git.md) +- **Chat & Sessions** — Real-time SSE streaming, slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams — [Learn more](features/chat.md) +- **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download — [Learn more](features/files.md) +- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions — [Learn more](features/schedules.md) +- **AI Configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents — [Learn more](features/ai-config.md) +- **MCP Servers** — Add local or remote MCP servers with OAuth support — [Learn more](features/mcp.md) +- **Skills** — Skill support for extended agent capabilities — [Learn more](features/skills.md) +- **Mobile & PWA** — Responsive UI, installable on any device, iOS-optimized — [Learn more](features/mobile.md) +- **Push Notifications** — Background alerts for agent events — [Learn more](features/notifications.md) +- **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) — [Learn more](features/tts.md) | [Learn more](features/stt.md) -!!! tip "Memory Plugin — Persistent Project Knowledge" - Store and retrieve project knowledge across sessions using vector embeddings and semantic search. Works as a standalone plugin with any OpenCode installation. +## Project Layout - **[Learn more →](features/memory.md)** | **[GitHub →](https://github.com/chriswritescode-dev/opencode-memory)** +- `backend/` — Bun + Hono API routes, services, database migrations, auth, schedules, and OpenCode integration. +- `frontend/` — React + Vite app, pages, components, hooks, API clients, stores, contexts, and PWA assets. +- `shared/` — Workspace package for schemas, types, config, and utilities. +- `docs/` — MkDocs Material documentation. +- `scripts/`, `Dockerfile`, `docker-compose.yml` — Setup, build, and deployment support. ## Next Steps - [Installation Guide](getting-started/installation.md) - Detailed setup instructions - [Quick Start](getting-started/quickstart.md) - Get up and running fast +- [Development Setup](development/setup.md) - Local environment, scripts, and testing +- [Contributing](development/contributing.md) - How to contribute to OpenCode Manager - [Features Overview](features/overview.md) - Explore all features - [Schedules & Recurring Jobs](features/schedules.md) - Automate recurring repo reviews and follow-ups -- [Memory Plugin](features/memory.md) - Persistent project knowledge with semantic search ([GitHub](https://github.com/chriswritescode-dev/opencode-memory)) - [Configuration](configuration/environment.md) - Environment variables and setup diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4df3cde..5c91c056 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' import { SessionDetail } from './pages/SessionDetail' -import { Memories } from './pages/Memories' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' import { Login } from './pages/Login' @@ -30,6 +29,8 @@ import { PermissionRequestDialog } from './components/session/PermissionRequestD import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' import { getSwipeBackTarget } from '@/lib/navigation' +import { useAuth } from '@/hooks/useAuth' +import { useServerHealth } from '@/hooks/useServerHealth' const queryClient = new QueryClient({ defaultOptions: { @@ -52,6 +53,12 @@ function SSHHostKeyDialogWrapper() { ) } +function HealthMonitor() { + const { isAuthenticated } = useAuth() + useServerHealth(isAuthenticated) + return null +} + function PermissionDialogWrapper() { const { current: currentPermission, @@ -115,7 +122,7 @@ function AppShell() { { enabled: canOpenMoreWithSwipe(), edgeWidth: 32, - threshold: 72, + threshold: 60, } ) @@ -158,6 +165,7 @@ function AppShell() { + , loader: protectedLoader, }, - { - path: '/repos/:id/memories', - element: , - loader: protectedLoader, - }, { path: '/repos/:id/schedules', element: , diff --git a/frontend/src/api/memory.ts b/frontend/src/api/memory.ts deleted file mode 100644 index 778250ca..00000000 --- a/frontend/src/api/memory.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' -import { API_BASE_URL } from '@/config' -import type { Memory, MemoryStats, CreateMemoryRequest, UpdateMemoryRequest, PluginConfig, KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest, LoopState } from '@opencode-manager/shared/types' - -export async function listMemories(filters?: { - projectId?: string - scope?: string - content?: string - limit?: number - offset?: number -}): Promise<{ memories: Memory[] }> { - const params = new URLSearchParams() - if (filters?.projectId) params.set('projectId', filters.projectId) - if (filters?.scope) params.set('scope', filters.scope) - if (filters?.content) params.set('content', filters.content) - if (filters?.limit) params.set('limit', String(filters.limit)) - if (filters?.offset) params.set('offset', String(filters.offset)) - - const query = params.toString() - return fetchWrapper(`${API_BASE_URL}/api/memory${query ? `?${query}` : ''}`) -} - -export async function createMemory(data: CreateMemoryRequest): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function getMemory(id: number): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/${id}`) -} - -export async function updateMemory(id: number, data: UpdateMemoryRequest): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function deleteMemory(id: number): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/memory/${id}`, { - method: 'DELETE', - }) -} - -export async function getProjectSummary( - repoId: number -): Promise<{ projectId: string | null; stats: MemoryStats; error?: string }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/project-summary?repoId=${repoId}`) -} - -export async function getPluginConfig(): Promise<{ config: PluginConfig }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/plugin-config`) -} - -export async function updatePluginConfig(config: PluginConfig): Promise<{ success: boolean; config: PluginConfig }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/plugin-config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }) -} - -export interface ReindexResult { - success: boolean - message: string - total: number - embedded: number - failed: number - requiresRestart?: boolean -} - -export async function reindexMemories(): Promise { - return fetchWrapper(`${API_BASE_URL}/api/memory/reindex`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) -} - -export interface TestEmbeddingResult { - success: boolean - error?: string - message?: string - dimensions?: number -} - -export async function testEmbeddingConfig(): Promise { - return fetchWrapper(`${API_BASE_URL}/api/memory/test-embedding`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) -} - -export async function listKvEntries(projectId: string, prefix?: string): Promise<{ entries: KvEntry[] }> { - const params = new URLSearchParams({ projectId }) - if (prefix) params.set('prefix', prefix) - return fetchWrapper(`${API_BASE_URL}/api/memory/kv?${params.toString()}`) -} - -export async function deleteKvEntry(projectId: string, key: string): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { - method: 'DELETE', - }) -} - -export async function createKvEntry(data: CreateKvEntryRequest): Promise<{ entry: KvEntry }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/kv`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function updateKvEntry(projectId: string, key: string, data: UpdateKvEntryRequest): Promise<{ entry: KvEntry }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function getLoopStatus(repoId: number): Promise<{ loops: LoopState[]; projectId?: string | null }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/loop/status?repoId=${repoId}`) -} - -export async function cancelLoop(repoId: number, sessionId: string): Promise<{ cancelled: boolean; worktreeName?: string }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/loop/cancel`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repoId, sessionId }), - }) -} diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 5b45823e..819201e8 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -1,14 +1,16 @@ import type { paths } from './opencode-types' -import { fetchWrapper } from './fetchWrapper' +import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' type SessionListResponse = paths['/session']['get']['responses']['200']['content']['application/json'] type SessionResponse = paths['/session/{sessionID}']['get']['responses']['200']['content']['application/json'] type CreateSessionRequest = NonNullable['content']['application/json'] type MessageListResponse = paths['/session/{sessionID}/message']['get']['responses']['200']['content']['application/json'] type SendPromptRequest = NonNullable['content']['application/json'] +type SendPromptAsyncRequest = NonNullable['content']['application/json'] type ConfigResponse = paths['/config']['get']['responses']['200']['content']['application/json'] type CommandListResponse = paths['/command']['get']['responses']['200']['content']['application/json'] type CommandRequest = NonNullable['content']['application/json'] +type SendCommandResponse = paths['/session/{sessionID}/command']['post']['responses']['200']['content']['application/json'] type ShellRequest = NonNullable['content']['application/json'] type AgentListResponse = paths['/agent']['get']['responses']['200']['content']['application/json'] type PermissionListResponse = paths['/permission']['get']['responses']['200']['content']['application/json'] @@ -17,7 +19,7 @@ type SendPromptResponse = paths['/session/{sessionID}/message']['post']['respons type LspStatusResponse = paths['/lsp']['get']['responses']['200']['content']['application/json'] type LspStatus = LspStatusResponse[number] -export type { SendPromptResponse, LspStatus } +export type { SendPromptResponse, SendCommandResponse, LspStatus } export class OpenCodeClient { private baseURL: string @@ -109,6 +111,19 @@ export class OpenCodeClient { ) } + async sendPromptAsync(sessionID: string, data: SendPromptAsyncRequest): Promise { + return fetchWrapperVoid( + `${this.baseURL}/session/${sessionID}/prompt_async`, + { + method: 'POST', + params: this.getParams(), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + timeout: 0, + } + ) + } + async summarizeSession(sessionID: string, providerID: string, modelID: string) { return fetchWrapper(`${this.baseURL}/session/${sessionID}/summarize`, { method: 'POST', @@ -157,12 +172,13 @@ export class OpenCodeClient { }) } - async sendCommand(sessionID: string, data: CommandRequest) { - return fetchWrapper(`${this.baseURL}/session/${sessionID}/command`, { + async sendCommand(sessionID: string, data: CommandRequest): Promise { + return fetchWrapper(`${this.baseURL}/session/${sessionID}/command`, { method: 'POST', params: this.getParams(), headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + timeout: 0, }) } diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 1a84d4f8..5bdf3546 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -229,10 +229,6 @@ export const settingsApi = { return fetchWrapper(`${API_BASE_URL}/api/health/version`) }, - getMemoryPluginStatus: async (): Promise<{ memoryPluginEnabled: boolean }> => { - return fetchWrapper(`${API_BASE_URL}/api/settings/memory-plugin-status`) - }, - listManagedSkills: async (repoId?: number): Promise => { const params = repoId ? `?repoId=${repoId}` : '' return fetchWrapper(`${API_BASE_URL}/api/settings/skills${params}`) @@ -278,3 +274,20 @@ export interface VersionInfo { releaseUrl: string | null releaseName: string | null } + +export interface OpenCodeServerAuthStatus { + isSet: boolean + source: 'db' | 'env' | 'none' +} + +export async function getOpenCodeServerAuth(): Promise { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-server-auth`) +} + +export async function updateOpenCodeServerAuth(password: string | null): Promise { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-server-auth`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) +} diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 7d35ab3d..32598617 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -63,7 +63,6 @@ export interface UserPreferences { notifications?: NotificationPreferences repoOrder?: number[] repoSortMode?: 'recent' | 'manual' | 'name' - memoryDedupThreshold?: number } export interface SettingsResponse { diff --git a/frontend/src/components/memory/KvFormDialog.tsx b/frontend/src/components/memory/KvFormDialog.tsx deleted file mode 100644 index 8cfed190..00000000 --- a/frontend/src/components/memory/KvFormDialog.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { useCreateKvEntry, useUpdateKvEntry } from '@/hooks/useMemories' -import type { KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest } from '@opencode-manager/shared/types' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' - -const kvSchema = z.object({ - key: z.string().min(1, 'Key is required'), - data: z.string().min(1, 'Data is required'), - ttlHours: z.number().optional(), -}) - -type KvFormData = z.infer - -interface KvFormDialogProps { - entry?: KvEntry - projectId?: string - open: boolean - onOpenChange: (open: boolean) => void -} - -export function KvFormDialog({ entry, projectId, open, onOpenChange }: KvFormDialogProps) { - const createMutation = useCreateKvEntry() - const updateMutation = useUpdateKvEntry() - - const [jsonError, setJsonError] = useState() - - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(kvSchema), - defaultValues: { - key: '', - data: '', - ttlHours: undefined, - }, - }) - - useEffect(() => { - if (open) { - if (entry) { - const ttlMs = entry.expiresAt - entry.updatedAt - const ttlHoursValue = ttlMs > 0 && ttlMs < Number.MAX_SAFE_INTEGER - ? Math.round(ttlMs / (1000 * 60 * 60)) - : undefined - - reset({ - key: entry.key, - data: JSON.stringify(entry.data, null, 2), - ttlHours: ttlHoursValue, - }) - } else { - reset({ - key: '', - data: '', - ttlHours: undefined, - }) - } - setJsonError(undefined) - } - }, [open, entry, reset]) - - const validateJson = (value: string): boolean => { - try { - JSON.parse(value) - setJsonError(undefined) - return true - } catch (e) { - setJsonError(e instanceof Error ? e.message : 'Invalid JSON') - return false - } - } - - const onSubmit = async (data: KvFormData) => { - if (!validateJson(data.data)) { - return - } - - const parsedData = JSON.parse(data.data) - const ttlMs = data.ttlHours ? data.ttlHours * 1000 * 60 * 60 : undefined - - if (entry) { - const updateData: UpdateKvEntryRequest = { - data: parsedData, - ttlMs, - } - await updateMutation.mutateAsync({ projectId: projectId!, key: entry.key, data: updateData }) - } else if (projectId) { - const createData: CreateKvEntryRequest = { - projectId, - key: data.key, - data: parsedData, - ttlMs, - } - await createMutation.mutateAsync(createData) - } - onOpenChange(false) - } - - const isLoading = createMutation.isPending || updateMutation.isPending - - return ( - - - - {entry ? 'Edit KV Entry' : 'Create KV Entry'} - - {entry - ? 'Update the KV entry data and TTL.' - : 'Add a new key-value entry to store project data.'} - - - -
-
- - - {errors.key && ( -

{errors.key.message}

- )} -
- -
- -