From c591c15743501ea2d02335554988fbc3c1fc5562 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Sun, 18 Oct 2026 22:35:39 +0000 Subject: [PATCH 1/5] feat(servers): link remote OpenCode servers with QR pairing --- .../202610181200-opencode-servers.ts | 26 + backend/src/db/migrations/index.ts | 2 + backend/src/db/opencode-servers.ts | 184 +++++ backend/src/index.ts | 12 + backend/src/routes/opencode-server-proxy.ts | 130 ++++ backend/src/routes/opencode-servers.ts | 100 +++ .../services/opencode-server-registry.test.ts | 350 +++++++++ .../src/services/opencode-server-registry.ts | 326 ++++++++ backend/src/services/opencode/auth.ts | 8 + backend/src/services/opencode/client.ts | 9 +- backend/src/services/remote-server-events.ts | 409 ++++++++++ backend/src/services/sse-aggregator.ts | 116 ++- backend/test/index.test.ts | 1 + .../test/routes/opencode-server-proxy.test.ts | 209 +++++ backend/test/routes/opencode-servers.test.ts | 213 +++++ backend/test/routes/sse.test.ts | 19 + .../services/remote-server-events.test.ts | 733 ++++++++++++++++++ .../services/sse-aggregator-remote.test.ts | 215 +++++ frontend/package.json | 1 + frontend/src/App.permission-dialog.test.tsx | 116 +++ frontend/src/App.tsx | 45 +- frontend/src/api/mcp.ts | 22 +- frontend/src/api/opencode.ts | 294 ++++--- frontend/src/api/opencodeApi.test.ts | 28 + frontend/src/api/opencodeApi.ts | 34 +- frontend/src/api/opencodeServers.test.ts | 129 +++ frontend/src/api/opencodeServers.ts | 77 ++ frontend/src/api/providers.ts | 21 +- .../components/message/ComposerToolsMenu.tsx | 50 +- .../components/message/FileReferenceText.tsx | 1 + .../src/components/message/FileToolRender.tsx | 19 +- .../components/message/MessagePart.test.tsx | 31 + .../message/PromptInput.mention.test.tsx | 8 +- .../src/components/message/PromptInput.tsx | 11 +- .../navigation/DesktopSessionTree.test.tsx | 2 +- .../navigation/DesktopSidebar.test.tsx | 18 + .../components/navigation/DesktopSidebar.tsx | 3 + .../navigation/LinkedServersNav.test.tsx | 78 ++ .../navigation/LinkedServersNav.tsx | 51 ++ .../navigation/MobileSheetHost.test.tsx | 14 + .../components/navigation/MobileSheetHost.tsx | 4 +- .../components/navigation/MoreDrawer.test.tsx | 22 + .../src/components/navigation/MoreDrawer.tsx | 14 +- .../components/navigation/moreDrawerItems.ts | 19 + .../notifications/PendingActionsGroup.tsx | 8 +- .../src/components/repo/RepoMcpDialog.tsx | 12 +- .../components/repo/RepoSkillsDialog.test.tsx | 2 +- .../session/BackgroundWorkBar.test.tsx | 8 +- .../components/session/BackgroundWorkBar.tsx | 6 +- .../session/SessionPickerDialog.tsx | 7 +- .../session/SideQuestionDialog.test.tsx | 8 +- .../components/session/SideQuestionDialog.tsx | 6 +- .../settings/OpenCodeServerDialog.test.tsx | 121 +++ .../settings/OpenCodeServerDialog.tsx | 448 +++++++++++ .../settings/OpenCodeServersSettings.tsx | 279 +++++++ .../src/components/settings/QrCodeScanner.tsx | 169 ++++ .../settings/SettingsDialog.test.tsx | 4 +- .../components/settings/SettingsDialog.tsx | 6 +- frontend/src/contexts/EventContext.test.tsx | 105 ++- frontend/src/contexts/EventContext.tsx | 157 ++-- .../contexts/OpenCodeServerContext.test.tsx | 27 + .../src/contexts/OpenCodeServerContext.tsx | 33 + .../__tests__/useCommandHandler.test.tsx | 10 +- .../src/hooks/__tests__/useLoadSkill.test.tsx | 2 +- frontend/src/hooks/useActiveWork.test.tsx | 2 +- frontend/src/hooks/useCommandHandler.ts | 23 +- frontend/src/hooks/useCommands.test.tsx | 42 +- frontend/src/hooks/useCommands.ts | 23 +- frontend/src/hooks/useContextUsage.ts | 5 +- frontend/src/hooks/useFileSearch.ts | 7 +- .../hooks/useGlobalShortcutActions.test.tsx | 2 +- frontend/src/hooks/useMcpServers.ts | 12 +- frontend/src/hooks/useModelSelection.ts | 12 +- frontend/src/hooks/useOpenCode.test.tsx | 22 +- frontend/src/hooks/useOpenCode.ts | 102 ++- frontend/src/hooks/useOpenCodeServers.ts | 97 +++ frontend/src/hooks/usePermissionModeToggle.ts | 8 +- frontend/src/hooks/useProviders.ts | 8 +- frontend/src/hooks/useRemoveMessage.test.ts | 12 +- frontend/src/hooks/useRemoveMessage.ts | 18 +- .../useResolveConflictsWithAgent.test.tsx | 1 + .../src/hooks/useResolveConflictsWithAgent.ts | 3 + frontend/src/hooks/useSSE.test.tsx | 73 ++ frontend/src/hooks/useSSE.ts | 19 +- frontend/src/hooks/useSandboxToggle.ts | 4 +- frontend/src/hooks/useSendPrompt.test.ts | 20 +- frontend/src/hooks/useSessionGoals.ts | 3 +- .../src/hooks/useSessionPermissionMode.ts | 3 +- frontend/src/hooks/useSessionSandbox.ts | 3 +- frontend/src/hooks/useSessionShells.ts | 15 +- .../src/hooks/useSessionTranscript.test.tsx | 8 +- frontend/src/hooks/useSessionTranscript.ts | 24 +- frontend/src/hooks/useSettingsDialog.ts | 2 +- frontend/src/hooks/useUndoMessage.ts | 7 +- frontend/src/lib/builtinCommands.ts | 16 + frontend/src/lib/navigation.test.ts | 47 +- frontend/src/lib/navigation.ts | 29 +- .../__tests__/openCodeEventStream.test.ts | 28 + .../openCodeEventStream.ts | 15 +- frontend/src/lib/serverScope.test.ts | 20 + frontend/src/lib/serverScope.ts | 12 + frontend/src/lib/utils.ts | 5 + frontend/src/pages/RemoteServer.tsx | 329 ++++++++ frontend/src/pages/SessionDetail.tsx | 270 ++++--- .../src/pages/__tests__/RemoteServer.test.tsx | 218 ++++++ .../__tests__/SessionDetail.commands.test.tsx | 18 +- .../__tests__/SessionDetail.remote.test.tsx | 321 ++++++++ ocm-cli/README.md | 34 + ocm-cli/bin/ocm.ts | 154 ++++ ocm-cli/package.json | 1 + ocm-cli/src/link.ts | 234 ++++++ ocm-cli/test/link.test.ts | 124 +++ pnpm-lock.yaml | 16 + shared/src/schemas/index.ts | 1 + shared/src/schemas/opencode-servers.ts | 132 ++++ 115 files changed, 7630 insertions(+), 566 deletions(-) create mode 100644 backend/src/db/migrations/202610181200-opencode-servers.ts create mode 100644 backend/src/db/opencode-servers.ts create mode 100644 backend/src/routes/opencode-server-proxy.ts create mode 100644 backend/src/routes/opencode-servers.ts create mode 100644 backend/src/services/opencode-server-registry.test.ts create mode 100644 backend/src/services/opencode-server-registry.ts create mode 100644 backend/src/services/remote-server-events.ts create mode 100644 backend/test/routes/opencode-server-proxy.test.ts create mode 100644 backend/test/routes/opencode-servers.test.ts create mode 100644 backend/test/services/remote-server-events.test.ts create mode 100644 backend/test/services/sse-aggregator-remote.test.ts create mode 100644 frontend/src/App.permission-dialog.test.tsx create mode 100644 frontend/src/api/opencodeApi.test.ts create mode 100644 frontend/src/api/opencodeServers.test.ts create mode 100644 frontend/src/api/opencodeServers.ts create mode 100644 frontend/src/components/navigation/LinkedServersNav.test.tsx create mode 100644 frontend/src/components/navigation/LinkedServersNav.tsx create mode 100644 frontend/src/components/settings/OpenCodeServerDialog.test.tsx create mode 100644 frontend/src/components/settings/OpenCodeServerDialog.tsx create mode 100644 frontend/src/components/settings/OpenCodeServersSettings.tsx create mode 100644 frontend/src/components/settings/QrCodeScanner.tsx create mode 100644 frontend/src/contexts/OpenCodeServerContext.test.tsx create mode 100644 frontend/src/contexts/OpenCodeServerContext.tsx create mode 100644 frontend/src/hooks/useOpenCodeServers.ts create mode 100644 frontend/src/lib/serverScope.test.ts create mode 100644 frontend/src/lib/serverScope.ts create mode 100644 frontend/src/pages/RemoteServer.tsx create mode 100644 frontend/src/pages/__tests__/RemoteServer.test.tsx create mode 100644 frontend/src/pages/__tests__/SessionDetail.remote.test.tsx create mode 100644 ocm-cli/src/link.ts create mode 100644 ocm-cli/test/link.test.ts create mode 100644 shared/src/schemas/opencode-servers.ts diff --git a/backend/src/db/migrations/202610181200-opencode-servers.ts b/backend/src/db/migrations/202610181200-opencode-servers.ts new file mode 100644 index 000000000..6fab50dd5 --- /dev/null +++ b/backend/src/db/migrations/202610181200-opencode-servers.ts @@ -0,0 +1,26 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + id: '202610181200-opencode-servers', + + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS opencode_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + username TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + }, + + down(db) { + db.run('DROP TABLE IF EXISTS opencode_servers') + db.run("DELETE FROM app_secrets WHERE key LIKE 'opencode_server:%:password'") + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index b04854719..93cb73794 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -33,6 +33,7 @@ import migration202610081200 from './202610081200-change-walkthrough-sources' import migration202610141200 from './202610141200-session-sandbox-modes' import migration202610151200 from './202610151200-change-walkthrough-sessions' import migration202610161200 from './202610161200-change-walkthrough-execution' +import migration202610181200 from './202610181200-opencode-servers' export const allMigrations: Migration[] = [ migration001, @@ -69,4 +70,5 @@ export const allMigrations: Migration[] = [ migration202610141200, migration202610151200, migration202610161200, + migration202610181200, ] diff --git a/backend/src/db/opencode-servers.ts b/backend/src/db/opencode-servers.ts new file mode 100644 index 000000000..9f8bf543a --- /dev/null +++ b/backend/src/db/opencode-servers.ts @@ -0,0 +1,184 @@ +import { randomBytes } from 'node:crypto' +import type { Database } from 'bun:sqlite' +import type { OpenCodeServer } from '@opencode-manager/shared/schemas' +import { decryptSecret, encryptSecret } from '../utils/crypto' +import { logger } from '../utils/logger' + +interface OpenCodeServerRow { + id: string + name: string + base_url: string + username: string + enabled: number + created_at: number + updated_at: number + has_password: number +} + +export interface OpenCodeServerInsert { + name: string + baseUrl: string + username: string + enabled: boolean + password?: string | null +} + +export interface OpenCodeServerUpdate { + name?: string + baseUrl?: string + username?: string + enabled?: boolean + password?: string | null +} + +const ROW_COLUMNS = 'id, name, base_url, username, enabled, created_at, updated_at' + +const SELECT_SERVER = ` + SELECT s.id, s.name, s.base_url, s.username, s.enabled, s.created_at, s.updated_at, + CASE WHEN k.key IS NULL THEN 0 ELSE 1 END AS has_password + FROM opencode_servers s + LEFT JOIN app_secrets k ON k.key = 'opencode_server:' || s.id || ':password' +` + +export function normalizeOpenCodeServerBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, '') +} + +function passwordSecretKey(serverId: string): string { + return `opencode_server:${serverId}:password` +} + +function mapRow(row: OpenCodeServerRow): OpenCodeServer { + return { + id: row.id, + name: row.name, + baseUrl: row.base_url, + username: row.username, + enabled: row.enabled === 1, + hasPassword: row.has_password === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function listOpenCodeServers(db: Database): OpenCodeServer[] { + const rows = db + .prepare(`${SELECT_SERVER} ORDER BY s.name COLLATE NOCASE, s.created_at`) + .all() as OpenCodeServerRow[] + return rows.map(mapRow) +} + +export function getOpenCodeServer(db: Database, serverId: string): OpenCodeServer | null { + const row = db + .prepare(`${SELECT_SERVER} WHERE s.id = ?`) + .get(serverId) as OpenCodeServerRow | undefined + return row ? mapRow(row) : null +} + +export function readOpenCodeServerPassword(db: Database, serverId: string): string | null { + const row = db + .prepare('SELECT value FROM app_secrets WHERE key = ?') + .get(passwordSecretKey(serverId)) as { value: string } | undefined + if (!row) return null + + try { + return decryptSecret(row.value) + } catch (error) { + logger.error(`Failed to decrypt the password for OpenCode server ${serverId}`, error) + return null + } +} + +export function setOpenCodeServerPassword(db: Database, serverId: string, password: string): void { + const now = Date.now() + 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(passwordSecretKey(serverId), encryptSecret(password), now, now) +} + +export function clearOpenCodeServerPassword(db: Database, serverId: string): void { + db.prepare('DELETE FROM app_secrets WHERE key = ?').run(passwordSecretKey(serverId)) +} + +export function insertOpenCodeServer(db: Database, input: OpenCodeServerInsert): OpenCodeServer { + const id = `srv_${randomBytes(18).toString('base64url')}` + const now = Date.now() + + const insert = db.transaction(() => { + db.prepare(`INSERT INTO opencode_servers (${ROW_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + id, + input.name, + normalizeOpenCodeServerBaseUrl(input.baseUrl), + input.username, + input.enabled ? 1 : 0, + now, + now, + ) + if (input.password) { + setOpenCodeServerPassword(db, id, input.password) + } + return getOpenCodeServer(db, id) + }) + + const server = insert() + if (!server) { + throw new Error(`Failed to read the OpenCode server inserted with id ${id}`) + } + return server +} + +export function updateOpenCodeServer( + db: Database, + serverId: string, + patch: OpenCodeServerUpdate, +): OpenCodeServer | null { + const update = db.transaction(() => { + if (!getOpenCodeServer(db, serverId)) return null + + const assignments: string[] = [] + const values: Array = [] + + if (patch.name !== undefined) { + assignments.push('name = ?') + values.push(patch.name) + } + if (patch.baseUrl !== undefined) { + assignments.push('base_url = ?') + values.push(normalizeOpenCodeServerBaseUrl(patch.baseUrl)) + } + if (patch.username !== undefined) { + assignments.push('username = ?') + values.push(patch.username) + } + if (patch.enabled !== undefined) { + assignments.push('enabled = ?') + values.push(patch.enabled ? 1 : 0) + } + + assignments.push('updated_at = ?') + values.push(Date.now()) + + db.prepare(`UPDATE opencode_servers SET ${assignments.join(', ')} WHERE id = ?`).run(...values, serverId) + + if (patch.password === null) { + clearOpenCodeServerPassword(db, serverId) + } else if (patch.password !== undefined) { + setOpenCodeServerPassword(db, serverId, patch.password) + } + + return getOpenCodeServer(db, serverId) + }) + + return update() +} + +export function deleteOpenCodeServer(db: Database, serverId: string): boolean { + const remove = db.transaction(() => { + const result = db.prepare('DELETE FROM opencode_servers WHERE id = ?').run(serverId) + clearOpenCodeServerPassword(db, serverId) + return result.changes > 0 + }) + + return remove() +} diff --git a/backend/src/index.ts b/backend/src/index.ts index c08933600..73ae3d4cb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -51,7 +51,10 @@ import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy' +import { createOpenCodeServerProxyRoutes } from './routes/opencode-server-proxy' +import { createOpenCodeServerRoutes } from './routes/opencode-servers' import { sseAggregator } from './services/sse-aggregator' +import { RemoteServerEventHub } from './services/remote-server-events' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' import { SettingsService } from './services/settings' import { SessionPermissionModeService } from './services/session-permission-modes' @@ -62,6 +65,7 @@ import { MultiRunService } from './services/multi-runs' import { ChangeWalkthroughService } from './services/change-walkthroughs' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' +import { OpenCodeServerRegistry } from './services/opencode-server-registry' import { getOpenCodeUpstreamBaseUrl } from './services/opencode/upstream' import { NotificationService } from './services/notification' import { ScheduleRunner, ScheduleService } from './services/schedules' @@ -127,6 +131,7 @@ const openCodeClient = createOpenCodeClient( () => new SettingsService(db).getOpenCodeServerPassword(), () => opencodeServerManager.getEffectiveServerHost(), ) +export const opencodeServerRegistry = new OpenCodeServerRegistry(db, openCodeClient) import { DEFAULT_AGENTS_MD } from './constants' @@ -340,6 +345,9 @@ sseAggregator.onUpstreamConnected(() => { }) sseAggregator.start() +const remoteServerEvents = new RemoteServerEventHub(opencodeServerRegistry, sseAggregator) +remoteServerEvents.start() + void sessionGoalService.recoverOpenGoals().catch((error) => { logger.error('Session goal recovery error:', error) }) @@ -379,6 +387,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/opencode-servers', createOpenCodeServerRoutes(opencodeServerRegistry, () => remoteServerEvents.getStatuses())) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService)) protectedApi.route('/session-goals', createSessionGoalRoutes(sessionGoalService)) @@ -390,6 +399,7 @@ protectedApi.route('/preview', createPreviewRoutes({ store: previewSessionStore, app.route('/api', protectedApi) +app.route('/api/opencode/servers', createOpenCodeServerProxyRoutes(opencodeServerRegistry, requireAuth)) app.route('/api/opencode', createAuthenticatedOpenCodeProxyRoutes(openCodeClient, requireAuth)) const isProduction = ENV.SERVER.NODE_ENV === 'production' @@ -468,6 +478,8 @@ const shutdown = async (signal: string) => { logger.info(`${signal} received, shutting down gracefully...`) try { + remoteServerEvents.shutdown() + logger.info('Remote server event hub stopped') sseAggregator.shutdown() logger.info('SSE Aggregator stopped') if (ipcServer) { diff --git a/backend/src/routes/opencode-server-proxy.ts b/backend/src/routes/opencode-server-proxy.ts new file mode 100644 index 000000000..34a126b7b --- /dev/null +++ b/backend/src/routes/opencode-server-proxy.ts @@ -0,0 +1,130 @@ +import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { MiddlewareHandler } from 'hono' +import { + OpenCodeServerError, + type OpenCodeServerConnection, + type OpenCodeServerRegistry, +} from '../services/opencode-server-registry' +import { filterProxyHeaders } from '../utils/proxy-headers' +import { logger } from '../utils/logger' + +const MOUNT_PREFIX = '/api/opencode/servers/' +const REQUEST_EXCLUDED_HEADERS = new Set(['cookie', 'origin', 'referer']) +const RESPONSE_EXCLUDED_HEADERS = new Set(['set-cookie']) +const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]) + +type UpstreamPath = { ok: true; path: string } | { ok: false; status: 400 | 404 } + +function decodeSegment(segment: string): string | null { + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + return null + } + try { + return decodeURIComponent(decoded) + } catch { + return decoded + } +} + +function decodePathSegments(path: string): string[] | null { + const segments: string[] = [] + for (const segment of path.split('/')) { + const decoded = decodeSegment(segment) + if (decoded === null) return null + if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) return null + segments.push(decoded) + } + return segments +} + +/** + * Resolves the upstream path for a request mounted at `/api/opencode/servers`. + * Only OpenCode API paths are forwardable, and encoded traversal segments are refused. + */ +function resolveUpstreamPath(pathname: string): UpstreamPath { + const remainder = pathname.slice(MOUNT_PREFIX.length) + const separator = remainder.indexOf('/') + const encodedPath = separator === -1 ? '/' : remainder.slice(separator) + const segments = decodePathSegments(encodedPath) + if (!segments) return { ok: false, status: 400 } + + const path = segments.join('/') + return path.startsWith('/api/') ? { ok: true, path } : { ok: false, status: 404 } +} + +function buildUpstreamUrl(connection: OpenCodeServerConnection, path: string, search: string): URL { + const url = new URL(connection.baseUrl) + url.pathname = `${url.pathname.replace(/\/+$/, '')}${path}` + url.search = search + return url +} + +export function createOpenCodeServerProxyRoutes( + registry: OpenCodeServerRegistry, + requireAuth: MiddlewareHandler, + fetchFn: typeof fetch = fetch, +): Hono { + const app = new Hono() + + app.all('/:serverId/*', requireAuth, async (c) => { + const url = new URL(c.req.url) + const upstreamPath = resolveUpstreamPath(url.pathname) + if (!upstreamPath.ok) { + return upstreamPath.status === 400 + ? c.json({ error: 'Invalid proxy path' }, 400) + : c.json({ error: 'Not found' }, 404) + } + + let connection: OpenCodeServerConnection + try { + connection = registry.getConnection(c.req.param('serverId')) + } catch (error) { + if (error instanceof OpenCodeServerError) { + return c.json({ error: error.message }, error.status as ContentfulStatusCode) + } + logger.error('Failed to resolve OpenCode server connection', error) + return c.json({ error: 'Failed to proxy request' }, 500) + } + + const headers = filterProxyHeaders(c.req.raw.headers, REQUEST_EXCLUDED_HEADERS) + if (connection.authorization) { + headers.Authorization = connection.authorization + } + + const hasBody = c.req.method !== 'GET' && c.req.method !== 'HEAD' + const upstreamUrl = buildUpstreamUrl(connection, upstreamPath.path, url.search) + + try { + const upstreamResponse = await fetchFn(upstreamUrl.toString(), { + method: c.req.method, + headers, + body: hasBody ? c.req.raw.body : undefined, + redirect: 'manual', + signal: c.req.raw.signal, + duplex: 'half', + }) + + const responseHeaders = new Headers( + filterProxyHeaders(upstreamResponse.headers, RESPONSE_EXCLUDED_HEADERS), + ) + const responseBody = NULL_BODY_STATUSES.has(upstreamResponse.status) + ? null + : upstreamResponse.body + + return new Response(responseBody, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders, + }) + } catch (error) { + logger.warn(`OpenCode server proxy request failed: ${c.req.method} ${upstreamUrl.host}${upstreamPath.path}`, error) + return c.json({ error: 'Remote OpenCode server unreachable' }, 502) + } + }) + + return app +} diff --git a/backend/src/routes/opencode-servers.ts b/backend/src/routes/opencode-servers.ts new file mode 100644 index 000000000..3e5c11300 --- /dev/null +++ b/backend/src/routes/opencode-servers.ts @@ -0,0 +1,100 @@ +import { Hono } from 'hono' +import { + CreateOpenCodeServerRequestSchema, + TestOpenCodeServerRequestSchema, + UpdateOpenCodeServerRequestSchema, + type OpenCodeServerStatus, +} from '@opencode-manager/shared/schemas' +import { OpenCodeServerError, type OpenCodeServerRegistry } from '../services/opencode-server-registry' +import { handleServiceError, parseJsonBody } from '../utils/route-helpers' + +export type OpenCodeServerStatusProvider = () => OpenCodeServerStatus[] + +export function createOpenCodeServerRoutes( + registry: OpenCodeServerRegistry, + statusProvider?: OpenCodeServerStatusProvider, +) { + const app = new Hono() + + app.get('/', (c) => { + try { + return c.json({ servers: registry.list() }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list OpenCode servers', OpenCodeServerError) + } + }) + + app.post('/', async (c) => { + const parsed = await parseJsonBody(c, CreateOpenCodeServerRequestSchema) + if (!parsed.ok) return parsed.response + + try { + const server = await registry.create(parsed.data) + const test = await registry.test({ serverId: server.id }) + return c.json({ server, test }, 201) + } catch (error) { + return handleServiceError(c, error, 'Failed to create OpenCode server', OpenCodeServerError) + } + }) + + app.post('/test', async (c) => { + const parsed = await parseJsonBody(c, TestOpenCodeServerRequestSchema) + if (!parsed.ok) return parsed.response + + try { + return c.json(await registry.test(parsed.data)) + } catch (error) { + return handleServiceError(c, error, 'Failed to test OpenCode server', OpenCodeServerError) + } + }) + + app.get('/status', (c) => { + if (!statusProvider) return c.json({ statuses: [] }) + try { + return c.json({ statuses: statusProvider() }) + } catch (error) { + return handleServiceError(c, error, 'Failed to read OpenCode server status', OpenCodeServerError) + } + }) + + app.get('/:id', (c) => { + try { + const server = registry.get(c.req.param('id')) + if (!server) return c.json({ error: 'OpenCode server not found' }, 404) + return c.json({ server }) + } catch (error) { + return handleServiceError(c, error, 'Failed to read OpenCode server', OpenCodeServerError) + } + }) + + app.patch('/:id', async (c) => { + const parsed = await parseJsonBody(c, UpdateOpenCodeServerRequestSchema) + if (!parsed.ok) return parsed.response + + try { + const server = await registry.update(c.req.param('id'), parsed.data) + return c.json({ server }) + } catch (error) { + return handleServiceError(c, error, 'Failed to update OpenCode server', OpenCodeServerError) + } + }) + + app.delete('/:id', (c) => { + try { + registry.delete(c.req.param('id')) + return c.body(null, 204) + } catch (error) { + return handleServiceError(c, error, 'Failed to delete OpenCode server', OpenCodeServerError) + } + }) + + app.post('/:id/test', async (c) => { + try { + return c.json(await registry.test({ serverId: c.req.param('id') })) + } catch (error) { + return handleServiceError(c, error, 'Failed to test OpenCode server', OpenCodeServerError) + } + }) + + return app +} diff --git a/backend/src/services/opencode-server-registry.test.ts b/backend/src/services/opencode-server-registry.test.ts new file mode 100644 index 000000000..ab4729bec --- /dev/null +++ b/backend/src/services/opencode-server-registry.test.ts @@ -0,0 +1,350 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../db/migration-runner' +import { allMigrations } from '../db/migrations' +import { decryptSecret } from '../utils/crypto' +import { + OpenCodeServerError, + OpenCodeServerNotFoundError, + OpenCodeServerPairingError, + OpenCodeServerRegistry, + type OpenCodeServerChange, +} from './opencode-server-registry' +import { FetchOpenCodeClient, type OpenCodeClient } from './opencode/client' + +vi.mock('../utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +type FetchMock = ReturnType Promise>> + +const PAIR_URL = 'https://remote.example.com/pair' +const PAIR_CODE = '0123456789abcdef' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createLocalClient(): OpenCodeClient { + return { api: {} as OpenCodeClient['api'], forwardRaw: vi.fn() } +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function readStoredSecret(db: Database, serverId: string): { value: string } | undefined { + return db + .prepare('SELECT value FROM app_secrets WHERE key = ?') + .get(`opencode_server:${serverId}:password`) as { value: string } | undefined +} + +describe('OpenCodeServerRegistry', () => { + let db: Database + let localClient: OpenCodeClient + let fetchFn: FetchMock + let registry: OpenCodeServerRegistry + + beforeEach(() => { + db = createTestDb() + localClient = createLocalClient() + fetchFn = vi.fn(async () => jsonResponse({})) + registry = new OpenCodeServerRegistry(db, localClient, fetchFn as unknown as typeof fetch) + }) + + describe('create', () => { + it('creates a server with a normalized base URL and an encrypted password', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com/', + password: 'super-secret', + }) + + expect(server.id).toMatch(/^srv_[A-Za-z0-9_-]+$/) + expect(server.baseUrl).toBe('https://studio.example.com') + expect(server.username).toBe('opencode') + expect(server.enabled).toBe(true) + expect(server.hasPassword).toBe(true) + expect(registry.list()).toHaveLength(1) + + const stored = readStoredSecret(db, server.id) + expect(stored?.value).not.toBe('super-secret') + expect(decryptSecret(stored!.value)).toBe('super-secret') + expect(fetchFn).not.toHaveBeenCalled() + }) + + it('creates a server without a password when none is provided', async () => { + const server = await registry.create({ name: 'Open', baseUrl: 'http://localhost:4096' }) + + expect(server.hasPassword).toBe(false) + expect(readStoredSecret(db, server.id)).toBeUndefined() + }) + + it('rejects an invalid request with a validation error', async () => { + await expect(registry.create({ name: '', baseUrl: 'not-a-url' })).rejects.toMatchObject({ status: 400 }) + }) + + it('redeems a pairing code and stores the returned credentials', async () => { + fetchFn.mockResolvedValueOnce(jsonResponse({ username: 'alice', password: 'paired-pass' })) + + const server = await registry.create({ + name: 'Paired', + baseUrl: 'https://remote.example.com', + username: 'ignored', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }) + + expect(server.username).toBe('alice') + expect(server.hasPassword).toBe(true) + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('paired-pass') + + const [url, init] = fetchFn.mock.calls[0]! + expect(url).toBe(PAIR_URL) + expect(init?.method).toBe('POST') + expect(init?.body).toBe(JSON.stringify({ code: PAIR_CODE })) + }) + + it('fails with a pairing error when the pairing endpoint rejects the code', async () => { + fetchFn.mockResolvedValueOnce(new Response(null, { status: 403 })) + + await expect( + registry.create({ + name: 'Paired', + baseUrl: 'https://remote.example.com', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }), + ).rejects.toMatchObject({ status: 502 }) + + expect(registry.list()).toEqual([]) + expect(fetchFn).toHaveBeenCalledTimes(1) + }) + + it('fails with a pairing error when the pairing endpoint is unreachable', async () => { + fetchFn.mockRejectedValueOnce(new Error('connect ECONNREFUSED')) + + const error = await registry + .create({ + name: 'Paired', + baseUrl: 'https://remote.example.com', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeServerPairingError) + expect((error as Error).message).not.toContain(PAIR_CODE) + }) + }) + + describe('update', () => { + it('updates fields and clears the password when null is sent', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const updated = await registry.update(server.id, { name: 'Renamed', password: null }) + + expect(updated.name).toBe('Renamed') + expect(updated.hasPassword).toBe(false) + expect(readStoredSecret(db, server.id)).toBeUndefined() + }) + + it('replaces the password when a new one is sent', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + await registry.update(server.id, { password: 'next-secret' }) + + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('next-secret') + }) + + it('throws a not-found error for an unknown server', async () => { + await expect(registry.update('srv_missing', { name: 'Renamed' })).rejects.toBeInstanceOf( + OpenCodeServerNotFoundError, + ) + }) + }) + + describe('delete', () => { + it('removes the server row and its stored password', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + registry.delete(server.id) + + expect(registry.get(server.id)).toBeNull() + expect(readStoredSecret(db, server.id)).toBeUndefined() + expect(() => registry.delete(server.id)).toThrow(OpenCodeServerNotFoundError) + }) + }) + + describe('getClient', () => { + it('returns the local client for the local server id', async () => { + expect(registry.getClient('local')).toBe(localClient) + }) + + it('caches remote clients and invalidates the cache on update', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const first = registry.getClient(server.id) + expect(first).toBeInstanceOf(FetchOpenCodeClient) + expect(registry.getClient(server.id)).toBe(first) + + await registry.update(server.id, { name: 'Renamed' }) + + const second = registry.getClient(server.id) + expect(second).toBeInstanceOf(FetchOpenCodeClient) + expect(second).not.toBe(first) + }) + + it('sends stored basic auth without the local default directory header', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + await registry.getClient(server.id).forwardRaw( + new Request('http://localhost:5003/api/opencode/api/info'), + ) + + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBe(`Basic ${btoa('opencode:super-secret')}`) + expect(headers.get('x-opencode-directory')).toBeNull() + }) + + it('throws a not-found error for unknown and disabled servers', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + await registry.update(server.id, { enabled: false }) + + expect(() => registry.getClient('srv_missing')).toThrow(OpenCodeServerNotFoundError) + expect(() => registry.getClient(server.id)).toThrow(OpenCodeServerNotFoundError) + }) + }) + + describe('getConnection', () => { + it('returns the remote base URL and authorization header', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com/', + username: 'alice', + password: 'super-secret', + }) + + expect(registry.getConnection(server.id)).toEqual({ + baseUrl: 'https://studio.example.com', + authorization: `Basic ${btoa('alice:super-secret')}`, + }) + }) + + it('rejects the local server id', async () => { + expect(() => registry.getConnection('local')).toThrow(OpenCodeServerError) + }) + }) + + describe('test', () => { + it('reports a supported version', async () => { + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + + const result = await registry.test({ + baseUrl: 'https://studio.example.com/', + username: 'alice', + password: 'super-secret', + }) + + expect(result).toEqual({ ok: true, version: '2.0.15', versionSupported: true, error: null }) + expect(fetchFn.mock.calls[0]?.[0]).toBe('https://studio.example.com/api/info') + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`) + }) + + it('reports an unsupported version', async () => { + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '1.4.0' })) + + const result = await registry.test({ baseUrl: 'https://studio.example.com' }) + + expect(result).toEqual({ ok: true, version: '1.4.0', versionSupported: false, error: null }) + }) + + it('uses stored credentials for a server id', async () => { + const server = await registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + username: 'alice', + password: 'super-secret', + }) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + + const result = await registry.test({ serverId: server.id }) + + expect(result.ok).toBe(true) + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`) + }) + + it('reports a failed request without throwing', async () => { + fetchFn.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const result = await registry.test({ baseUrl: 'https://studio.example.com' }) + + expect(result.ok).toBe(false) + expect(result.version).toBeNull() + expect(result.error).toContain('500') + }) + + it('reports an unreachable server without throwing', async () => { + fetchFn.mockRejectedValueOnce(new Error('connect ECONNREFUSED')) + + const result = await registry.test({ baseUrl: 'https://studio.example.com' }) + + expect(result).toEqual({ + ok: false, + version: null, + versionSupported: false, + error: 'Could not reach the OpenCode server', + }) + }) + + it('throws a not-found error for an unknown server id', async () => { + await expect(registry.test({ serverId: 'srv_missing' })).rejects.toBeInstanceOf(OpenCodeServerNotFoundError) + }) + }) + + describe('onChange', () => { + it('emits upserted and deleted changes and unsubscribes', async () => { + const changes: OpenCodeServerChange[] = [] + const unsubscribe = registry.onChange((change) => changes.push(change)) + + const server = await registry.create({ name: 'Studio', baseUrl: 'https://studio.example.com' }) + registry.delete(server.id) + unsubscribe() + await registry.create({ name: 'Other', baseUrl: 'https://other.example.com' }) + + expect(changes).toEqual([ + { type: 'upserted', serverId: server.id }, + { type: 'deleted', serverId: server.id }, + ]) + }) + }) +}) diff --git a/backend/src/services/opencode-server-registry.ts b/backend/src/services/opencode-server-registry.ts new file mode 100644 index 000000000..06eed4e7e --- /dev/null +++ b/backend/src/services/opencode-server-registry.ts @@ -0,0 +1,326 @@ +import type { Database } from 'bun:sqlite' +import { z } from 'zod' +import { + CreateOpenCodeServerRequestSchema, + LOCAL_OPENCODE_SERVER_ID, + TestOpenCodeServerRequestSchema, + UpdateOpenCodeServerRequestSchema, + type CreateOpenCodeServerRequest, + type OpenCodeServer, + type OpenCodeServerPairing, + type OpenCodeServerTestResult, + type TestOpenCodeServerRequest, + type UpdateOpenCodeServerRequest, +} from '@opencode-manager/shared/schemas' +import { isSupportedOpenCodeVersion } from '@opencode-manager/shared/opencode' +import { + deleteOpenCodeServer, + getOpenCodeServer, + insertOpenCodeServer, + listOpenCodeServers, + normalizeOpenCodeServerBaseUrl, + readOpenCodeServerPassword, + updateOpenCodeServer, +} from '../db/opencode-servers' +import { logger } from '../utils/logger' +import { ServiceError } from '../utils/service-error' +import { getOpenCodeServerBasicAuthHeader } from './opencode/auth' +import { FetchOpenCodeClient, type OpenCodeClient } from './opencode/client' + +const PAIRING_TIMEOUT_MS = 10_000 +const TEST_TIMEOUT_MS = 5_000 + +const PairingRedeemResponseSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}) + +type TestServerInput = z.output + +interface TestTarget { + baseUrl: string + username: string + password: string | null +} + +export interface OpenCodeServerChange { + type: 'upserted' | 'deleted' + serverId: string +} + +export interface OpenCodeServerConnection { + baseUrl: string + authorization: string | null +} + +export type OpenCodeServerChangeListener = (change: OpenCodeServerChange) => void + +export class OpenCodeServerError extends ServiceError {} + +export class OpenCodeServerNotFoundError extends OpenCodeServerError { + constructor(serverId: string, reason: 'unknown' | 'disabled' = 'unknown') { + super( + reason === 'disabled' ? `OpenCode server is disabled: ${serverId}` : `OpenCode server not found: ${serverId}`, + 404, + ) + this.name = 'OpenCodeServerNotFoundError' + } +} + +export class OpenCodeServerValidationError extends OpenCodeServerError { + constructor(message: string, details?: unknown) { + super(message, 400, { details }) + this.name = 'OpenCodeServerValidationError' + } +} + +export class OpenCodeServerPairingError extends OpenCodeServerError { + constructor(message: string) { + super(message, 502) + this.name = 'OpenCodeServerPairingError' + } +} + +export class OpenCodeServerRegistry { + private readonly clients = new Map() + private readonly listeners = new Set() + + constructor( + private readonly db: Database, + private readonly localClient: OpenCodeClient, + private readonly fetchFn: typeof fetch = fetch, + ) {} + + list(): OpenCodeServer[] { + return listOpenCodeServers(this.db) + } + + get(serverId: string): OpenCodeServer | null { + return getOpenCodeServer(this.db, serverId) + } + + async create(input: CreateOpenCodeServerRequest): Promise { + const request = this.parse(CreateOpenCodeServerRequestSchema, input) + const pairing = request.pairing ? await this.redeemPairing(request.pairing) : null + + const server = insertOpenCodeServer(this.db, { + name: request.name, + baseUrl: request.baseUrl, + username: pairing?.username ?? request.username, + enabled: request.enabled, + password: pairing?.password ?? request.password ?? null, + }) + + this.emit({ type: 'upserted', serverId: server.id }) + return server + } + + async update(serverId: string, input: UpdateOpenCodeServerRequest): Promise { + this.requireServer(serverId) + const request = this.parse(UpdateOpenCodeServerRequestSchema, input) + const pairing = request.pairing ? await this.redeemPairing(request.pairing) : null + + const server = updateOpenCodeServer(this.db, serverId, { + name: request.name, + baseUrl: request.baseUrl, + username: pairing?.username ?? request.username, + enabled: request.enabled, + password: pairing?.password ?? request.password, + }) + if (!server) throw new OpenCodeServerNotFoundError(serverId) + + this.invalidateClient(serverId) + this.emit({ type: 'upserted', serverId }) + return server + } + + delete(serverId: string): void { + if (!deleteOpenCodeServer(this.db, serverId)) { + throw new OpenCodeServerNotFoundError(serverId) + } + + this.invalidateClient(serverId) + this.emit({ type: 'deleted', serverId }) + } + + getClient(serverId: string): OpenCodeClient { + if (serverId === LOCAL_OPENCODE_SERVER_ID) return this.localClient + + const cached = this.clients.get(serverId) + if (cached) return cached + + const server = this.requireServer(serverId) + if (!server.enabled) throw new OpenCodeServerNotFoundError(serverId, 'disabled') + + const password = readOpenCodeServerPassword(this.db, serverId) + const client = new FetchOpenCodeClient({ + baseUrl: server.baseUrl, + basicAuth: password === null ? null : getOpenCodeServerBasicAuthHeader(server.username, password), + defaultDirectory: false, + fetchFn: this.fetchFn, + }) + + this.clients.set(serverId, client) + return client + } + + getConnection(serverId: string): OpenCodeServerConnection { + if (serverId === LOCAL_OPENCODE_SERVER_ID) { + throw new OpenCodeServerError('The local OpenCode server has no remote connection', 400) + } + + const server = this.requireServer(serverId) + if (!server.enabled) throw new OpenCodeServerNotFoundError(serverId, 'disabled') + + const password = readOpenCodeServerPassword(this.db, serverId) + return { + baseUrl: server.baseUrl, + authorization: password === null ? null : getOpenCodeServerBasicAuthHeader(server.username, password), + } + } + + async test(input: TestOpenCodeServerRequest | { serverId: string }): Promise { + const target = this.resolveTestTarget(input) + const headers: Record = { Accept: 'application/json' } + if (target.password !== null) { + headers.Authorization = getOpenCodeServerBasicAuthHeader(target.username, target.password) + } + + let response: Response + try { + response = await this.fetchFn(`${target.baseUrl}/api/info`, { + headers, + signal: AbortSignal.timeout(TEST_TIMEOUT_MS), + }) + } catch { + return { + ok: false, + version: null, + versionSupported: false, + error: 'Could not reach the OpenCode server', + } + } + + if (!response.ok) { + return { + ok: false, + version: null, + versionSupported: false, + error: `OpenCode server responded with status ${response.status}`, + } + } + + const version = await this.readServerVersion(response) + if (version === null) { + return { + ok: false, + version: null, + versionSupported: false, + error: 'OpenCode server returned an invalid response', + } + } + + return { + ok: true, + version, + versionSupported: isSupportedOpenCodeVersion(version), + error: null, + } + } + + onChange(listener: OpenCodeServerChangeListener): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + private resolveTestTarget(input: TestOpenCodeServerRequest | { serverId: string }): TestTarget { + if ('serverId' in input) { + const server = this.requireServer(input.serverId) + return { + baseUrl: server.baseUrl, + username: server.username, + password: readOpenCodeServerPassword(this.db, input.serverId), + } + } + + const request: TestServerInput = this.parse(TestOpenCodeServerRequestSchema, input) + return { + baseUrl: normalizeOpenCodeServerBaseUrl(request.baseUrl), + username: request.username, + password: request.password ?? null, + } + } + + private async readServerVersion(response: Response): Promise { + try { + const body = (await response.json()) as { version?: unknown } + return typeof body.version === 'string' && body.version.length > 0 ? body.version : null + } catch { + return null + } + } + + private async redeemPairing(pairing: OpenCodeServerPairing): Promise<{ username: string; password: string }> { + let response: Response + try { + response = await this.fetchFn(pairing.pairUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: pairing.code }), + signal: AbortSignal.timeout(PAIRING_TIMEOUT_MS), + }) + } catch { + throw new OpenCodeServerPairingError('Could not reach the pairing endpoint') + } + + if (response.status !== 200) { + throw new OpenCodeServerPairingError(`Pairing was rejected with status ${response.status}`) + } + + let payload: unknown + try { + payload = await response.json() + } catch { + throw new OpenCodeServerPairingError('Pairing returned an invalid response') + } + + const parsed = PairingRedeemResponseSchema.safeParse(payload) + if (!parsed.success) { + throw new OpenCodeServerPairingError('Pairing returned an invalid response') + } + return parsed.data + } + + private requireServer(serverId: string): OpenCodeServer { + const server = getOpenCodeServer(this.db, serverId) + if (!server) throw new OpenCodeServerNotFoundError(serverId) + return server + } + + private invalidateClient(serverId: string): void { + this.clients.delete(serverId) + } + + private emit(change: OpenCodeServerChange): void { + for (const listener of this.listeners) { + try { + listener(change) + } catch (error) { + logger.error('OpenCode server change listener failed', error) + } + } + } + + private parse(schema: z.ZodType, input: unknown): T { + const parsed = schema.safeParse(input) + if (!parsed.success) { + throw new OpenCodeServerValidationError( + parsed.error.issues[0]?.message ?? 'Invalid OpenCode server request', + parsed.error.issues, + ) + } + return parsed.data + } +} diff --git a/backend/src/services/opencode/auth.ts b/backend/src/services/opencode/auth.ts index 510c5cab8..58246caa5 100644 --- a/backend/src/services/opencode/auth.ts +++ b/backend/src/services/opencode/auth.ts @@ -11,3 +11,11 @@ export function getOpenCodeBasicAuthHeader(source: string | OpenCodePasswordReso return buildOpenCodeBasicAuth(source) } + +/** Builds the Basic authorization header for a server that uses a username other than the default `opencode`. */ +export function getOpenCodeServerBasicAuthHeader(username: string, password: string): string { + const bytes = new TextEncoder().encode(`${username}:${password}`) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return `Basic ${btoa(binary)}` +} diff --git a/backend/src/services/opencode/client.ts b/backend/src/services/opencode/client.ts index 98777a4af..7c6f176cc 100644 --- a/backend/src/services/opencode/client.ts +++ b/backend/src/services/opencode/client.ts @@ -17,6 +17,7 @@ export interface FetchOpenCodeClientConfig { basicAuth: string | null passwordResolver?: OpenCodePasswordResolver fetchFn?: typeof fetch + defaultDirectory?: boolean } export class FetchOpenCodeClient implements OpenCodeClient { @@ -45,8 +46,12 @@ export class FetchOpenCodeClient implements OpenCodeClient { return await getOpenCodeBasicAuthHeader(this.config.passwordResolver) } + private withDirectoryHeader(headers: Record): Record { + return this.config.defaultDirectory === false ? headers : withDefaultOpenCodeDirectory(headers) + } + private async fetchWithResolvedAuth(input: Parameters[0], init?: Parameters[1]): Promise { - const headers = new Headers(withDefaultOpenCodeDirectory(Object.fromEntries(new Headers(init?.headers)))) + const headers = new Headers(this.withDirectoryHeader(Object.fromEntries(new Headers(init?.headers)))) const basicAuth = await this.getBasicAuth() if (basicAuth) { @@ -59,7 +64,7 @@ export class FetchOpenCodeClient implements OpenCodeClient { private async request(req: { method: string; path: string; body?: string; headers?: Record }): Promise { const url = new URL(this.resolveBaseUrl() + req.path) - const headers: Record = withDefaultOpenCodeDirectory({ ...(req.headers ?? {}) }) + const headers: Record = this.withDirectoryHeader({ ...(req.headers ?? {}) }) const basicAuth = await this.getBasicAuth() diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts new file mode 100644 index 000000000..a91189bea --- /dev/null +++ b/backend/src/services/remote-server-events.ts @@ -0,0 +1,409 @@ +import { EventSource } from 'eventsource' +import { DEFAULTS } from '@opencode-manager/shared/config' +import { + buildServerDirectoryKey, + LOCAL_OPENCODE_SERVER_ID, + type OpenCodeServer, + type OpenCodeServerStatus, +} from '@opencode-manager/shared/schemas' +import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { logger } from '../utils/logger' +import type { + OpenCodeServerChange, + OpenCodeServerConnection, + OpenCodeServerRegistry, +} from './opencode-server-registry' +import type { RemoteReplayHandler } from './sse-aggregator' + +const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE +const MULTILINE_PATTERN = /[\r\n]/ + +export interface RemoteEventAggregator { + deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void + deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void + sendRemoteResync(serverId: string, data: string): void + hasClientDirectory(clientId: string, key: string): boolean + setRemoteReplayHandler(handler: RemoteReplayHandler | null): void +} + +export interface RemoteEventSource { + onopen: ((event: unknown) => void) | null + onerror: ((event: unknown) => void) | null + onmessage: ((event: { data: string }) => void) | null + close(): void +} + +export type RemoteEventSourceInit = ConstructorParameters[1] +export type RemoteEventSourceFactory = (url: string, init?: RemoteEventSourceInit) => RemoteEventSource + +export interface RemoteServerEventHubOptions { + eventSourceFactory?: RemoteEventSourceFactory + reconnectDelayMs?: number + maxReconnectDelayMs?: number +} + +interface RemoteServerStream { + source: RemoteEventSource | null + reconnectTimeout: ReturnType | null + reconnectDelay: number + connected: boolean + error: string | null + version: string | null + versionRequested: boolean + sessionDirectories: Map +} + +type RemoteReplayEventType = 'permission.asked' | 'form.created' + +function createDefaultEventSource(url: string, init?: RemoteEventSourceInit): RemoteEventSource { + return new EventSource(url, init) as unknown as RemoteEventSource +} + +function describeStreamError(error: unknown): string { + if (typeof error === 'string' && error) return error + if (error instanceof Error && error.message) return error.message + if (typeof error === 'object' && error !== null) { + const { message, code } = error as { message?: unknown; code?: unknown } + if (typeof message === 'string' && message) return message + if (typeof code === 'number' || typeof code === 'string') return `event stream closed (code=${String(code)})` + } + return 'event stream disconnected' +} + +export class RemoteServerEventHub { + private readonly streams = new Map() + private readonly eventSourceFactory: RemoteEventSourceFactory + private readonly reconnectDelayMs: number + private readonly maxReconnectDelayMs: number + private unsubscribeRegistry: (() => void) | null = null + private started = false + private replayEventCount = 0 + + constructor( + private readonly registry: OpenCodeServerRegistry, + private readonly aggregator: RemoteEventAggregator, + options: RemoteServerEventHubOptions = {}, + ) { + this.eventSourceFactory = options.eventSourceFactory ?? createDefaultEventSource + this.reconnectDelayMs = options.reconnectDelayMs ?? RECONNECT_DELAY_MS + this.maxReconnectDelayMs = options.maxReconnectDelayMs ?? MAX_RECONNECT_DELAY_MS + } + + start(): void { + if (this.started) return + this.started = true + this.aggregator.setRemoteReplayHandler((clientId, serverId, directory) => + this.replayPendingActions(clientId, serverId, directory)) + this.unsubscribeRegistry = this.registry.onChange(change => this.handleRegistryChange(change)) + this.connectAll() + } + + shutdown(): void { + this.started = false + if (this.unsubscribeRegistry) { + this.unsubscribeRegistry() + this.unsubscribeRegistry = null + } + for (const serverId of Array.from(this.streams.keys())) { + this.forgetStream(serverId) + } + this.aggregator.setRemoteReplayHandler(null) + } + + getStatuses(): OpenCodeServerStatus[] { + let servers: OpenCodeServer[] + try { + servers = this.registry.list() + } catch (error) { + logger.error('Failed to list OpenCode servers for remote event status', error) + return [] + } + + return servers.map(server => { + const stream = this.streams.get(server.id) + return { + serverId: server.id, + connected: stream?.connected ?? false, + version: stream?.version ?? null, + error: stream?.error ?? null, + } + }) + } + + async replayPendingActions(clientId: string, serverId: string, directory: string): Promise { + const key = buildServerDirectoryKey(serverId, directory) + if (!this.aggregator.hasClientDirectory(clientId, key)) return + + let api: OpenCodeApi + try { + api = this.registry.getClient(serverId).api + } catch (error) { + logger.warn(`remote replay: no client for ${serverId}: ${String(error)}`) + return + } + + const [permissionsResult, formsResult] = await Promise.allSettled([ + api.permission.request.list(openCodeLocation(directory)), + api.form.list(openCodeLocation(directory)), + ]) + + if (permissionsResult.status === 'rejected') { + logger.warn(`remote replay: failed to fetch pending permissions for ${key}: ${String(permissionsResult.reason)}`) + } else { + this.emitReplayEventsToClient(clientId, serverId, directory, permissionsResult.value.data.map(request => + this.buildReplayEvent('permission.asked', directory, request) + )) + } + + if (formsResult.status === 'rejected') { + logger.warn(`remote replay: failed to fetch pending forms for ${key}: ${String(formsResult.reason)}`) + } else { + this.emitReplayEventsToClient(clientId, serverId, directory, formsResult.value.data.map(form => + this.buildReplayEvent('form.created', directory, { form }) + )) + } + } + + private emitReplayEventsToClient( + clientId: string, + serverId: string, + directory: string, + events: V2Event[], + ): void { + for (const event of events) { + this.aggregator.deliverRemoteEnvelopeToClient(clientId, serverId, directory, JSON.stringify(event)) + } + } + + private buildReplayEvent(type: RemoteReplayEventType, directory: string, data: unknown): V2Event { + this.replayEventCount += 1 + return { + id: `ocm_replay_remote_${this.replayEventCount}`, + created: Date.now(), + type, + location: { directory }, + data, + } as V2Event + } + + private connectAll(): void { + let servers: OpenCodeServer[] + try { + servers = this.registry.list() + } catch (error) { + logger.error('Failed to list OpenCode servers for remote event streams', error) + return + } + + for (const server of servers) { + if (server.id === LOCAL_OPENCODE_SERVER_ID) continue + if (server.enabled) this.connectServer(server.id) + } + } + + private connectServer(serverId: string): void { + if (!this.started) return + this.closeStream(serverId) + + let connection: OpenCodeServerConnection + try { + connection = this.registry.getConnection(serverId) + } catch (error) { + this.recordError(serverId, describeStreamError(error)) + logger.warn(`Remote event stream unavailable for ${serverId}: ${describeStreamError(error)}`) + return + } + + const authorization = connection.authorization + const init: RemoteEventSourceInit | undefined = authorization + ? { + fetch: (input, fetchInit) => + fetch(input, { + ...fetchInit, + headers: { + ...(fetchInit?.headers ?? {}), + Authorization: authorization, + }, + }), + } + : undefined + + const stream = this.ensureStream(serverId) + let source: RemoteEventSource + try { + source = this.eventSourceFactory(`${connection.baseUrl}/api/event`, init) + } catch (error) { + this.recordError(serverId, describeStreamError(error)) + this.scheduleReconnect(serverId) + return + } + + stream.source = source + stream.error = null + source.onopen = () => this.handleOpen(serverId, source) + source.onerror = (event) => this.handleStreamError(serverId, source, event) + source.onmessage = (event) => this.handleMessage(serverId, event.data) + logger.info(`Remote event stream connecting: ${serverId}`) + } + + private handleOpen(serverId: string, source: RemoteEventSource): void { + const stream = this.streams.get(serverId) + if (!stream || stream.source !== source) return + + stream.connected = true + stream.error = null + stream.reconnectDelay = this.reconnectDelayMs + logger.info(`Remote event stream connected: ${serverId}`) + this.aggregator.sendRemoteResync(serverId, JSON.stringify({ timestamp: Date.now() })) + void this.refreshVersion(serverId) + } + + private handleStreamError(serverId: string, source: RemoteEventSource, error: unknown): void { + const stream = this.streams.get(serverId) + if (!stream || stream.source !== source) return + + stream.connected = false + stream.error = describeStreamError(error) + logger.warn(`Remote event stream error for ${serverId}: ${stream.error}`) + this.closeSource(stream) + this.scheduleReconnect(serverId) + } + + private scheduleReconnect(serverId: string): void { + const stream = this.streams.get(serverId) + if (!this.started || !stream || stream.reconnectTimeout) return + + const delay = stream.reconnectDelay + stream.reconnectTimeout = setTimeout(() => { + const current = this.streams.get(serverId) + if (!current) return + current.reconnectTimeout = null + current.reconnectDelay = Math.min(current.reconnectDelay * 2, this.maxReconnectDelayMs) + this.connectServer(serverId) + }, delay) + } + + private handleMessage(serverId: string, data: string): void { + const stream = this.streams.get(serverId) + if (!stream) return + + let event: V2Event + try { + event = JSON.parse(data) as V2Event + } catch { + return + } + + if (!event?.type) return + + const payloadJson = MULTILINE_PATTERN.test(data) ? JSON.stringify(event) : data + const directory = event.location?.directory + const sessionID = sessionIDFromEvent(event) + const forkParentId = event.type === 'session.forked' ? event.data.parentID : undefined + const inheritedDirectory = forkParentId ? stream.sessionDirectories.get(forkParentId) : undefined + + try { + const rememberedDirectory = directory ?? inheritedDirectory + const resolvedDirectory = rememberedDirectory ?? (sessionID ? stream.sessionDirectories.get(sessionID) : undefined) + + if (sessionID && event.type === 'session.deleted') { + stream.sessionDirectories.delete(sessionID) + } else if (sessionID && rememberedDirectory) { + stream.sessionDirectories.set(sessionID, rememberedDirectory) + } + + this.aggregator.deliverRemoteEnvelope(serverId, resolvedDirectory ?? null, payloadJson) + } catch (error) { + logger.error(`Remote event stream failed to handle ${event.type} for ${serverId}:`, error) + } + } + + private handleRegistryChange(change: OpenCodeServerChange): void { + if (!this.started) return + + if (change.type === 'deleted') { + this.forgetStream(change.serverId) + return + } + + let server: OpenCodeServer | null + try { + server = this.registry.get(change.serverId) + } catch (error) { + logger.warn(`Failed to read OpenCode server ${change.serverId}: ${String(error)}`) + this.forgetStream(change.serverId) + return + } + + if (!server || !server.enabled) { + this.forgetStream(change.serverId) + return + } + + const stream = this.streams.get(change.serverId) + if (stream) stream.reconnectDelay = this.reconnectDelayMs + this.connectServer(change.serverId) + } + + private async refreshVersion(serverId: string): Promise { + const stream = this.streams.get(serverId) + if (!stream || stream.versionRequested) return + const test = this.registry.test + if (typeof test !== 'function') return + + stream.versionRequested = true + try { + const result = await test.call(this.registry, { serverId }) + const current = this.streams.get(serverId) + if (current && result.ok && result.version) current.version = result.version + } catch (error) { + logger.warn(`Failed to read the version of OpenCode server ${serverId}: ${String(error)}`) + } + } + + private ensureStream(serverId: string): RemoteServerStream { + const existing = this.streams.get(serverId) + if (existing) return existing + + const stream: RemoteServerStream = { + source: null, + reconnectTimeout: null, + reconnectDelay: this.reconnectDelayMs, + connected: false, + error: null, + version: null, + versionRequested: false, + sessionDirectories: new Map(), + } + this.streams.set(serverId, stream) + return stream + } + + private recordError(serverId: string, error: string): void { + const stream = this.ensureStream(serverId) + stream.connected = false + stream.error = error + } + + private closeSource(stream: RemoteServerStream): void { + if (!stream.source) return + stream.source.close() + stream.source = null + } + + private closeStream(serverId: string): void { + const stream = this.streams.get(serverId) + if (!stream) return + if (stream.reconnectTimeout) { + clearTimeout(stream.reconnectTimeout) + stream.reconnectTimeout = null + } + this.closeSource(stream) + stream.connected = false + } + + private forgetStream(serverId: string): void { + this.closeStream(serverId) + this.streams.delete(serverId) + } +} diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 4b5cedd50..ffde2e546 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -1,6 +1,7 @@ import { EventSource } from 'eventsource' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' +import { buildServerDirectoryKey, LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-manager/shared/schemas' import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' import { getOpenCodeUpstreamBaseUrl } from './opencode/upstream' @@ -10,6 +11,8 @@ type SSEClientCallback = (event: string, data: string) => void type SSEClientFrameWriter = (frame: Uint8Array) => void type SSEEventListener = (directory: string | null, event: SSEEvent) => void +export type RemoteReplayHandler = (clientId: string, serverId: string, directory: string) => void | Promise + interface SSEClient { id: string callback: SSEClientCallback @@ -40,6 +43,10 @@ function serializeEnvelope(directory: string | null, payloadJson: string): strin return '{"directory":' + JSON.stringify(directory) + ',"payload":' + payloadJson + '}' } +function serializeRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): string { + return '{"directory":' + JSON.stringify(directory) + ',"serverId":' + JSON.stringify(serverId) + ',"payload":' + payloadJson + '}' +} + class SSEAggregator { private static instance: SSEAggregator private clients: Map = new Map() @@ -58,6 +65,7 @@ class SSEAggregator { private pendingActionsFetcher: PendingActionsFetcher | null = null private passwordResolver: OpenCodePasswordResolver | null = null private scheduledSessionsResolver: (() => ScheduledSessionRef[]) | null = null + private remoteReplayHandler: RemoteReplayHandler | null = null private replayEventCount = 0 private constructor() {} @@ -74,6 +82,10 @@ class SSEAggregator { this.scheduledSessionsResolver = resolver } + setRemoteReplayHandler(handler: RemoteReplayHandler | null): void { + this.remoteReplayHandler = handler + } + reconnect(): void { if (!this.started) return logger.info('SSE forcing upstream reconnect (auth changed)') @@ -118,7 +130,7 @@ class SSEAggregator { logger.info(`Client ${id} connected with directories: ${directories.length > 0 ? directories.join(', ') : '(none)'}`) if (directories.length > 0) { - void this.replayPendingActionsForClient(id, directories) + this.replayForDirectories(id, directories) } return () => this.removeClient(id) @@ -127,7 +139,10 @@ class SSEAggregator { removeClient(id: string): void { const client = this.clients.get(id) if (client) { - client.directories.forEach(dir => this.deindexClientDirectory(id, dir)) + for (const directory of Array.from(client.directories)) { + client.directories.delete(directory) + this.deindexClientDirectory(id, directory) + } } this.clients.delete(id) } @@ -149,7 +164,7 @@ class SSEAggregator { logger.info(`Client ${clientId} subscribed to: ${directories.join(', ')}`) if (newDirectories.length > 0) { - void this.replayPendingActionsForClient(clientId, newDirectories) + this.replayForDirectories(clientId, newDirectories) } return true @@ -185,6 +200,44 @@ class SSEAggregator { if (set.size === 0) this.directoryClients.delete(directory) } + private replayForDirectories(clientId: string, directories: string[]): void { + const localDirectories: string[] = [] + const remoteDirectories: Array<{ serverId: string; directory: string }> = [] + + for (const directory of directories) { + const parsed = parseServerDirectoryKey(directory) + if (parsed.serverId === LOCAL_OPENCODE_SERVER_ID) { + localDirectories.push(directory) + } else { + remoteDirectories.push(parsed) + } + } + + if (localDirectories.length > 0) { + void this.replayPendingActionsForClient(clientId, localDirectories) + } + + const handler = this.remoteReplayHandler + if (!handler) return + + for (const { serverId, directory } of remoteDirectories) { + void this.replayRemoteDirectory(clientId, serverId, directory, handler) + } + } + + private async replayRemoteDirectory( + clientId: string, + serverId: string, + directory: string, + handler: RemoteReplayHandler, + ): Promise { + try { + await handler(clientId, serverId, directory) + } catch (error) { + logger.warn(`remote replay: failed for ${buildServerDirectoryKey(serverId, directory)}: ${String(error)}`) + } + } + private async replayPendingActionsForClient(clientId: string, directories: string[]): Promise { const fetcher = this.pendingActionsFetcher if (!fetcher) return @@ -494,6 +547,62 @@ class SSEAggregator { } } + hasClientDirectory(clientId: string, key: string): boolean { + return this.clients.get(clientId)?.directories.has(key) ?? false + } + + deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void { + const clientIds = directory === null + ? this.clientIdsForServer(serverId) + : Array.from(this.directoryClients.get(buildServerDirectoryKey(serverId, directory)) ?? []) + if (clientIds.length === 0) return + const frame = encodeSSEFrame('message', serializeRemoteEnvelope(serverId, directory, payloadJson)) + for (const clientId of clientIds) { + const client = this.clients.get(clientId) + if (!client) continue + try { + client.writeFrame(frame) + } catch (error) { + logger.error(`Failed to send remote event to client ${client.id}:`, error) + } + } + } + + deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void { + if (!this.hasClientDirectory(clientId, buildServerDirectoryKey(serverId, directory))) return + const client = this.clients.get(clientId) + if (!client) return + const frame = encodeSSEFrame('message', serializeRemoteEnvelope(serverId, directory, payloadJson)) + try { + client.writeFrame(frame) + } catch (error) { + logger.error(`Failed to send remote event to client ${client.id}:`, error) + } + } + + sendRemoteResync(serverId: string, data: string): void { + for (const clientId of this.clientIdsForServer(serverId)) { + const client = this.clients.get(clientId) + if (!client) continue + try { + client.callback('resync', data) + } catch { /* ignore resync errors */ } + } + } + + private clientIdsForServer(serverId: string): string[] { + const clientIds: string[] = [] + for (const client of this.clients.values()) { + for (const key of client.directories) { + if (parseServerDirectoryKey(key).serverId === serverId) { + clientIds.push(client.id) + break + } + } + } + return clientIds + } + private handleEvent(directory: string, event: SSEEvent): void { switch (event.type) { case 'session.status': { @@ -652,6 +761,7 @@ class SSEAggregator { this.clients.clear() this.eventListeners.clear() this.upstreamConnectedListeners.clear() + this.remoteReplayHandler = null } broadcastToAll(event: string, data: string): void { diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index a2b120cee..2b4fa3cdb 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -126,6 +126,7 @@ const sseAggregatorMock = vi.hoisted(() => ({ setPendingActionsFetcher: vi.fn(), setPasswordResolver: vi.fn(), setScheduledSessionsResolver: vi.fn(), + setRemoteReplayHandler: vi.fn(), start: vi.fn(), shutdown: vi.fn(), reconnect: vi.fn(), diff --git a/backend/test/routes/opencode-server-proxy.test.ts b/backend/test/routes/opencode-server-proxy.test.ts new file mode 100644 index 000000000..d008aa7b9 --- /dev/null +++ b/backend/test/routes/opencode-server-proxy.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' +import type { MiddlewareHandler } from 'hono' +import { createOpenCodeServerProxyRoutes } from '../../src/routes/opencode-server-proxy' +import { + OpenCodeServerError, + OpenCodeServerNotFoundError, + type OpenCodeServerRegistry, +} from '../../src/services/opencode-server-registry' + +const getConnectionMock = vi.hoisted(() => vi.fn()) + +const registry = { getConnection: getConnectionMock } as unknown as OpenCodeServerRegistry + +const basicAuth = `Basic ${Buffer.from('opencode:secret').toString('base64')}` + +const passThroughAuth: MiddlewareHandler = async (_c, next) => { + await next() +} + +function buildApp( + fetchFn: typeof fetch, + requireAuth: MiddlewareHandler = passThroughAuth, +): Hono { + const app = new Hono() + app.route('/api/opencode/servers', createOpenCodeServerProxyRoutes(registry, requireAuth, fetchFn)) + return app +} + +function createFetchMock(response: Response = new Response('ok', { status: 200 })) { + return vi.fn().mockResolvedValue(response) +} + +function readBody(body: RequestInit['body']): Promise { + return new Response(body as unknown as string).text() +} + +describe('opencode server proxy routes', () => { + beforeEach(() => { + vi.clearAllMocks() + getConnectionMock.mockReturnValue({ baseUrl: 'https://remote.example:4096', authorization: basicAuth }) + }) + + it('forwards method, path, query, and streamed body to the upstream with registry credentials', async () => { + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + const payload = JSON.stringify({ parts: [{ type: 'text', text: 'hi' }] }) + + const res = await app.request('/api/opencode/servers/srv_1/api/session/ses_1/message?directory=%2Fwork', { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: 'session=cookie-value', + host: 'manager.local', + authorization: 'Bearer client-token', + origin: 'https://manager.local', + referer: 'https://manager.local/chat', + }, + body: payload, + }) + + expect(res.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://remote.example:4096/api/session/ses_1/message?directory=%2Fwork') + expect(init.method).toBe('POST') + expect(init.redirect).toBe('manual') + expect(await readBody(init.body)).toBe(payload) + + const headers = init.headers as Record + expect(headers.Authorization).toBe(basicAuth) + expect(headers['content-type']).toBe('application/json') + const headerNames = Object.keys(headers).map((name) => name.toLowerCase()) + expect(headerNames).not.toContain('cookie') + expect(headerNames).not.toContain('host') + expect(headerNames).not.toContain('origin') + expect(headerNames).not.toContain('referer') + expect(headerNames).not.toContain('x-opencode-directory') + expect(headerNames.filter((name) => name === 'authorization')).toHaveLength(1) + }) + + it('does not add a default directory header on GET requests', async () => { + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + await app.request('/api/opencode/servers/srv_1/api/session', { method: 'GET' }) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Record + expect(Object.keys(headers).map((name) => name.toLowerCase())).not.toContain('x-opencode-directory') + expect(init.body).toBeUndefined() + }) + + it('returns 404 for an unknown server', async () => { + getConnectionMock.mockImplementation(() => { + throw new OpenCodeServerNotFoundError('srv_missing') + }) + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_missing/api/session') + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'OpenCode server not found: srv_missing' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 404 for a disabled server', async () => { + getConnectionMock.mockImplementation(() => { + throw new OpenCodeServerNotFoundError('srv_off', 'disabled') + }) + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_off/api/session') + + expect(res.status).toBe(404) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 400 for the local server', async () => { + getConnectionMock.mockImplementation(() => { + throw new OpenCodeServerError('The local OpenCode server has no remote connection', 400) + }) + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/local/api/session') + + expect(res.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 400 for encoded traversal segments', async () => { + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/api/session%2F..%2Fsecrets') + + expect(res.status).toBe(400) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 404 for upstream paths outside /api', async () => { + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/config') + + expect(res.status).toBe(404) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 502 when the upstream fetch fails', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')) + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/api/session') + + expect(res.status).toBe(502) + expect(await res.json()).toEqual({ error: 'Remote OpenCode server unreachable' }) + }) + + it('streams the upstream response body and drops set-cookie', async () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: one\n\n')) + controller.enqueue(encoder.encode('data: two\n\n')) + controller.close() + }, + }) + const fetchMock = createFetchMock(new Response(stream, { + status: 200, + headers: { 'content-type': 'text/event-stream', 'set-cookie': 'leak=1' }, + })) + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/api/event') + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/event-stream') + expect(res.headers.get('set-cookie')).toBeNull() + expect(await res.text()).toBe('data: one\n\ndata: two\n\n') + }) + + it('returns a null body for bodyless upstream statuses', async () => { + const fetchMock = createFetchMock(new Response(null, { status: 204 })) + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/api/session/ses_1', { method: 'DELETE' }) + + expect(res.status).toBe(204) + expect(await res.text()).toBe('') + }) + + it('rejects unauthenticated requests before reaching the upstream', async () => { + const fetchMock = createFetchMock() + const denyAuth: MiddlewareHandler = async (c) => c.json({ error: 'Unauthorized' }, 401) + const app = buildApp(fetchMock as unknown as typeof fetch, denyAuth) + + const res = await app.request('/api/opencode/servers/srv_1/api/session') + + expect(res.status).toBe(401) + expect(fetchMock).not.toHaveBeenCalled() + expect(getConnectionMock).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/routes/opencode-servers.test.ts b/backend/test/routes/opencode-servers.test.ts new file mode 100644 index 000000000..2fa434bb3 --- /dev/null +++ b/backend/test/routes/opencode-servers.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenCodeServerRoutes } from '../../src/routes/opencode-servers' +import { + OpenCodeServerNotFoundError, + OpenCodeServerPairingError, + type OpenCodeServerRegistry, +} from '../../src/services/opencode-server-registry' + +const SERVER = { + id: 'srv_1', + name: 'Studio', + baseUrl: 'https://studio.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 1, +} + +const TEST_RESULT = { ok: true, version: '2.0.15', versionSupported: true, error: null } + +describe('opencode server routes', () => { + const registry = { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + test: vi.fn(), + } + let app: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + registry.list.mockReturnValue([]) + registry.get.mockReturnValue(null) + app = createOpenCodeServerRoutes(registry as unknown as OpenCodeServerRegistry) + }) + + it('lists servers', async () => { + registry.list.mockReturnValue([SERVER]) + + const response = await app.request('/') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ servers: [SERVER] }) + }) + + it('returns 404 for an unknown server id', async () => { + const response = await app.request('/srv_missing') + + expect(response.status).toBe(404) + }) + + it('returns the server for a known id', async () => { + registry.get.mockReturnValue(SERVER) + + const response = await app.request('/srv_1') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ server: SERVER }) + }) + + it('returns 400 for an invalid create body', async () => { + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Studio' }), + }) + + expect(response.status).toBe(400) + expect(registry.create).not.toHaveBeenCalled() + }) + + it('returns 400 for an invalid draft test body', async () => { + const response = await app.request('/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseUrl: 'ftp://studio.example.com' }), + }) + + expect(response.status).toBe(400) + }) + + it('returns 400 for an invalid update body', async () => { + const response = await app.request('/srv_1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '' }), + }) + + expect(response.status).toBe(400) + expect(registry.update).not.toHaveBeenCalled() + }) + + it('creates a server and returns its test result', async () => { + registry.create.mockResolvedValue(SERVER) + registry.test.mockResolvedValue(TEST_RESULT) + + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Studio', baseUrl: 'https://studio.example.com', password: 'super-secret' }), + }) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ server: SERVER, test: TEST_RESULT }) + expect(registry.test).toHaveBeenCalledWith({ serverId: 'srv_1' }) + }) + + it('returns 502 when pairing fails', async () => { + registry.create.mockRejectedValue(new OpenCodeServerPairingError('Pairing was rejected with status 403')) + + const response = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + pairing: { pairUrl: 'https://studio.example.com/pair', code: '0123456789abcdef' }, + }), + }) + + expect(response.status).toBe(502) + }) + + it('updates a server', async () => { + registry.update.mockResolvedValue({ ...SERVER, name: 'Renamed' }) + + const response = await app.request('/srv_1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Renamed' }), + }) + + expect(response.status).toBe(200) + expect(registry.update).toHaveBeenCalledWith('srv_1', { name: 'Renamed' }) + }) + + it('returns 404 when updating an unknown server', async () => { + registry.update.mockRejectedValue(new OpenCodeServerNotFoundError('srv_missing')) + + const response = await app.request('/srv_missing', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Renamed' }), + }) + + expect(response.status).toBe(404) + }) + + it('deletes a server', async () => { + const response = await app.request('/srv_1', { method: 'DELETE' }) + + expect(response.status).toBe(204) + expect(registry.delete).toHaveBeenCalledWith('srv_1') + }) + + it('returns 404 when deleting an unknown server', async () => { + registry.delete.mockImplementation(() => { + throw new OpenCodeServerNotFoundError('srv_missing') + }) + + const response = await app.request('/srv_missing', { method: 'DELETE' }) + + expect(response.status).toBe(404) + }) + + it('tests a stored server', async () => { + registry.test.mockResolvedValue(TEST_RESULT) + + const response = await app.request('/srv_1/test', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(TEST_RESULT) + expect(registry.test).toHaveBeenCalledWith({ serverId: 'srv_1' }) + }) + + it('tests a draft server', async () => { + registry.test.mockResolvedValue(TEST_RESULT) + + const response = await app.request('/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseUrl: 'https://studio.example.com' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(TEST_RESULT) + expect(registry.test).toHaveBeenCalledWith({ baseUrl: 'https://studio.example.com', username: 'opencode' }) + }) + + it('returns the remote event statuses from the provider', async () => { + const statuses = [{ serverId: 'srv_1', connected: true, version: '2.0.15', error: null }] + const statusApp = createOpenCodeServerRoutes( + registry as unknown as OpenCodeServerRegistry, + () => statuses, + ) + + const response = await statusApp.request('/status') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ statuses }) + }) + + it('returns an empty status list when no provider is configured', async () => { + const response = await app.request('/status') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ statuses: [] }) + expect(registry.get).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts index b6ad20704..ed5f7dd4c 100644 --- a/backend/test/routes/sse.test.ts +++ b/backend/test/routes/sse.test.ts @@ -121,6 +121,25 @@ describe('SSE Routes', () => { ) expect(cleanup).toHaveBeenCalledTimes(1) }) + + it('registers remote server directory keys unchanged', async () => { + const directories = ['/local', 'srv_a|/remote', 'srv_b|/other'] + const res = await app.fetch(new Request(`http://localhost/stream?directories=${encodeURIComponent(directories.join(','))}`)) + const reader = res.body!.getReader() + try { + const first = await readChunk(reader) + expect(first).toContain(JSON.stringify(directories)) + } finally { + await reader.cancel() + } + + expect(sseMocks.addClient).toHaveBeenCalledWith( + expect.stringMatching(/^client_/), + expect.any(Function), + expect.any(Function), + directories, + ) + }) }) describe('POST /subscribe', () => { diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts new file mode 100644 index 000000000..41fcaa8d8 --- /dev/null +++ b/backend/test/services/remote-server-events.test.ts @@ -0,0 +1,733 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import type { OpenCodeServer } from '@opencode-manager/shared/schemas' +import { sseAggregator } from '../../src/services/sse-aggregator' +import { + RemoteServerEventHub, + type RemoteEventSource, + type RemoteEventSourceFactory, + type RemoteEventSourceInit, +} from '../../src/services/remote-server-events' +import type { + OpenCodeServerChange, + OpenCodeServerConnection, + OpenCodeServerRegistry, +} from '../../src/services/opencode-server-registry' + +const SERVER_A: OpenCodeServer = { + id: 'srv_a', + name: 'Studio', + baseUrl: 'https://studio.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 1, +} + +const SERVER_B: OpenCodeServer = { + id: 'srv_b', + name: 'Laptop', + baseUrl: 'https://laptop.example.com', + username: 'opencode', + enabled: true, + hasPassword: false, + createdAt: 1, + updatedAt: 1, +} + +class FakeEventSource implements RemoteEventSource { + onopen: ((event: unknown) => void) | null = null + onerror: ((event: unknown) => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + closed = false + + constructor( + readonly url: string, + readonly init?: RemoteEventSourceInit, + ) {} + + close(): void { + this.closed = true + } + + open(): void { + this.onopen?.({}) + } + + fail(event: unknown = { message: 'connection lost' }): void { + this.onerror?.(event) + } + + message(data: string): void { + this.onmessage?.({ data }) + } +} + +interface CapturedClient { + events: Array<{ event: string; data: string }> + frames: string[] + callback: (event: string, data: string) => void + writeFrame: (frame: Uint8Array) => void +} + +function createCapturingClient(): CapturedClient { + const events: Array<{ event: string; data: string }> = [] + const frames: string[] = [] + const decoder = new TextDecoder() + return { + events, + frames, + callback: (event, data) => { events.push({ event, data }) }, + writeFrame: (frame) => { frames.push(decoder.decode(frame)) }, + } +} + +function parseFrame(frame: string): { + directory: string | null + serverId?: string + payload: { id?: string; type: string; data: Record } +} { + return JSON.parse(frame.replace(/^event: message\ndata: /, '').trim()) +} + +interface ApiFixture { + permissions?: Record + forms?: Record +} + +function makeApi(fixture: ApiFixture = {}): OpenCodeApi { + const directoryOf = (input?: { location?: { directory?: string } }): string => input?.location?.directory ?? '' + + return { + permission: { + request: { + list: vi.fn(async (input?: { location?: { directory?: string } }) => ({ + location: { directory: directoryOf(input) }, + data: fixture.permissions?.[directoryOf(input)] ?? [], + })), + }, + }, + form: { + list: vi.fn(async (input?: { location?: { directory?: string } }) => ({ + location: { directory: directoryOf(input) }, + data: fixture.forms?.[directoryOf(input)] ?? [], + })), + }, + } as unknown as OpenCodeApi +} + +interface FakeRegistry { + registry: OpenCodeServerRegistry + api: OpenCodeApi + emit: (change: OpenCodeServerChange) => void + setServer: (server: OpenCodeServer) => void + removeServer: (serverId: string) => void + listCalls: () => number +} + +function createRegistry(servers: OpenCodeServer[], fixture: ApiFixture = {}): FakeRegistry { + const byId = new Map(servers.map(server => [server.id, server])) + const listeners = new Set<(change: OpenCodeServerChange) => void>() + const api = makeApi(fixture) + let listCalls = 0 + + const registry = { + list: () => { + listCalls += 1 + return Array.from(byId.values()) + }, + get: (serverId: string) => byId.get(serverId) ?? null, + getConnection: (serverId: string): OpenCodeServerConnection => { + const server = byId.get(serverId) + if (!server) throw new Error(`OpenCode server not found: ${serverId}`) + if (!server.enabled) throw new Error(`OpenCode server is disabled: ${serverId}`) + return { + baseUrl: server.baseUrl, + authorization: server.hasPassword ? 'Basic dXNlcjpwYXNz' : null, + } + }, + getClient: () => ({ api }), + onChange: (listener: (change: OpenCodeServerChange) => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + test: vi.fn(async () => ({ ok: true, version: '2.0.15', versionSupported: true, error: null })), + } as unknown as OpenCodeServerRegistry + + return { + registry, + api, + emit: (change) => { listeners.forEach(listener => listener(change)) }, + setServer: (server) => { byId.set(server.id, server) }, + removeServer: (serverId) => { byId.delete(serverId) }, + listCalls: () => listCalls, + } +} + +function createSourceFactory() { + const sources: FakeEventSource[] = [] + const factory: RemoteEventSourceFactory = (url, init) => { + const source = new FakeEventSource(url, init) + sources.push(source) + return source + } + return { + sources, + factory, + latest: () => sources[sources.length - 1]!, + } +} + +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +function remoteEvent(type: string, data: unknown, directory?: string): string { + return JSON.stringify({ + id: 'evt_1', + created: 1, + type, + ...(directory ? { location: { directory } } : {}), + data, + }) +} + +describe('RemoteServerEventHub event delivery', () => { + let hub: RemoteServerEventHub + let fake: FakeRegistry + let sources: ReturnType + + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + fake = createRegistry([SERVER_A, SERVER_B]) + sources = createSourceFactory() + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + }) + + afterEach(() => { + hub.shutdown() + sseAggregator.shutdown() + }) + + it('opens one stream per enabled server with the connection authorization', () => { + hub.start() + + expect(sources.sources.map(source => source.url)).toEqual([ + 'https://studio.example.com/api/event', + 'https://laptop.example.com/api/event', + ]) + expect(sources.sources[0]?.init?.fetch).toBeTypeOf('function') + expect(sources.sources[1]?.init).toBeUndefined() + }) + + it('delivers a located event only to clients subscribed to that server and directory, with a serverId envelope', () => { + const remoteClient = createCapturingClient() + const otherServerClient = createCapturingClient() + const localClient = createCapturingClient() + sseAggregator.addClient('remote-a', remoteClient.callback, remoteClient.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('remote-b', otherServerClient.callback, otherServerClient.writeFrame, ['srv_b|/repo']) + sseAggregator.addClient('local-a', localClient.callback, localClient.writeFrame, ['/repo']) + + const listener = vi.fn() + sseAggregator.onEvent(listener) + + hub.start() + const source = sources.sources[0]! + source.open() + remoteClient.frames.length = 0 + + source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + + expect(remoteClient.frames.map(parseFrame)).toEqual([{ + directory: '/repo', + serverId: 'srv_a', + payload: { + id: 'evt_1', + created: 1, + type: 'session.idle', + location: { directory: '/repo' }, + data: { sessionID: 'ses_1' }, + }, + }]) + expect(otherServerClient.frames).toHaveLength(0) + expect(localClient.frames).toHaveLength(0) + expect(listener).not.toHaveBeenCalled() + expect(sseAggregator.getActiveSessions()).toEqual({}) + }) + + it('resolves a directory-less event through the per-server session map', () => { + const client = createCapturingClient() + sseAggregator.addClient('session-map', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const source = sources.sources[0]! + source.message(remoteEvent('session.created', { sessionID: 'ses_1', parentID: null }, '/repo')) + client.frames.length = 0 + + source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + + expect(client.frames.map(parseFrame)).toEqual([{ + directory: '/repo', + serverId: 'srv_a', + payload: { + id: 'evt_1', + created: 1, + type: 'session.execution.succeeded', + data: { sessionID: 'ses_1' }, + }, + }]) + }) + + it('inherits the parent directory for a forked session and forgets deleted sessions', () => { + const client = createCapturingClient() + sseAggregator.addClient('fork', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const source = sources.sources[0]! + source.message(remoteEvent('session.created', { sessionID: 'ses_parent' }, '/repo')) + source.message(remoteEvent('session.forked', { sessionID: 'ses_fork', parentID: 'ses_parent' })) + client.frames.length = 0 + + source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + expect(client.frames.map(parseFrame).map(frame => frame.directory)).toEqual(['/repo']) + + source.message(remoteEvent('session.deleted', { sessionID: 'ses_fork' })) + client.frames.length = 0 + source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_fork' })) + + expect(client.frames.map(parseFrame)).toEqual([{ + directory: null, + serverId: 'srv_a', + payload: { + id: 'evt_1', + created: 1, + type: 'session.execution.succeeded', + data: { sessionID: 'ses_fork' }, + }, + }]) + }) + + it('falls back to every client holding any key of the server when the directory is unknown', () => { + const subscribedA = createCapturingClient() + const otherDirectoryA = createCapturingClient() + const subscribedB = createCapturingClient() + sseAggregator.addClient('any-a', subscribedA.callback, subscribedA.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('other-a', otherDirectoryA.callback, otherDirectoryA.writeFrame, ['srv_a|/elsewhere']) + sseAggregator.addClient('any-b', subscribedB.callback, subscribedB.writeFrame, ['srv_b|/repo']) + + hub.start() + sources.sources[0]!.message(remoteEvent('server.connected', {})) + + const envelope = { + directory: null, + serverId: 'srv_a', + payload: { id: 'evt_1', created: 1, type: 'server.connected', data: {} }, + } + expect(subscribedA.frames.map(parseFrame)).toEqual([envelope]) + expect(otherDirectoryA.frames.map(parseFrame)).toEqual([envelope]) + expect(subscribedB.frames).toHaveLength(0) + }) + + it('keeps the raw upstream payload in the remote envelope and re-serializes multi-line payloads', () => { + const client = createCapturingClient() + sseAggregator.addClient('raw-remote', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const source = sources.sources[0]! + const raw = '{"type":"session.idle","id":"evt_raw","created":7,"location":{"directory":"/repo"},"data":{"sessionID":"ses_1","extra":{"b":2,"a":1}}}' + source.message(raw) + + expect(client.frames).toEqual([`event: message\ndata: {"directory":"/repo","serverId":"srv_a","payload":${raw}}\n\n`]) + + const multiLine = JSON.stringify(JSON.parse(raw), null, 2) + source.message(multiLine) + + expect(client.frames[1]!.split('\n').filter(line => line.startsWith('data: '))).toHaveLength(1) + expect(parseFrame(client.frames[1]!).payload).toEqual(JSON.parse(raw)) + }) + + it('ignores malformed remote payloads', () => { + const client = createCapturingClient() + sseAggregator.addClient('malformed-remote', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const source = sources.sources[0]! + source.message('not-json') + source.message(JSON.stringify({ id: 'evt', created: 1, data: {} })) + + expect(client.frames).toHaveLength(0) + }) +}) + +describe('RemoteServerEventHub reconnection', () => { + let hub: RemoteServerEventHub + let fake: FakeRegistry + let sources: ReturnType + + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + fake = createRegistry([SERVER_A]) + sources = createSourceFactory() + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { + eventSourceFactory: sources.factory, + reconnectDelayMs: 1000, + maxReconnectDelayMs: 4000, + }) + }) + + afterEach(() => { + hub.shutdown() + sseAggregator.shutdown() + vi.useRealTimers() + }) + + it('sends a resync to the subscribers of the server on connect', () => { + const clientA = createCapturingClient() + const clientB = createCapturingClient() + sseAggregator.addClient('resync-a', clientA.callback, clientA.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('resync-b', clientB.callback, clientB.writeFrame, ['srv_b|/repo']) + + hub.start() + sources.sources[0]!.open() + + const resyncs = clientA.events.filter(event => event.event === 'resync') + expect(resyncs).toHaveLength(1) + expect(JSON.parse(resyncs[0]!.data)).toMatchObject({ timestamp: expect.any(Number) }) + expect(clientB.events.filter(event => event.event === 'resync')).toHaveLength(0) + }) + + it('closes the failed stream and reconnects with exponential backoff', async () => { + vi.useFakeTimers() + hub.start() + const first = sources.sources[0]! + first.open() + await flushPromises() + first.fail({ message: 'Unauthorized', code: 401 }) + + expect(first.closed).toBe(true) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: false, version: '2.0.15', error: 'Unauthorized' }, + ]) + + await vi.advanceTimersByTimeAsync(999) + expect(sources.sources).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1) + expect(sources.sources).toHaveLength(2) + + const second = sources.sources[1]! + second.fail({ message: 'Unauthorized', code: 401 }) + + await vi.advanceTimersByTimeAsync(1000) + expect(sources.sources).toHaveLength(2) + + await vi.advanceTimersByTimeAsync(1000) + expect(sources.sources).toHaveLength(3) + }) + + it('reports the version lazily from the registry without polling', async () => { + hub.start() + sources.sources[0]!.open() + await flushPromises() + sources.sources[0]!.open() + await flushPromises() + + expect(fake.registry.test).toHaveBeenCalledTimes(1) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: true, version: '2.0.15', error: null }, + ]) + }) + + it('closes and forgets the stream when the server is disabled', () => { + const client = createCapturingClient() + sseAggregator.addClient('disable', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const source = sources.sources[0]! + source.open() + client.frames.length = 0 + + fake.setServer({ ...SERVER_A, enabled: false }) + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + + expect(source.closed).toBe(true) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: false, version: null, error: null }, + ]) + + source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) + expect(client.frames).toHaveLength(0) + }) + + it('reconnects with a fresh stream when the server is updated', () => { + hub.start() + const first = sources.sources[0]! + first.open() + + fake.setServer({ ...SERVER_A, baseUrl: 'https://moved.example.com' }) + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + + expect(first.closed).toBe(true) + expect(sources.sources).toHaveLength(2) + expect(sources.sources[1]!.url).toBe('https://moved.example.com/api/event') + }) + + it('closes the stream when the server is deleted', () => { + hub.start() + const source = sources.sources[0]! + source.open() + + fake.removeServer('srv_a') + fake.emit({ type: 'deleted', serverId: 'srv_a' }) + + expect(source.closed).toBe(true) + expect(hub.getStatuses()).toEqual([]) + }) + + it('stops reconnecting and clears the replay hook after shutdown', () => { + hub.start() + const source = sources.sources[0]! + source.open() + hub.shutdown() + + expect(source.closed).toBe(true) + + const client = createCapturingClient() + sseAggregator.addClient('after-shutdown', client.callback, client.writeFrame, ['srv_a|/repo']) + expect(fake.api.permission.request.list).not.toHaveBeenCalled() + }) +}) + +describe('RemoteServerEventHub failure handling', () => { + let hub: RemoteServerEventHub + let fake: FakeRegistry + let sources: ReturnType + + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + fake = createRegistry([SERVER_A]) + sources = createSourceFactory() + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { + eventSourceFactory: sources.factory, + reconnectDelayMs: 1000, + maxReconnectDelayMs: 4000, + }) + }) + + afterEach(() => { + hub.shutdown() + sseAggregator.shutdown() + vi.useRealTimers() + }) + + it('reports a credential-free error for every stream failure shape', async () => { + vi.useFakeTimers() + hub.start() + + const first = sources.sources[0]! + first.fail(new Error('boom')) + expect(hub.getStatuses()[0]?.error).toBe('boom') + + await vi.advanceTimersByTimeAsync(1000) + sources.sources[1]!.fail('plain failure') + expect(hub.getStatuses()[0]?.error).toBe('plain failure') + + await vi.advanceTimersByTimeAsync(2000) + sources.sources[2]!.fail({ code: 500 }) + expect(hub.getStatuses()[0]?.error).toBe('event stream closed (code=500)') + + await vi.advanceTimersByTimeAsync(4000) + sources.sources[3]!.fail({}) + expect(hub.getStatuses()[0]?.error).toBe('event stream disconnected') + expect(hub.getStatuses()[0]?.error).not.toContain('Basic') + expect(hub.getStatuses()[0]?.error).not.toContain('studio.example.com') + }) + + it('records a connection failure without credentials and without opening a stream', () => { + ;(fake.registry as unknown as { getConnection: (serverId: string) => never }).getConnection = () => { + throw new Error('OpenCode server is disabled: srv_a') + } + + hub.start() + + expect(sources.sources).toHaveLength(0) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: false, version: null, error: 'OpenCode server is disabled: srv_a' }, + ]) + }) + + it('retries with backoff when the event source cannot be created', async () => { + vi.useFakeTimers() + let attempts = 0 + const factory: RemoteEventSourceFactory = () => { + attempts += 1 + throw new Error('bad event source') + } + hub.shutdown() + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: factory, reconnectDelayMs: 1000 }) + + hub.start() + expect(attempts).toBe(1) + expect(hub.getStatuses()[0]?.error).toBe('bad event source') + + await vi.advanceTimersByTimeAsync(1000) + expect(attempts).toBe(2) + }) + + it('returns no statuses when the registry cannot be listed', () => { + ;(fake.registry as unknown as { list: () => never }).list = () => { + throw new Error('db down') + } + + hub.start() + + expect(hub.getStatuses()).toEqual([]) + }) + + it('forgets the stream when the server cannot be read on change', () => { + hub.start() + const source = sources.sources[0]! + source.open() + + ;(fake.registry as unknown as { get: (serverId: string) => never }).get = () => { + throw new Error('db down') + } + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + + expect(source.closed).toBe(true) + }) + + it('keeps the version null when the version probe rejects', async () => { + vi.mocked(fake.registry.test).mockRejectedValue(new Error('no info endpoint')) + + hub.start() + sources.sources[0]!.open() + await flushPromises() + + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: true, version: null, error: null }, + ]) + }) + + it('keeps the version null when the registry cannot test a server', async () => { + ;(fake.registry as unknown as { test?: unknown }).test = undefined + + hub.start() + sources.sources[0]!.open() + await flushPromises() + + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: true, version: null, error: null }, + ]) + }) +}) + +describe('RemoteServerEventHub pending action replay', () => { + let hub: RemoteServerEventHub + let fake: FakeRegistry + let sources: ReturnType + + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + fake = createRegistry([SERVER_A], { + permissions: { '/repo': [{ id: 'perm-1', sessionID: 'ses-1', action: 'shell', resources: ['ls'] }] }, + forms: { '/repo': [{ id: 'form-1', sessionID: 'ses-1', title: 'Continue?', fields: [] }] }, + }) + sources = createSourceFactory() + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + }) + + afterEach(() => { + hub.shutdown() + sseAggregator.shutdown() + }) + + it('replays pending remote actions to the subscribing client only', async () => { + hub.start() + const client = createCapturingClient() + const other = createCapturingClient() + sseAggregator.addClient('replay-a', client.callback, client.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('replay-b', other.callback, other.writeFrame, ['srv_a|/other']) + + await flushPromises() + + expect(fake.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + expect(fake.api.form.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + + const parsed = client.frames.map(parseFrame) + expect(parsed.map(frame => frame.serverId)).toEqual(['srv_a', 'srv_a']) + expect(parsed.map(frame => frame.directory)).toEqual(['/repo', '/repo']) + expect(parsed.map(frame => frame.payload.type)).toEqual(['permission.asked', 'form.created']) + expect(parsed[0]?.payload.id).toMatch(/^ocm_replay_remote_/) + expect(parsed[1]?.payload.data).toEqual({ + form: { id: 'form-1', sessionID: 'ses-1', title: 'Continue?', fields: [] }, + }) + expect(other.frames).toHaveLength(0) + }) + + it('does not use the local pending action fetcher for remote keys', async () => { + const localApi = makeApi({ permissions: { '/repo': [{ id: 'local-perm' }] } }) + sseAggregator.setPendingActionsFetcher({ api: localApi }) + + hub.start() + const client = createCapturingClient() + sseAggregator.addClient('mixed', client.callback, client.writeFrame, ['/repo', 'srv_a|/repo']) + + await flushPromises() + + expect(localApi.permission.request.list).toHaveBeenCalledTimes(1) + expect(localApi.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + expect(fake.api.permission.request.list).toHaveBeenCalledTimes(1) + expect(fake.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + }) + + it('replays remote actions when a client subscribes later', async () => { + hub.start() + const client = createCapturingClient() + sseAggregator.addClient('late', client.callback, client.writeFrame, []) + + sseAggregator.addDirectories('late', ['srv_a|/repo']) + await flushPromises() + + expect(client.frames.map(parseFrame).map(frame => frame.payload.type)).toEqual(['permission.asked', 'form.created']) + }) + + it('skips the replay when the client unsubscribed before it resolved', async () => { + let resolvePermissions: (value: { location: { directory: string }; data: unknown[] }) => void = () => {} + const list = fake.api.permission.request.list as unknown as (input: { location?: { directory?: string } }) => Promise + vi.mocked(list).mockImplementation(() => new Promise((resolve) => { + resolvePermissions = resolve as (value: { location: { directory: string }; data: unknown[] }) => void + })) + vi.mocked(fake.api.form.list).mockResolvedValue({ location: { directory: '/repo' }, data: [] }) + + hub.start() + const client = createCapturingClient() + sseAggregator.addClient('unsubscribed', client.callback, client.writeFrame, ['srv_a|/repo']) + sseAggregator.removeDirectories('unsubscribed', ['srv_a|/repo']) + + resolvePermissions({ location: { directory: '/repo' }, data: [{ id: 'late', sessionID: 'ses-1' }] }) + await flushPromises() + + expect(client.frames).toHaveLength(0) + }) +}) diff --git a/backend/test/services/sse-aggregator-remote.test.ts b/backend/test/services/sse-aggregator-remote.test.ts new file mode 100644 index 000000000..232b345c5 --- /dev/null +++ b/backend/test/services/sse-aggregator-remote.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } 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 type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' + +interface CapturedClient { + events: Array<{ event: string; data: string }> + frames: string[] + callback: (event: string, data: string) => void + writeFrame: (frame: Uint8Array) => void +} + +function createCapturingClient(): CapturedClient { + const events: Array<{ event: string; data: string }> = [] + const frames: string[] = [] + const decoder = new TextDecoder() + return { + events, + frames, + callback: (event, data) => { events.push({ event, data }) }, + writeFrame: (frame) => { frames.push(decoder.decode(frame)) }, + } +} + +function parseFrame(frame: string): { directory: string | null; serverId?: string; payload: { type: string } } { + return JSON.parse(frame.replace(/^event: message\ndata: /, '').trim()) +} + +function makeFetcher(): PendingActionsFetcher { + const api = { + permission: { + request: { + list: vi.fn(async (input?: { location?: { directory?: string } }) => ({ + location: { directory: input?.location?.directory ?? '' }, + data: [{ id: 'perm-1', sessionID: 'ses-1' }], + })), + }, + }, + form: { + list: vi.fn(async (input?: { location?: { directory?: string } }) => ({ + location: { directory: input?.location?.directory ?? '' }, + data: [], + })), + }, + } as unknown as OpenCodeApi + return { api } +} + +async function flushReplay(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +const PAYLOAD = '{"id":"evt_1","created":1,"type":"session.idle","location":{"directory":"/repo"},"data":{"sessionID":"ses_1"}}' + +describe('SSEAggregator remote envelope delivery', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('delivers a remote envelope only to clients subscribed to that server and directory', () => { + const subscribed = createCapturingClient() + const otherDirectory = createCapturingClient() + const local = createCapturingClient() + sseAggregator.addClient('remote-key', subscribed.callback, subscribed.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('other-dir', otherDirectory.callback, otherDirectory.writeFrame, ['srv_a|/other']) + sseAggregator.addClient('local-dir', local.callback, local.writeFrame, ['/repo']) + + sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', PAYLOAD) + + expect(subscribed.frames).toEqual([ + `event: message\ndata: {"directory":"/repo","serverId":"srv_a","payload":${PAYLOAD}}\n\n`, + ]) + expect(otherDirectory.frames).toHaveLength(0) + expect(local.frames).toHaveLength(0) + }) + + it('broadcasts a directory-less remote envelope to every client holding any key of that server', () => { + const first = createCapturingClient() + const second = createCapturingClient() + const otherServer = createCapturingClient() + const local = createCapturingClient() + sseAggregator.addClient('any-a', first.callback, first.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('any-b', second.callback, second.writeFrame, ['srv_a|/elsewhere']) + sseAggregator.addClient('any-c', otherServer.callback, otherServer.writeFrame, ['srv_b|/repo']) + sseAggregator.addClient('any-d', local.callback, local.writeFrame, ['/repo']) + + sseAggregator.deliverRemoteEnvelope('srv_a', null, PAYLOAD) + + const expected = { directory: null, serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } + expect(first.frames.map(parseFrame)).toEqual([expected]) + expect(second.frames.map(parseFrame)).toEqual([expected]) + expect(otherServer.frames).toHaveLength(0) + expect(local.frames).toHaveLength(0) + }) + + it('skips a targeted remote envelope once the client unsubscribed', () => { + const client = createCapturingClient() + sseAggregator.addClient('targeted', client.callback, client.writeFrame, ['srv_a|/repo']) + sseAggregator.removeDirectories('targeted', ['srv_a|/repo']) + + sseAggregator.deliverRemoteEnvelopeToClient('targeted', 'srv_a', '/repo', PAYLOAD) + + expect(client.frames).toHaveLength(0) + }) + + it('delivers a targeted remote envelope to a subscribed client', () => { + const client = createCapturingClient() + sseAggregator.addClient('targeted-ok', client.callback, client.writeFrame, ['srv_a|/repo']) + + sseAggregator.deliverRemoteEnvelopeToClient('targeted-ok', 'srv_a', '/repo', PAYLOAD) + + expect(client.frames.map(parseFrame)).toEqual([ + { directory: '/repo', serverId: 'srv_a', payload: JSON.parse(PAYLOAD) }, + ]) + }) + + it('sends a resync only to the subscribers of that server', () => { + const subscribed = createCapturingClient() + const otherServer = createCapturingClient() + const local = createCapturingClient() + sseAggregator.addClient('resync-a', subscribed.callback, subscribed.writeFrame, ['srv_a|/repo']) + sseAggregator.addClient('resync-b', otherServer.callback, otherServer.writeFrame, ['srv_b|/repo']) + sseAggregator.addClient('resync-c', local.callback, local.writeFrame, ['/repo']) + + sseAggregator.sendRemoteResync('srv_a', '{"timestamp":1}') + + expect(subscribed.events).toEqual([{ event: 'resync', data: '{"timestamp":1}' }]) + expect(otherServer.events).toHaveLength(0) + expect(local.events).toHaveLength(0) + expect(subscribed.frames).toHaveLength(0) + }) + + it('keeps the local envelope free of a serverId', () => { + const client = createCapturingClient() + sseAggregator.addClient('local-envelope', client.callback, client.writeFrame, ['/repo']) + + ;(sseAggregator as unknown as { handleUpstreamMessage(data: string): void }).handleUpstreamMessage(PAYLOAD) + + expect(client.frames).toEqual([`event: message\ndata: {"directory":"/repo","payload":${PAYLOAD}}\n\n`]) + expect(client.frames[0]).not.toContain('serverId') + expect(parseFrame(client.frames[0]!).serverId).toBeUndefined() + }) + + it('routes remote subscriptions to the remote replay hook instead of the local fetcher', async () => { + const fetcher = makeFetcher() + sseAggregator.setPendingActionsFetcher(fetcher) + const remoteReplay = vi.fn(async () => {}) + + sseAggregator.addClient('mixed', () => {}, () => {}, ['/repo', 'srv_a|/repo']) + sseAggregator.setRemoteReplayHandler(remoteReplay) + sseAggregator.addDirectories('mixed', ['srv_a|/new']) + + await flushReplay() + + expect(remoteReplay).toHaveBeenCalledTimes(1) + expect(remoteReplay).toHaveBeenCalledWith('mixed', 'srv_a', '/new') + expect(fetcher.api.permission.request.list).toHaveBeenCalledTimes(1) + expect(fetcher.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + }) + + it('does not replay remote keys through the local fetcher on connect', async () => { + const fetcher = makeFetcher() + sseAggregator.setPendingActionsFetcher(fetcher) + const remoteReplay = vi.fn() + sseAggregator.setRemoteReplayHandler(remoteReplay) + + sseAggregator.addClient('remote-only', () => {}, () => {}, ['srv_a|/repo']) + await flushReplay() + + expect(fetcher.api.permission.request.list).not.toHaveBeenCalled() + expect(fetcher.api.form.list).not.toHaveBeenCalled() + expect(remoteReplay).toHaveBeenCalledWith('remote-only', 'srv_a', '/repo') + }) + + it('isolates a failing remote replay hook', async () => { + const healthy = vi.fn() + sseAggregator.setRemoteReplayHandler(() => { throw new Error('boom') }) + sseAggregator.addClient('failing', () => {}, () => {}, ['srv_a|/repo']) + await flushReplay() + + sseAggregator.setRemoteReplayHandler(healthy) + sseAggregator.addDirectories('failing', ['srv_a|/other']) + await flushReplay() + + expect(healthy).toHaveBeenCalledWith('failing', 'srv_a', '/other') + }) + + it('stops broadcasting remote envelopes after a client is removed', () => { + const client = createCapturingClient() + sseAggregator.addClient('removed', client.callback, client.writeFrame, ['srv_a|/repo']) + sseAggregator.removeClient('removed') + + sseAggregator.deliverRemoteEnvelope('srv_a', null, PAYLOAD) + + expect(client.frames).toHaveLength(0) + }) +}) diff --git a/frontend/package.json b/frontend/package.json index 82e3223eb..8dad5385b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,6 +38,7 @@ "cronstrue": "^3.13.0", "date-fns": "^4.1.0", "diff": "^8.0.2", + "jsqr": "^1.4.0", "lucide-react": "^0.546.0", "mermaid": "^11.12.2", "react": "^19.1.1", diff --git a/frontend/src/App.permission-dialog.test.tsx b/frontend/src/App.permission-dialog.test.tsx new file mode 100644 index 000000000..df89ba600 --- /dev/null +++ b/frontend/src/App.permission-dialog.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PermissionRequest } from '@opencode-manager/shared/opencode' + +const mocks = vi.hoisted(() => ({ + usePermissions: vi.fn(), + getRepoIdForSession: vi.fn(), + useSession: vi.fn(), + navigateToCurrent: vi.fn(), + PermissionRequestDialog: vi.fn(), +})) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: mocks.usePermissions, + useEventContext: () => ({ getRepoIdForSession: mocks.getRepoIdForSession }), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ useSession: mocks.useSession })) + +vi.mock('@/components/session/PermissionRequestDialog', () => ({ + PermissionRequestDialog: mocks.PermissionRequestDialog, +})) + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createBrowserRouter: vi.fn(() => ({ routes: [] })) } +}) + +import { PermissionDialogWrapper } from './App' + +type PendingPermission = PermissionRequest & { serverId: string } + +const remotePermission: PendingPermission = { + id: 'permission-1', + sessionID: 'ses_remote', + action: 'shell', + resources: ['echo hello'], + metadata: {}, + serverId: 'srv_kitchen', +} + +const localPermission: PendingPermission = { + id: 'permission-2', + sessionID: 'ses_local', + action: 'shell', + resources: ['echo hello'], + metadata: {}, + serverId: 'local', +} + +interface DialogProps { + permission: PermissionRequest | null + onOpenSession?: () => void +} + +function renderWrapper(permission: PendingPermission) { + mocks.usePermissions.mockReturnValue({ + current: permission, + pendingCount: 1, + respond: vi.fn(), + showDialog: true, + setShowDialog: vi.fn(), + navigateToCurrent: mocks.navigateToCurrent, + viewedSessionId: null, + currentDirectory: '/srv/app', + currentIsFromDifferentSession: false, + }) + + render() + + return mocks.PermissionRequestDialog.mock.calls.at(-1)?.[0] as DialogProps +} + +describe('PermissionDialogWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSession.mockReturnValue({ data: undefined }) + mocks.PermissionRequestDialog.mockReturnValue(
) + }) + + it('offers the session for a linked-server permission without a Manager repo lookup', () => { + mocks.getRepoIdForSession.mockReturnValue(null) + + const props = renderWrapper(remotePermission) + + expect(props.permission).toEqual(remotePermission) + expect(props.onOpenSession).toBe(mocks.navigateToCurrent) + expect(mocks.getRepoIdForSession).not.toHaveBeenCalled() + expect(mocks.useSession).toHaveBeenCalledWith(undefined, '/srv/app') + }) + + it('offers the session for a local permission resolved through its Manager repo', () => { + mocks.getRepoIdForSession.mockReturnValue(12) + + const props = renderWrapper(localPermission) + + expect(props.onOpenSession).toBe(mocks.navigateToCurrent) + expect(mocks.getRepoIdForSession).toHaveBeenCalledWith('ses_local') + expect(mocks.useSession).toHaveBeenCalledWith('ses_local', '/srv/app') + }) + + it('hides the session action for a local permission without a Manager repo', () => { + mocks.getRepoIdForSession.mockReturnValue(null) + + const props = renderWrapper(localPermission) + + expect(props.onOpenSession).toBeUndefined() + }) + + it('renders the dialog host', () => { + renderWrapper(remotePermission) + + expect(screen.getByTestId('permission-dialog')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('permission-dialog')) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d1613fdc..f020528b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation } from 'react-router-dom' -import { useEffect, useRef, useCallback } from 'react' +import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation, useParams, Navigate } from 'react-router-dom' +import { useEffect, useRef, useCallback, type ReactNode } from 'react' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' +import { RemoteServer } from './pages/RemoteServer' import { SessionDetail } from './pages/SessionDetail' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' @@ -26,6 +28,7 @@ import { ThemeProvider, useThemeMode } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' +import { OpenCodeServerProvider, isLocalOpenCodeServer } from '@/contexts/OpenCodeServerContext' import { RepoWorktreesProvider } from '@/contexts/RepoWorktreesContext' import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' @@ -98,7 +101,7 @@ function HealthMonitor() { ) } -function PermissionDialogWrapper() { +export function PermissionDialogWrapper() { const { current: currentPermission, pendingCount, @@ -111,10 +114,14 @@ function PermissionDialogWrapper() { currentIsFromDifferentSession, } = usePermissions() const { getRepoIdForSession } = useEventContext() - const { data: session } = useSession(currentPermission?.sessionID, currentDirectory ?? undefined) + const isRemotePermission = currentPermission !== null && !isLocalOpenCodeServer(currentPermission.serverId) + const { data: session } = useSession( + isRemotePermission ? undefined : currentPermission?.sessionID, + currentDirectory ?? undefined, + ) const canOpenCurrentSession = currentPermission !== null - && getRepoIdForSession(currentPermission.sessionID) !== null + && (isRemotePermission || getRepoIdForSession(currentPermission.sessionID) !== null) && (currentIsFromDifferentSession || viewedSessionId === null) return ( @@ -137,6 +144,16 @@ function GlobalShortcuts() { return null } +function ServerScope({ children }: { children: ReactNode }) { + const { serverId } = useParams<{ serverId: string }>() + + if (!serverId || serverId === LOCAL_OPENCODE_SERVER_ID) { + return + } + + return {children} +} + function AppShell() { const navigate = useNavigate() const location = useLocation() @@ -292,6 +309,24 @@ const router = createBrowserRouter([ element: , loader: protectedLoader, }, + { + path: '/servers/:serverId', + element: ( + + + + ), + loader: protectedLoader, + }, + { + path: '/servers/:serverId/sessions/:sessionId', + element: ( + + + + ), + loader: protectedLoader, + }, ], }, ]) diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts index 318475e6f..10bd1a643 100644 --- a/frontend/src/api/mcp.ts +++ b/frontend/src/api/mcp.ts @@ -22,27 +22,33 @@ type McpOAuthFlowStatus = | { status: 'unknown' } export const mcpApi = { - async getStatus(directory?: string): Promise { - const { data } = await callOpenCode((api) => api.mcp.list(openCodeLocation(directory))) + async getStatus(directory?: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.mcp.list(openCodeLocation(directory)), serverId) return mcpStatusByName(data) }, - async removeServer(name: string): Promise { + async removeServer(name: string, serverId?: string): Promise { await callOpenCode(async (api) => { try { await api.mcp.remove({ server: name }) } catch (error) { if (!isMcpServerNotFoundError(error)) throw error } - }) + }, serverId) }, - async connect(name: string, directory?: string): Promise { - await callOpenCode((api) => api.mcp.connect({ server: name, ...openCodeLocation(directory) })) + async connect(name: string, directory?: string, serverId?: string): Promise { + await callOpenCode( + (api) => api.mcp.connect({ server: name, ...openCodeLocation(directory) }), + serverId, + ) }, - async disconnect(name: string, directory?: string): Promise { - await callOpenCode((api) => api.mcp.disconnect({ server: name, ...openCodeLocation(directory) })) + async disconnect(name: string, directory?: string, serverId?: string): Promise { + await callOpenCode( + (api) => api.mcp.disconnect({ server: name, ...openCodeLocation(directory) }), + serverId, + ) }, async startAuth(name: string, directory?: string): Promise { diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index fd5220b58..84a10121d 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -35,6 +35,8 @@ export type ShellOutputChunk = Awaited> +export type OpenCodeProject = Awaited>[number] + export interface SessionPage { items: SessionInfo[] nextCursor?: string @@ -97,7 +99,7 @@ function buildPromptFields(input: PromptFields) { } } -export async function listSessionPage(input: SessionPageInput): Promise { +export async function listSessionPage(input: SessionPageInput, serverId?: string): Promise { const { data, cursor } = await callOpenCode((api) => api.session.list({ ...(input.directory === undefined ? {} : { directory: input.directory }), @@ -109,104 +111,130 @@ export async function listSessionPage(input: SessionPageInput): Promise { - return callOpenCode((api) => api.location.get(openCodeLocation(directory))) +export async function getOpenCodeLocation(directory: string, serverId?: string): Promise { + return callOpenCode((api) => api.location.get(openCodeLocation(directory)), serverId) +} + +export async function listProjects(serverId?: string): Promise { + return callOpenCode((api) => api.project.list(), serverId) } -export async function getSession(sessionID: string): Promise { - return callOpenCode((api) => api.session.get({ sessionID })) +export async function getSession(sessionID: string, serverId?: string): Promise { + return callOpenCode((api) => api.session.get({ sessionID }), serverId) } -export async function createSession(input: CreateSessionInput): Promise { +export async function createSession(input: CreateSessionInput, serverId?: string): Promise { const model = input.model ? parseOpenCodeModelRef(input.model) : undefined - return callOpenCode((api) => - api.session.create({ - ...(input.title !== undefined ? { title: input.title } : {}), - ...(input.agent !== undefined ? { agent: input.agent } : {}), - ...(model ? { model } : {}), - location: { directory: input.directory }, - }), + return callOpenCode( + (api) => + api.session.create({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(model ? { model } : {}), + location: { directory: input.directory }, + }), + serverId, ) } -export async function deleteSession(sessionID: string): Promise { - await callOpenCode((api) => api.session.remove({ sessionID })) +export async function deleteSession(sessionID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.remove({ sessionID }), serverId) } -export async function renameSession(sessionID: string, title: string): Promise { - await callOpenCode((api) => api.session.update({ sessionID, title })) +export async function renameSession(sessionID: string, title: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.update({ sessionID, title }), serverId) } -export async function forkSession(sessionID: string, before?: string): Promise { - return callOpenCode((api) => - api.session.fork({ - sessionID, - ...(before ? { before } : {}), - }), +export async function forkSession(sessionID: string, before?: string, serverId?: string): Promise { + return callOpenCode( + (api) => + api.session.fork({ + sessionID, + ...(before ? { before } : {}), + }), + serverId, ) } -export async function addSessionContext(sessionID: string, text: string): Promise { - await callOpenCode((api) => api.session.synthetic({ sessionID, text, resume: false })) +export async function addSessionContext(sessionID: string, text: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.synthetic({ sessionID, text, resume: false }), serverId) } async function createSessionThen( input: CreateSessionInput, step: (session: SessionInfo) => Promise, + serverId?: string, ): Promise { - const session = await createSession(input) + const session = await createSession(input, serverId) try { await step(session) } catch (error) { - await deleteSession(session.id).catch(() => undefined) + await deleteSession(session.id, serverId).catch(() => undefined) throw error } return session } -export async function createSessionWithContext(input: CreateSessionInput, context: string): Promise { - return createSessionThen(input, (session) => addSessionContext(session.id, context)) +export async function createSessionWithContext( + input: CreateSessionInput, + context: string, + serverId?: string, +): Promise { + return createSessionThen(input, (session) => addSessionContext(session.id, context, serverId), serverId) } -export async function createSessionWithPrompt(input: CreateSessionInput, text: string): Promise { - return createSessionThen(input, (session) => sendPrompt({ sessionID: session.id, text })) +export async function createSessionWithPrompt( + input: CreateSessionInput, + text: string, + serverId?: string, +): Promise { + return createSessionThen( + input, + (session) => sendPrompt({ sessionID: session.id, text }, serverId), + serverId, + ) } -export async function switchSessionModel(sessionID: string, model: ModelRef): Promise { - await callOpenCode((api) => api.session.switchModel({ sessionID, model })) +export async function switchSessionModel(sessionID: string, model: ModelRef, serverId?: string): Promise { + await callOpenCode((api) => api.session.switchModel({ sessionID, model }), serverId) } -export async function switchSessionAgent(sessionID: string, agent: string): Promise { - await callOpenCode((api) => api.session.switchAgent({ sessionID, agent })) +export async function switchSessionAgent(sessionID: string, agent: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.switchAgent({ sessionID, agent }), serverId) } /** Sends a session prompt with client metadata derived from the current viewport. */ -export async function sendPrompt(input: SendPromptInput): Promise { - return callOpenCode((api) => - api.session.prompt({ - sessionID: input.sessionID, - ...buildPromptFields(input), - metadata: { [MANAGER_PROMPT_CLIENT_METADATA_KEY]: isMobileViewport() ? 'mobile' : 'desktop' }, - }), +export async function sendPrompt(input: SendPromptInput, serverId?: string): Promise { + return callOpenCode( + (api) => + api.session.prompt({ + sessionID: input.sessionID, + ...buildPromptFields(input), + metadata: { [MANAGER_PROMPT_CLIENT_METADATA_KEY]: isMobileViewport() ? 'mobile' : 'desktop' }, + }), + serverId, ) } -export async function runCommand(input: RunCommandInput): Promise { - await callOpenCode((api) => - api.session.command({ - sessionID: input.sessionID, - name: input.name, - ...buildPromptFields(input), - }), +export async function runCommand(input: RunCommandInput, serverId?: string): Promise { + await callOpenCode( + (api) => + api.session.command({ + sessionID: input.sessionID, + name: input.name, + ...buildPromptFields(input), + }), + serverId, ) } -export async function runShell(sessionID: string, command: string): Promise { - await callOpenCode((api) => api.session.shell({ sessionID, command })) +export async function runShell(sessionID: string, command: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.shell({ sessionID, command }), serverId) } export const SIDE_QUESTION_INSTRUCTIONS = @@ -216,26 +244,29 @@ export async function askSideQuestion( sessionID: string, question: string, signal?: AbortSignal, + serverId?: string, ): Promise { - const result = await callOpenCode((api) => - api.session.generate( - { sessionID, prompt: [SIDE_QUESTION_INSTRUCTIONS, question].join('\n\n') }, - signal ? { signal } : undefined, - ), + const result = await callOpenCode( + (api) => + api.session.generate( + { sessionID, prompt: [SIDE_QUESTION_INSTRUCTIONS, question].join('\n\n') }, + signal ? { signal } : undefined, + ), + serverId, ) return result.text.trim() } -export async function interruptSession(sessionID: string): Promise { - await callOpenCode((api) => api.session.interrupt({ sessionID })) +export async function interruptSession(sessionID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.interrupt({ sessionID }), serverId) } -export async function backgroundSession(sessionID: string): Promise { - await callOpenCode((api) => api.session.background({ sessionID })) +export async function backgroundSession(sessionID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.background({ sessionID }), serverId) } -export async function listShells(directory: string): Promise { - const { data } = await callOpenCode((api) => api.shell.list(openCodeLocation(directory))) +export async function listShells(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.shell.list(openCodeLocation(directory)), serverId) return data } @@ -244,60 +275,72 @@ export async function readShellOutput( directory: string, cursor: number, limit: number, + serverId?: string, ): Promise { - const { data } = await callOpenCode((api) => - api.shell.output({ id, cursor, limit, ...openCodeLocation(directory) }), + const { data } = await callOpenCode( + (api) => api.shell.output({ id, cursor, limit, ...openCodeLocation(directory) }), + serverId, ) return data } -export async function removeShell(id: string, directory: string): Promise { - await callOpenCode((api) => api.shell.remove({ id, ...openCodeLocation(directory) })) +export async function removeShell(id: string, directory: string, serverId?: string): Promise { + await callOpenCode((api) => api.shell.remove({ id, ...openCodeLocation(directory) }), serverId) } -export async function stageRevert(sessionID: string, messageID: string): Promise { - await interruptSession(sessionID) - await callOpenCode((api) => api.session.wait({ sessionID })) - return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID })) +export async function stageRevert( + sessionID: string, + messageID: string, + serverId?: string, +): Promise { + await interruptSession(sessionID, serverId) + await callOpenCode((api) => api.session.wait({ sessionID }), serverId) + return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID }), serverId) } -export async function commitRevert(sessionID: string): Promise { - await callOpenCode((api) => api.session.revert.commit({ sessionID })) +export async function commitRevert(sessionID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.revert.commit({ sessionID }), serverId) } -export async function clearRevert(sessionID: string): Promise { - await callOpenCode((api) => api.session.revert.clear({ sessionID })) +export async function clearRevert(sessionID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.revert.clear({ sessionID }), serverId) } -export async function compactSession(sessionID: string): Promise { - return callOpenCode((api) => api.session.compact({ sessionID })) +export async function compactSession(sessionID: string, serverId?: string): Promise { + return callOpenCode((api) => api.session.compact({ sessionID }), serverId) } -export async function activateSkill(sessionID: string, id: string): Promise { - await callOpenCode((api) => api.session.skill({ sessionID, id })) +export async function activateSkill(sessionID: string, id: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.skill({ sessionID, id }), serverId) } -export async function listActiveSessions(): Promise { - return callOpenCode((api) => api.session.active()) +export async function listActiveSessions(serverId?: string): Promise { + return callOpenCode((api) => api.session.active(), serverId) } -export async function listAgents(directory?: string): Promise { - const { data } = await callOpenCode((api) => api.agent.list(openCodeLocation(directory))) +export async function listAgents(directory?: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.agent.list(openCodeLocation(directory)), serverId) return data } -export async function listCommands(directory?: string): Promise { - const { data } = await callOpenCode((api) => api.command.list(openCodeLocation(directory))) +export async function listCommands(directory?: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.command.list(openCodeLocation(directory)), serverId) return data } -export async function listSkills(directory?: string): Promise { - const { data } = await callOpenCode((api) => api.skill.list(openCodeLocation(directory))) +export async function listSkills(directory?: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.skill.list(openCodeLocation(directory)), serverId) return data } -export async function listPendingPermissions(directory: string): Promise { - const { data } = await callOpenCode((api) => api.permission.request.list(openCodeLocation(directory))) +export async function listPendingPermissions( + directory: string, + serverId?: string, +): Promise { + const { data } = await callOpenCode( + (api) => api.permission.request.list(openCodeLocation(directory)), + serverId, + ) return data } @@ -306,38 +349,48 @@ export async function replyPermission( requestID: string, decision: 'once' | 'always' | 'reject', message?: string, + serverId?: string, ): Promise { - await callOpenCode((api) => - api.permission.reply({ - sessionID, - requestID, - decision, - ...(message ? { message } : {}), - }), + await callOpenCode( + (api) => + api.permission.reply({ + sessionID, + requestID, + decision, + ...(message ? { message } : {}), + }), + serverId, ) } -export async function listPendingForms(directory: string): Promise { - const { data } = await callOpenCode((api) => api.form.list(openCodeLocation(directory))) +export async function listPendingForms(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.form.list(openCodeLocation(directory)), serverId) return data } -export async function replyForm(sessionID: string, formID: string, answer: FormAnswer): Promise { - await callOpenCode((api) => api.session.form.reply({ sessionID, formID, answer })) +export async function replyForm( + sessionID: string, + formID: string, + answer: FormAnswer, + serverId?: string, +): Promise { + await callOpenCode((api) => api.session.form.reply({ sessionID, formID, answer }), serverId) } -export async function cancelForm(sessionID: string, formID: string): Promise { - await callOpenCode((api) => api.session.form.cancel({ sessionID, formID })) +export async function cancelForm(sessionID: string, formID: string, serverId?: string): Promise { + await callOpenCode((api) => api.session.form.cancel({ sessionID, formID }), serverId) } -export async function findFiles(input: FindFilesInput): Promise { - const { data } = await callOpenCode((api) => - api.file.find({ - ...openCodeLocation(input.directory), - query: input.query, - type: 'file', - limit: input.limit, - }), +export async function findFiles(input: FindFilesInput, serverId?: string): Promise { + const { data } = await callOpenCode( + (api) => + api.file.find({ + ...openCodeLocation(input.directory), + query: input.query, + type: 'file', + limit: input.limit, + }), + serverId, ) return data.map((entry) => entry.path) } @@ -345,22 +398,25 @@ export async function findFiles(input: FindFilesInput): Promise { export async function listSessionMessages( sessionID: string, input: SessionMessagesInput = {}, + serverId?: string, ): Promise { - const { data, cursor } = await callOpenCode((api) => - api.message.list({ - sessionID, - ...(input.limit === undefined ? {} : { limit: input.limit }), - ...(input.cursor === undefined ? { order: 'desc' as const } : { cursor: input.cursor }), - }), + const { data, cursor } = await callOpenCode( + (api) => + api.message.list({ + sessionID, + ...(input.limit === undefined ? {} : { limit: input.limit }), + ...(input.cursor === undefined ? { order: 'desc' as const } : { cursor: input.cursor }), + }), + serverId, ) return { messages: [...data].reverse(), nextCursor: cursor.next ?? undefined } } -export async function readSessionSnapshot(sessionID: string): Promise { +export async function readSessionSnapshot(sessionID: string, serverId?: string): Promise { const [page, pending, active] = await Promise.all([ - listSessionMessages(sessionID), - callOpenCode((api) => api.session.inbox.list({ sessionID })), - listActiveSessions(), + listSessionMessages(sessionID, {}, serverId), + callOpenCode((api) => api.session.inbox.list({ sessionID }), serverId), + listActiveSessions(serverId), ]) return { messages: page.messages, diff --git a/frontend/src/api/opencodeApi.test.ts b/frontend/src/api/opencodeApi.test.ts new file mode 100644 index 000000000..279d2225b --- /dev/null +++ b/frontend/src/api/opencodeApi.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeApi, openCodeApiBaseUrl } from './opencodeApi' +import { API_BASE_URL, OPENCODE_API_ENDPOINT } from '@/config' + +describe('openCodeApi', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps the local server on the direct OpenCode endpoint', () => { + expect(openCodeApiBaseUrl()).toBe(OPENCODE_API_ENDPOINT) + expect(openCodeApiBaseUrl('local')).toBe(OPENCODE_API_ENDPOINT) + }) + + it('routes remote servers through the backend proxy', () => { + expect(openCodeApiBaseUrl('srv_abc')).toBe(`${API_BASE_URL}/api/opencode/servers/srv_abc`) + }) + + it('memoizes one client per server', () => { + const local = getOpenCodeApi() + const remote = getOpenCodeApi('srv_abc') + + expect(getOpenCodeApi()).toBe(local) + expect(getOpenCodeApi('local')).toBe(local) + expect(getOpenCodeApi('srv_abc')).toBe(remote) + expect(getOpenCodeApi('srv_other')).not.toBe(remote) + }) +}) diff --git a/frontend/src/api/opencodeApi.ts b/frontend/src/api/opencodeApi.ts index e590c5f12..c55d5fad7 100644 --- a/frontend/src/api/opencodeApi.ts +++ b/frontend/src/api/opencodeApi.ts @@ -1,11 +1,28 @@ import { ClientError, createOpenCodeApi, openCodeErrorStatus, type OpenCodeApi } from '@opencode-manager/shared/opencode' import { FetchError } from '@opencode-manager/shared' -import { OPENCODE_API_ENDPOINT } from '@/config' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { API_BASE_URL, OPENCODE_API_ENDPOINT } from '@/config' -const openCodeApi = createOpenCodeApi({ - baseUrl: new URL(OPENCODE_API_ENDPOINT, window.location.origin).toString(), - fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }), -}) +const openCodeApis = new Map() + +/** Base URL of the OpenCode API for a server: the direct local endpoint or the remote proxy. */ +export function openCodeApiBaseUrl(serverId: string = LOCAL_OPENCODE_SERVER_ID): string { + return serverId === LOCAL_OPENCODE_SERVER_ID + ? OPENCODE_API_ENDPOINT + : `${API_BASE_URL}/api/opencode/servers/${encodeURIComponent(serverId)}` +} + +export function getOpenCodeApi(serverId: string = LOCAL_OPENCODE_SERVER_ID): OpenCodeApi { + const cached = openCodeApis.get(serverId) + if (cached) return cached + + const api = createOpenCodeApi({ + baseUrl: new URL(openCodeApiBaseUrl(serverId), window.location.origin).toString(), + fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }), + }) + openCodeApis.set(serverId, api) + return api +} function toFetchError(error: unknown): FetchError { if (error instanceof FetchError) { @@ -25,9 +42,12 @@ function toFetchError(error: unknown): FetchError { return new FetchError('Request failed', status ?? 0) } -export async function callOpenCode(operation: (api: OpenCodeApi) => Promise): Promise { +export async function callOpenCode( + operation: (api: OpenCodeApi) => Promise, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { try { - return await operation(openCodeApi) + return await operation(getOpenCodeApi(serverId)) } catch (error) { throw toFetchError(error) } diff --git a/frontend/src/api/opencodeServers.test.ts b/frontend/src/api/opencodeServers.test.ts new file mode 100644 index 000000000..c67fdfb7c --- /dev/null +++ b/frontend/src/api/opencodeServers.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createOpenCodeServer, + deleteOpenCodeServer, + listOpenCodeServers, + testOpenCodeServer, + testOpenCodeServerById, + updateOpenCodeServer, +} from './opencodeServers' + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + +const server = { + id: 'srv_1', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const testResult = { ok: true, version: '2.0.15', versionSupported: true, error: null } + +describe('opencodeServers', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lists servers from the { servers } envelope', async () => { + fetchMock.mockResolvedValue(jsonResponse({ servers: [server] })) + + await expect(listOpenCodeServers()).resolves.toEqual([server]) + expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining('/api/opencode-servers')) + expect(fetchMock.mock.calls[0][1].method).toBeUndefined() + }) + + it('creates a server and returns the test result', async () => { + const input = { + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + password: 'secret', + enabled: true, + } + fetchMock.mockResolvedValue(jsonResponse({ server, test: testResult }, 201)) + + await expect(createOpenCodeServer(input)).resolves.toEqual({ server, test: testResult }) + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toEqual(expect.stringContaining('/api/opencode-servers')) + expect(options.method).toBe('POST') + expect(options.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(options.body)).toEqual(input) + }) + + it('unwraps the { server } envelope on PATCH', async () => { + fetchMock.mockResolvedValue(jsonResponse({ server: { ...server, name: 'Office' } })) + + await expect(updateOpenCodeServer('srv_1', { name: 'Office' })).resolves.toEqual({ + ...server, + name: 'Office', + }) + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toEqual(expect.stringContaining('/api/opencode-servers/srv_1')) + expect(options.method).toBe('PATCH') + expect(JSON.parse(options.body)).toEqual({ name: 'Office' }) + }) + + it('clears a password by sending null', async () => { + fetchMock.mockResolvedValue(jsonResponse({ server: { ...server, hasPassword: false } })) + + await updateOpenCodeServer('srv_1', { password: null }) + + const [, options] = fetchMock.mock.calls[0] + expect(JSON.parse(options.body)).toEqual({ password: null }) + }) + + it('deletes a server with DELETE and accepts the empty 204 response', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + await expect(deleteOpenCodeServer('srv_1')).resolves.toBeUndefined() + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toEqual(expect.stringContaining('/api/opencode-servers/srv_1')) + expect(options.method).toBe('DELETE') + }) + + it('tests unsaved values with POST /test', async () => { + fetchMock.mockResolvedValue(jsonResponse(testResult)) + const input = { baseUrl: 'https://kitchen.example.com', username: 'opencode', password: 'secret' } + + await expect(testOpenCodeServer(input)).resolves.toEqual(testResult) + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toEqual(expect.stringContaining('/api/opencode-servers/test')) + expect(options.method).toBe('POST') + expect(JSON.parse(options.body)).toEqual(input) + }) + + it('tests a saved server with POST /:id/test', async () => { + fetchMock.mockResolvedValue(jsonResponse(testResult)) + + await expect(testOpenCodeServerById('srv_1')).resolves.toEqual(testResult) + + const [url, options] = fetchMock.mock.calls[0] + expect(url).toEqual(expect.stringContaining('/api/opencode-servers/srv_1/test')) + expect(options.method).toBe('POST') + }) + + it('surfaces backend error messages', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: 'Server unreachable' }, 502)) + + await expect(testOpenCodeServerById('srv_1')).rejects.toThrow('Server unreachable') + }) +}) diff --git a/frontend/src/api/opencodeServers.ts b/frontend/src/api/opencodeServers.ts new file mode 100644 index 000000000..b31234f5d --- /dev/null +++ b/frontend/src/api/opencodeServers.ts @@ -0,0 +1,77 @@ +import type { + CreateOpenCodeServerRequest, + OpenCodeServer, + OpenCodeServerStatus, + OpenCodeServerTestResult, + TestOpenCodeServerRequest, + UpdateOpenCodeServerRequest, +} from '@opencode-manager/shared/schemas' +import { API_BASE_URL } from '@/config' +import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' + +const OPENCODE_SERVERS_URL = `${API_BASE_URL}/api/opencode-servers` + +export interface CreateOpenCodeServerResponse { + server: OpenCodeServer + test: OpenCodeServerTestResult +} + +export async function listOpenCodeServers(): Promise { + const response = await fetchWrapper<{ servers: OpenCodeServer[] }>(OPENCODE_SERVERS_URL) + return response.servers +} + +export async function listOpenCodeServerStatuses(): Promise { + const response = await fetchWrapper<{ statuses: OpenCodeServerStatus[] }>( + `${OPENCODE_SERVERS_URL}/status`, + ) + return response.statuses +} + +export async function createOpenCodeServer( + input: CreateOpenCodeServerRequest, +): Promise { + return fetchWrapper(OPENCODE_SERVERS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) +} + +export async function updateOpenCodeServer( + id: string, + input: UpdateOpenCodeServerRequest, +): Promise { + const response = await fetchWrapper<{ server: OpenCodeServer }>( + `${OPENCODE_SERVERS_URL}/${encodeURIComponent(id)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }, + ) + return response.server +} + +export async function deleteOpenCodeServer(id: string): Promise { + return fetchWrapperVoid(`${OPENCODE_SERVERS_URL}/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} + +export async function testOpenCodeServer( + input: TestOpenCodeServerRequest, +): Promise { + return fetchWrapper(`${OPENCODE_SERVERS_URL}/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) +} + +export async function testOpenCodeServerById(id: string): Promise { + return fetchWrapper( + `${OPENCODE_SERVERS_URL}/${encodeURIComponent(id)}/test`, + { method: 'POST' }, + ) +} diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 919fa4e4b..e1906dfdc 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -59,11 +59,12 @@ function mapModelInfo(model: ModelInfo): Model { }; } -export async function getProviders(directory?: string): Promise { +export async function getProviders(directory?: string, serverId?: string): Promise { try { const location = openCodeLocation(directory); - const [providerResult, modelResult] = await callOpenCode((api) => - Promise.all([api.provider.list(location), api.model.list(location)]), + const [providerResult, modelResult] = await callOpenCode( + (api) => Promise.all([api.provider.list(location), api.model.list(location)]), + serverId, ); const activeProviders = providerResult.data.filter(isActiveCatalogProvider); @@ -100,9 +101,12 @@ export async function getProviders(directory?: string): Promise } } -export async function getOpenCodeConfiguredModel(directory?: string): Promise { +export async function getOpenCodeConfiguredModel( + directory?: string, + serverId?: string, +): Promise { try { - const entries = await callOpenCode((api) => api.config.get(openCodeLocation(directory))); + const entries = await callOpenCode((api) => api.config.get(openCodeLocation(directory)), serverId); const ref = selectConfiguredModelRef(entries); return ref ? formatOpenCodeModelRef(ref) : null; } catch { @@ -110,9 +114,12 @@ export async function getOpenCodeConfiguredModel(directory?: string): Promise { +export async function getOpenCodeServerDefaultModel( + directory?: string, + serverId?: string, +): Promise { try { - const result = await callOpenCode((api) => api.model.default(openCodeLocation(directory))); + const result = await callOpenCode((api) => api.model.default(openCodeLocation(directory)), serverId); const model = result.data; return model ? { providerID: model.providerID, id: model.id } : null; } catch { diff --git a/frontend/src/components/message/ComposerToolsMenu.tsx b/frontend/src/components/message/ComposerToolsMenu.tsx index f5eb451be..70b201f94 100644 --- a/frontend/src/components/message/ComposerToolsMenu.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.tsx @@ -25,6 +25,7 @@ interface ComposerToolsMenuProps { onToggleGoal: () => void onAttachFile: () => void onDictate?: () => void + allowManagerTools?: boolean } export function ComposerToolsMenu({ @@ -36,9 +37,10 @@ export function ComposerToolsMenu({ onToggleGoal, onAttachFile, onDictate, + allowManagerTools = true, }: ComposerToolsMenuProps) { - const permission = usePermissionModeToggle(sessionID, directory) - const sandbox = useSandboxToggle(sessionID) + const permission = usePermissionModeToggle(sessionID, directory, { enabled: allowManagerTools }) + const sandbox = useSandboxToggle(sessionID, { enabled: allowManagerTools }) const [resourcesOpen, setResourcesOpen] = useState(false) return ( @@ -55,26 +57,30 @@ export function ComposerToolsMenu({ - - : } - title="Accept all permissions" - detail={permission.disabled ? permission.label : undefined} - /> - - setResourcesOpen(true)} /> - - } - title="Goal mode" - detail={goalDisabled ? goalLabel : undefined} - /> - - + {allowManagerTools ? ( + <> + + : } + title="Accept all permissions" + detail={permission.disabled ? permission.label : undefined} + /> + + setResourcesOpen(true)} /> + + } + title="Goal mode" + detail={goalDisabled ? goalLabel : undefined} + /> + + + + ) : null} } title="Attach image or PDF" /> diff --git a/frontend/src/components/message/FileReferenceText.tsx b/frontend/src/components/message/FileReferenceText.tsx index 344f17318..85108b6e7 100644 --- a/frontend/src/components/message/FileReferenceText.tsx +++ b/frontend/src/components/message/FileReferenceText.tsx @@ -8,6 +8,7 @@ interface FileReferenceTextProps { export function FileReferenceText({ text, onFileClick }: FileReferenceTextProps) { const parts = useMemo(() => { + if (!onFileClick) return text const references = detectFileReferences(text) if (references.length === 0) return text diff --git a/frontend/src/components/message/FileToolRender.tsx b/frontend/src/components/message/FileToolRender.tsx index aa8895081..3b67c708e 100644 --- a/frontend/src/components/message/FileToolRender.tsx +++ b/frontend/src/components/message/FileToolRender.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' import type { FileDiffInfo, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' +import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { DiffStats } from './DiffStats' import { CodePreview } from './CodePreview' import { ToolContentFiles } from './ToolContentFiles' @@ -73,10 +74,12 @@ interface FileToolRenderProps { function FileToolRender({ part, filediff, filePath, content, toolName, onFileClick }: FileToolRenderProps) { const { preferences } = useSettings() + const isRemoteServer = !isLocalOpenCodeServer(useOpenCodeServerId()) const isReadTool = toolName === 'Read' const isEditTool = toolName === 'Edit' const isWriteTool = toolName === 'Write' const hasExpandableContent = !isReadTool && (filediff || content) + const isFileLink = Boolean(onFileClick) && !isRemoteServer const isFileMutatingTool = isEditTool || isWriteTool const defaultExpanded = isFileMutatingTool @@ -106,12 +109,16 @@ function FileToolRender({ part, filediff, filePath, content, toolName, onFileCli ✓ {toolName} {filePath && ( - - {getRelativePath(filePath)} - + isFileLink ? ( + + {getRelativePath(filePath)} + + ) : ( + {getRelativePath(filePath)} + ) )}
diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index e98347993..a6c7a2b32 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { MessagePart } from './MessagePart' import { getFileApiUrl } from '@/api/files' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { applySessionEvent, emptySessionTranscript } from '@/lib/session-projection' import { promptSequence } from '@/test/fixtures/session-projection' import type { @@ -509,6 +510,36 @@ describe('MessagePart', () => { }) describe('file tools', () => { + it('renders a linked-server file name as plain text', () => { + const onFileClick = vi.fn() + const queryClient = new QueryClient() + render( + + + + + + + , + ) + + const fileName = screen.getByText('/srv/app/src/a.ts') + expect(fileName.className).not.toContain('cursor-pointer') + expect(fileName.className).not.toContain('text-primary') + + fireEvent.click(fileName) + + expect(onFileClick).not.toHaveBeenCalled() + }) + it('renders an edit tool diff', () => { renderWithProviders( { sessionID: 'test-session', text: 'ask @reviewer', agents: [{ name: 'reviewer', mention: { start: 4, end: 13, text: '@reviewer' } }], - })) + }), 'local') expect(mocks.switchSessionAgent).not.toHaveBeenCalled() expect(mocks.switchSessionModel).not.toHaveBeenCalled() }) @@ -231,7 +231,7 @@ describe('PromptInput agent mention submission', () => { text: 'ask @reviewer', agents: [{ name: 'reviewer', mention: { start: 4, end: 13, text: '@reviewer' } }], delivery: 'queue', - })) + }), 'local') expect(mocks.switchSessionAgent).not.toHaveBeenCalled() }) @@ -248,7 +248,7 @@ describe('PromptInput agent mention submission', () => { text: 'use @pr-review and @pr-review', agents: [], skills: [{ id: 'pr-review', mention: { start: 4, end: 14, text: '@pr-review' } }], - })) + }), 'local') }) it('sends plain text without inferring an agent attachment or switching', async () => { @@ -264,7 +264,7 @@ describe('PromptInput agent mention submission', () => { sessionID: 'test-session', text: 'plain text', agents: [], - })) + }), 'local') expect(mocks.switchSessionAgent).not.toHaveBeenCalled() expect(mocks.switchSessionModel).not.toHaveBeenCalled() }) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index a86720bc2..7702fdc9f 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -18,6 +18,7 @@ import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { usePermissions } from '@/contexts/EventContext' +import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { ArrowDown, Upload, X, Mic, MicOff, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' @@ -251,6 +252,7 @@ export const PromptInput = memo(forwardRef( const sessionAgent = useSessionAgent(sessionID, directory) const hasBackgroundActivity = useHasBackgroundActivity(sessionID) + const allowManagerTools = isLocalOpenCodeServer(useOpenCodeServerId()) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) @@ -258,8 +260,8 @@ export const PromptInput = memo(forwardRef( const startGoal = useStartSessionGoal() const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending || startGoal.isPending const interruptSession = useInterruptSession() - const { data: sessionGoal } = useSessionGoal(sessionID) - const { data: permissionMode } = useSessionPermissionMode(sessionID) + const { data: sessionGoal } = useSessionGoal(sessionID, { enabled: allowManagerTools }) + const { data: permissionMode } = useSessionPermissionMode(sessionID, { enabled: allowManagerTools }) const { searchCommands, findCommand, recentNames } = useCommands({ directory }) const recordCommand = useRecentCommandsStore((state) => state.recordCommand) const isExactCommandPrompt = (value: string) => { @@ -1577,7 +1579,7 @@ return ( onAgentChange={handleAgentChange} isBashMode={isBashMode} /> - {directory && !isMobile && ( + {directory && !isMobile && allowManagerTools ? ( <> @@ -1590,7 +1592,7 @@ return ( - )} + ) : null} {(isSessionActive || hasBackgroundActivity) && (
@@ -1662,6 +1664,7 @@ return ( goalLabel={goalButtonLabel} onToggleGoal={toggleGoalArmed} onAttachFile={openFilePicker} + allowManagerTools={allowManagerTools} onDictate={canUseMobileVoice && hasPromptContent && !showVoiceFeedback ? () => void startVoiceRecording() : undefined} /> )} diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index f028c5cbb..9a37fcf38 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -299,7 +299,7 @@ describe('DesktopSessionTree', () => { const rows = screen.getAllByRole('button', { name: /Session / }) expect(rows[0]).toHaveTextContent('Session old') expect(rows[0].querySelector('[aria-label="Pinned"]')).not.toBeNull() - expect(getSessionMock).toHaveBeenCalledWith('old') + expect(getSessionMock).toHaveBeenCalledWith('old', 'local') }) it('keeps a non-ready repo navigable without actions or session requests', async () => { diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index 535a9aa12..d5d17fb2a 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -11,6 +11,10 @@ import * as useAuthModule from '@/hooks/useAuth' vi.mock('@/hooks/useDesktop') vi.mock('@/hooks/useSidebarCollapsed') vi.mock('@/hooks/useAuth') +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: () => ({ servers: [], isLoading: false }), + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) vi.mock('@/components/navigation/DesktopSessionTree', () => ({ DesktopSessionTree: () =>
, })) @@ -245,6 +249,20 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() }) + + it('hides the Servers group when no linked servers exist', () => { + vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) + vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) + vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper(['/']) }) + + expect(screen.queryByText('Servers')).toBeNull() + }) it('renders home, repos and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 72996d292..91e58f273 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -8,6 +8,7 @@ import { useOpenNavItem } from '@/hooks/useOpenNavItem' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' +import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' import { Sidebar, SidebarSection, @@ -94,6 +95,8 @@ export function DesktopSidebar() {
) : ( <> + +

Sessions

diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx new file mode 100644 index 000000000..caaf40e87 --- /dev/null +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { LinkedServersNav } from './LinkedServersNav' + +const mocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const office = { ...kitchen, id: 'srv_office', name: 'Office' } + +function renderNav(pathname = '/') { + return render( + + + , + ) +} + +describe('LinkedServersNav', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, + isLoading: false, + }) + }) + + it('renders nothing when no linked servers exist', () => { + const { container } = renderNav() + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByText('Servers')).not.toBeInTheDocument() + }) + + it('lists enabled servers with a link and connection dot', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + + renderNav() + + expect(screen.getByText('Servers')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Kitchen/ })).toHaveAttribute('href', '/servers/srv_kitchen') + expect(screen.getByRole('link', { name: /Office/ })).toHaveAttribute('href', '/servers/srv_office') + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Disconnected' })).toBeInTheDocument() + }) + + it('hides disabled servers', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, { ...office, enabled: false }], + isLoading: false, + }) + + renderNav() + + expect(screen.getByRole('link', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Office/ })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx new file mode 100644 index 000000000..77436ed61 --- /dev/null +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -0,0 +1,51 @@ +import { Link, useLocation } from 'react-router-dom' +import { Server } from 'lucide-react' +import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' +import { SidebarSection } from '@/components/ui/sidebar' +import { getServerPath } from '@/lib/navigation' +import { cn } from '@/lib/utils' + +export function LinkedServersNav() { + const location = useLocation() + const { servers, isLoading } = useOpenCodeServers() + const { statusById } = useOpenCodeServerStatuses() + const enabledServers = servers.filter((server) => server.enabled) + + if (isLoading || enabledServers.length === 0) { + return null + } + + return ( + + {enabledServers.map((server) => { + const path = getServerPath(server.id) + const active = location.pathname === path || location.pathname.startsWith(`${path}/`) + const connected = statusById[server.id]?.connected ?? false + + return ( + + + {server.name} + + + ) + })} + + ) +} diff --git a/frontend/src/components/navigation/MobileSheetHost.test.tsx b/frontend/src/components/navigation/MobileSheetHost.test.tsx index 1c6dc5699..1d425d251 100644 --- a/frontend/src/components/navigation/MobileSheetHost.test.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.test.tsx @@ -89,6 +89,20 @@ describe('MobileSheetHost', () => { expect(sheet.getAttribute('data-repo-name')).toBe('Workspace Root') }) + it('hides the FileBrowserSheet on a linked-server route', () => { + vi.mocked(useMobileTabBar).mockReturnValue({ + openSheet: 'files', + open: vi.fn(), + close: vi.fn(), + }) + render( + + + , + ) + expect(screen.queryByTestId('file-browser-sheet')).not.toBeInTheDocument() + }) + it('renders NotificationsSheet when mobileTab=notifications', () => { vi.mocked(useMobileTabBar).mockReturnValue({ openSheet: 'notifications', diff --git a/frontend/src/components/navigation/MobileSheetHost.tsx b/frontend/src/components/navigation/MobileSheetHost.tsx index 0a7232d83..4134cd7f0 100644 --- a/frontend/src/components/navigation/MobileSheetHost.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.tsx @@ -4,17 +4,19 @@ import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { NotificationsSheet } from '@/components/navigation/NotificationsSheet' import { MoreDrawer } from '@/components/navigation/MoreDrawer' +import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' export function MobileSheetHost() { const isMobile = useMobile() const { openSheet, close } = useMobileTabBar() + const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) if (!isMobile) return null return ( <> - {openSheet === 'files' && ( + {openSheet === 'files' && !isRemoteRoute && ( { expect(navigateMock).toHaveBeenCalledWith('/repos/1/sessions/s1') expect(handleClose).not.toHaveBeenCalled() }) + + it('offers only account items on a linked-server route', () => { + mockAuth() + mockServerHealth() + setActivityGroups({ recent: [createActiveWorkItem()] }) + renderMoreDrawer({ + initialEntry: '/servers/srv_kitchen/sessions/ses_remote', + routePath: '/servers/:serverId/sessions/:sessionId', + }) + + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByText('Logout')).toBeInTheDocument() + expect(screen.queryByText('Files')).not.toBeInTheDocument() + expect(screen.queryByText('Source Control')).not.toBeInTheDocument() + expect(screen.queryByText('Terminal')).not.toBeInTheDocument() + expect(screen.queryByText('MCP')).not.toBeInTheDocument() + expect(screen.queryByText('Schedules')).not.toBeInTheDocument() + expect(screen.queryByText('Repos')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Activity' })).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index fd82c5886..566dd63dd 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -9,11 +9,12 @@ import { getRepo } from '@/api/repos' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { SideDrawer, SideDrawerContent } from '@/components/ui/side-drawer' import { CompactActivityList } from '@/components/activity/CompactActivityList' -import { buildMoreItems, buildNavModel, isSessionDetailPath, type MoreDrawerItem } from './moreDrawerItems' +import { buildMoreItems, buildNavModel, isManagerOnlyNavItem, isSessionDetailPath, type MoreDrawerItem } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' import { cn, getRepoDisplayName } from '@/lib/utils' import { getPathWithReturnTo, isAssistantPath } from '@/lib/navigation' import { useMobileTabBar } from '@/hooks/useMobileTabBar' +import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' interface MoreDrawerProps { isOpen: boolean @@ -76,6 +77,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const isSessionDetail = isSessionDetailPath(location.pathname) const isAssistantRoute = isAssistantPath(location.pathname) const isAssistantSession = isSessionDetail && searchParams.get('assistant') === '1' + const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) const { open: openMobileSheet } = useMobileTabBar() useEffect(() => { @@ -136,11 +138,13 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { } } - const items = buildMoreItems(location.pathname) + const items = buildMoreItems(location.pathname).filter( + (item) => !isRemoteRoute || !isManagerOnlyNavItem(item), + ) const itemsInGroup = (keys: ReadonlySet) => items.filter((item) => keys.has(item.key)) const groupedKeys = new Set([...ACCOUNT_ITEM_KEYS, ...ITEM_GROUPS.flatMap(({ keys }) => [...keys])]) const navigationItems = items.filter((item) => !groupedKeys.has(item.key)) - const assistantCta = isSessionDetail && !isAssistantSession + const assistantCta = isSessionDetail && !isAssistantSession && !isRemoteRoute ? buildNavModel(location.pathname).primary.find((cta) => cta.key === 'assistant') : undefined @@ -184,11 +188,11 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { )}
- {isOpen && ( + {isOpen && !isRemoteRoute && ( navigate(path)} /> )} - {isSessionDetail && ( + {isSessionDetail && !isRemoteRoute && ( openMobileSheet('repos')} trailing /> )} {assistantCta?.to && ( diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index d142cc192..5be89385f 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -55,6 +55,25 @@ export function isSessionDetailPath(pathname: string): boolean { const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) +const MANAGER_ONLY_ITEM_KEYS: ReadonlySet = new Set([ + 'files', + 'source-control', + 'terminal', + 'preview', + 'walkthrough', + 'actions', + 'mcp', + 'skills', + 'reset-permissions', + 'schedules', + 'all-schedules', +]) + +/** True for an item that opens a Manager feature a linked OpenCode server cannot serve. */ +export function isManagerOnlyNavItem(item: MoreDrawerItem): boolean { + return MANAGER_ONLY_ITEM_KEYS.has(item.key) +} + /** Route-scoped tool items, shown in the desktop tool rail instead of the sidebar. */ export function buildToolItems(pathname: string): MoreDrawerItem[] { const routeItems = buildNavModel(pathname).items.filter((item) => !NON_TOOL_ITEM_KEYS.has(item.key)) diff --git a/frontend/src/components/notifications/PendingActionsGroup.tsx b/frontend/src/components/notifications/PendingActionsGroup.tsx index 529f98c9f..b1b1b0f81 100644 --- a/frontend/src/components/notifications/PendingActionsGroup.tsx +++ b/frontend/src/components/notifications/PendingActionsGroup.tsx @@ -3,13 +3,17 @@ import { PendingActionBadge } from '@/components/ui/pending-action-badge' import { ScheduleReportsBell } from '@/components/notifications/ScheduleReportsBell' import { usePermissions, useForms } from '@/contexts/EventContext' -export function PendingActionsGroup() { +interface PendingActionsGroupProps { + showScheduleReports?: boolean +} + +export function PendingActionsGroup({ showScheduleReports = true }: PendingActionsGroupProps) { const { pendingCount: permissionCount, setShowDialog, navigateToCurrent: navigateToPermission } = usePermissions() const { pendingCount: formCount, navigateToCurrent } = useForms() return ( <> - + {showScheduleReports ? : null} >({}) const [isLoadingStatus, setIsLoadingStatus] = useState(false) @@ -33,19 +35,19 @@ export function RepoMcpContent({ open, directory }: RepoMcpContentProps) { const fetchStatus = useCallback(async () => { setIsLoadingStatus(true) try { - setLocalStatus(await mcpApi.getStatus(directory)) + setLocalStatus(await mcpApi.getStatus(directory, openCodeServerId)) setHasFetchedStatus(true) } finally { setIsLoadingStatus(false) } - }, [directory]) + }, [directory, openCodeServerId]) const toggleMutation = useMutation({ mutationFn: async ({ serverId, enable }: { serverId: string; enable: boolean }) => { if (enable) { - await mcpApi.connect(serverId, directory) + await mcpApi.connect(serverId, directory, openCodeServerId) } else { - await mcpApi.disconnect(serverId, directory) + await mcpApi.disconnect(serverId, directory, openCodeServerId) } }, onSuccess: async () => { @@ -80,7 +82,7 @@ export function RepoMcpContent({ open, directory }: RepoMcpContentProps) { const handleOAuthCheckStatus = async (): Promise => { if (!authDialogServerId) return false - const status = await mcpApi.getStatus(directory) + const status = await mcpApi.getStatus(directory, openCodeServerId) if (status[authDialogServerId]?.status === 'connected') { setLocalStatus(status) return true diff --git a/frontend/src/components/repo/RepoSkillsDialog.test.tsx b/frontend/src/components/repo/RepoSkillsDialog.test.tsx index dfd4d5fc3..b51d41d4b 100644 --- a/frontend/src/components/repo/RepoSkillsDialog.test.tsx +++ b/frontend/src/components/repo/RepoSkillsDialog.test.tsx @@ -224,7 +224,7 @@ describe('RepoSkillsDialog', () => { expect(onOpenChange).toHaveBeenCalledWith(false) expect(onSkillLoaded).toHaveBeenCalledWith(mockSkills[0]) - expect(mocks.activateSkill).toHaveBeenCalledWith('test-session', 'test-skill') + expect(mocks.activateSkill).toHaveBeenCalledWith('test-session', 'test-skill', 'local') }) it('shows toast error on activateSkill failure', async () => { diff --git a/frontend/src/components/session/BackgroundWorkBar.test.tsx b/frontend/src/components/session/BackgroundWorkBar.test.tsx index 5dcef7ac1..6645bfe98 100644 --- a/frontend/src/components/session/BackgroundWorkBar.test.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.test.tsx @@ -150,7 +150,7 @@ describe('BackgroundWorkBar', () => { it('renders nothing when there is no running or background work', async () => { const { container } = renderBar([], false) - await waitFor(() => expect(api.listShells).toHaveBeenCalledWith('/repo')) + await waitFor(() => expect(api.listShells).toHaveBeenCalledWith('/repo', 'local')) expect(container).toBeEmptyDOMElement() }) @@ -159,7 +159,7 @@ describe('BackgroundWorkBar', () => { fireEvent.click(await screen.findByRole('button', { name: /Move to background/ })) - await waitFor(() => expect(api.backgroundSession).toHaveBeenCalledWith('session-1')) + await waitFor(() => expect(api.backgroundSession).toHaveBeenCalledWith('session-1', 'local')) }) it('hides the background action once the session is idle', async () => { @@ -180,7 +180,7 @@ describe('BackgroundWorkBar', () => { fireEvent.click(screen.getByRole('button', { name: 'Kill npm run dev' })) - await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) + await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo', 'local')) await waitFor(() => expect(container).toBeEmptyDOMElement()) clearShellExitRecord('/repo', 'dev') }) @@ -443,7 +443,7 @@ describe('BackgroundWorkBar', () => { fireEvent.click(screen.getByRole('button', { name: 'Output' })) expect(await screen.findByText('server listening on 3000')).toBeInTheDocument() - expect(api.readShellOutput).toHaveBeenCalledWith('dev', '/repo', 0, 50_000) + expect(api.readShellOutput).toHaveBeenCalledWith('dev', '/repo', 0, 50_000, 'local') }) it('reads the final output when the shell exits during an in-flight poll', async () => { diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx index c7429a620..3cb7f4701 100644 --- a/frontend/src/components/session/BackgroundWorkBar.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -3,6 +3,7 @@ import { Bot, Check, ChevronDown, ChevronUp, ExternalLink, Loader2, MoveDownRigh import { useShallow } from 'zustand/react/shallow' import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' import { readShellOutput } from '@/api/opencode' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useBackgroundSession, useChildSessionReconciliation } from '@/hooks/useOpenCode' @@ -49,6 +50,7 @@ function appendShellOutput(previous: ShellOutputState, output: string, truncated } function useShellOutput(shellID: string, directory: string, running: boolean): ShellOutputState { + const serverId = useOpenCodeServerId() const [state, setState] = useState({ output: '', truncated: false }) const cursorRef = useRef(0) @@ -64,7 +66,7 @@ function useShellOutput(shellID: string, directory: string, running: boolean): S let truncated = false try { for (;;) { - const chunk = await readShellOutput(shellID, directory, cursor, SHELL_OUTPUT_LIMIT) + const chunk = await readShellOutput(shellID, directory, cursor, SHELL_OUTPUT_LIMIT, serverId) output += chunk.output if (chunk.truncated) truncated = true if (chunk.cursor <= cursor) break @@ -99,7 +101,7 @@ function useShellOutput(shellID: string, directory: string, running: boolean): S cancelled = true clearInterval(interval) } - }, [shellID, directory, running]) + }, [shellID, directory, running, serverId]) return state } diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 677c73501..db2f59d71 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query' import { format, startOfDay } from 'date-fns' import { MoreHorizontal, Pin, PinOff } from 'lucide-react' import { getOpenCodeLocation } from '@/api/opencode' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { serverScopedKey } from '@/lib/serverScope' import type { Repo, Session } from '@/api/types' import { ACTIVE_WORK_GROUPS, MS_PER_MINUTE, formatActivityEmptyMessage } from '@/lib/activeWork' import { Button } from '@/components/ui/button' @@ -553,9 +555,10 @@ function SessionPickerContent({ return map }, [repos, currentRepo]) + const serverId = useOpenCodeServerId() const locationQuery = useQuery({ - queryKey: ['opencode', 'location', currentRepo?.fullPath], - queryFn: () => getOpenCodeLocation(currentRepo!.fullPath), + queryKey: serverScopedKey(['opencode', 'location', currentRepo?.fullPath], serverId), + queryFn: () => getOpenCodeLocation(currentRepo!.fullPath, serverId), enabled: !allProjects && Boolean(currentRepo?.fullPath), staleTime: 5 * 60 * 1000, }) diff --git a/frontend/src/components/session/SideQuestionDialog.test.tsx b/frontend/src/components/session/SideQuestionDialog.test.tsx index 5cb98529a..5c8ee81b0 100644 --- a/frontend/src/components/session/SideQuestionDialog.test.tsx +++ b/frontend/src/components/session/SideQuestionDialog.test.tsx @@ -78,6 +78,7 @@ describe('SideQuestionDialog', () => { 'ses_1', 'what is the answer?', expect.any(AbortSignal), + 'local', ) expect(await screen.findByText('42')).toBeInTheDocument() }) @@ -95,7 +96,7 @@ describe('SideQuestionDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Ask' })) await waitFor(() => - expect(mocks.askSideQuestion).toHaveBeenCalledWith('ses_1', 'why?', expect.any(AbortSignal)), + expect(mocks.askSideQuestion).toHaveBeenCalledWith('ses_1', 'why?', expect.any(AbortSignal), 'local'), ) expect(await screen.findByText('because')).toBeInTheDocument() }) @@ -111,7 +112,7 @@ describe('SideQuestionDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })) await waitFor(() => expect(mocks.askSideQuestion).toHaveBeenCalledTimes(2)) - expect(mocks.askSideQuestion).toHaveBeenLastCalledWith('ses_1', 'why?', expect.any(AbortSignal)) + expect(mocks.askSideQuestion).toHaveBeenLastCalledWith('ses_1', 'why?', expect.any(AbortSignal), 'local') expect(await screen.findByText('retried answer')).toBeInTheDocument() }) @@ -132,6 +133,7 @@ describe('SideQuestionDialog', () => { 'ses_1', 'question A', expect.any(AbortSignal), + 'local', ) }) @@ -152,6 +154,7 @@ describe('SideQuestionDialog', () => { 'ses_1', 'question A', expect.any(AbortSignal), + 'local', ) }) @@ -189,6 +192,7 @@ describe('SideQuestionDialog', () => { 'ses_1', 'question B', expect.any(AbortSignal), + 'local', ) expect(signalB).toBeDefined() expect(signalA?.aborted).toBe(true) diff --git a/frontend/src/components/session/SideQuestionDialog.tsx b/frontend/src/components/session/SideQuestionDialog.tsx index 1b34884b3..0f42ef42a 100644 --- a/frontend/src/components/session/SideQuestionDialog.tsx +++ b/frontend/src/components/session/SideQuestionDialog.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input' import { CopyButton } from '@/components/ui/copy-button' import { TextPart } from '@/components/message/TextPart' import { askSideQuestion } from '@/api/opencode' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' interface SideQuestionDialogProps { open: boolean @@ -20,6 +21,7 @@ export function SideQuestionDialog({ sessionID, initialQuestion = '', }: SideQuestionDialogProps) { + const serverId = useOpenCodeServerId() const [question, setQuestion] = useState(initialQuestion) const [answer, setAnswer] = useState('') const [error, setError] = useState(false) @@ -43,7 +45,7 @@ export function SideQuestionDialog({ setAnswer('') try { - const result = await askSideQuestion(sessionID, trimmed, controller.signal) + const result = await askSideQuestion(sessionID, trimmed, controller.signal, serverId) if (requestRef.current !== requestId || controller.signal.aborted) return setAnswer(result) setPending(false) @@ -52,7 +54,7 @@ export function SideQuestionDialog({ setError(true) setPending(false) } - }, [sessionID]) + }, [sessionID, serverId]) useEffect(() => { if (initialQuestion.trim()) { diff --git a/frontend/src/components/settings/OpenCodeServerDialog.test.tsx b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx new file mode 100644 index 000000000..3df24b03e --- /dev/null +++ b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx @@ -0,0 +1,121 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { buildOpenCodeServerLink, type OpenCodeServer, type OpenCodeServerTestResult } from '@opencode-manager/shared/schemas' +import { OpenCodeServerDialog, type OpenCodeServerFormValues } from './OpenCodeServerDialog' + +const testResult: OpenCodeServerTestResult = { + ok: true, + version: '2.0.15', + versionSupported: true, + error: null, +} + +const pairingLink = buildOpenCodeServerLink({ + kind: 'opencode', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + pairing: { pairUrl: 'https://kitchen.example.com/pair', code: 'pairing-code-0123456789' }, +}) + +const savedServer: OpenCodeServer = { + id: 'srv_1', + name: 'Office', + baseUrl: 'https://office.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +function renderDialog(options: { + server?: OpenCodeServer | null + onSubmit?: (values: OpenCodeServerFormValues) => Promise +} = {}) { + const onSubmit = vi.fn(options.onSubmit ?? (async () => {})) + const onTest = vi.fn(async () => testResult) + const onOpenChange = vi.fn() + render( + , + ) + return { onSubmit, onTest, onOpenChange } +} + +describe('OpenCodeServerDialog', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + Element.prototype.scrollIntoView ??= () => {} + }) + + it('prefills name and URL from a pasted pairing link and sends pairing on save', async () => { + const user = userEvent.setup() + const { onSubmit } = renderDialog() + + await user.type(screen.getByLabelText('Pairing link'), pairingLink) + await user.click(screen.getByRole('button', { name: 'Use link' })) + + expect(screen.getByText('Pairing with Kitchen')).toBeInTheDocument() + expect(screen.getByLabelText('Name')).toHaveValue('Kitchen') + expect(screen.getByLabelText('URL')).toHaveValue('https://kitchen.example.com') + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Add server' })) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + password: '', + pairing: { pairUrl: 'https://kitchen.example.com/pair', code: 'pairing-code-0123456789' }, + }) + }) + + it('rejects an invalid pairing link with an inline error', async () => { + const user = userEvent.setup() + renderDialog() + + await user.type(screen.getByLabelText('Pairing link'), 'not-a-pairing-link') + await user.click(screen.getByRole('button', { name: 'Use link' })) + + expect(screen.getByRole('alert')).toHaveTextContent('That is not a valid OpenCode pairing link.') + expect(screen.queryByText(/Pairing with/)).not.toBeInTheDocument() + }) + + it('sends clearPassword when the stored password is explicitly cleared', async () => { + const user = userEvent.setup() + const { onSubmit } = renderDialog({ server: savedServer }) + + expect(screen.getByLabelText('Password')).toHaveValue('') + + await user.click(screen.getByLabelText('Clear stored password')) + await user.click(screen.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + name: 'Office', + baseUrl: 'https://office.example.com', + clearPassword: true, + pairing: null, + }) + }) + + it('shows the connection test result from the parent', async () => { + const user = userEvent.setup() + const { onTest } = renderDialog({ server: savedServer }) + + await user.click(screen.getByRole('button', { name: 'Test connection' })) + + await waitFor(() => expect(onTest).toHaveBeenCalledTimes(1)) + expect(await screen.findByText('Connected — OpenCode 2.0.15')).toBeInTheDocument() + expect(screen.getByText('Supported')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/OpenCodeServerDialog.tsx b/frontend/src/components/settings/OpenCodeServerDialog.tsx new file mode 100644 index 000000000..a5ab65114 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeServerDialog.tsx @@ -0,0 +1,448 @@ +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { AlertTriangle, CheckCircle2, Loader2, ScanLine, XCircle } from 'lucide-react' +import { + OPENCODE_SERVER_DEFAULT_USERNAME, + parseOpenCodeServerLink, + type OpenCodeServer, + type OpenCodeServerLink, + type OpenCodeServerTestResult, +} from '@opencode-manager/shared/schemas' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { QrCodeScanner } from './QrCodeScanner' + +export interface OpenCodeServerFormValues { + name: string + baseUrl: string + username: string + password: string + enabled: boolean + clearPassword: boolean + pairing: OpenCodeServerLink['pairing'] | null +} + +interface OpenCodeServerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + server?: OpenCodeServer | null + onSubmit: (values: OpenCodeServerFormValues) => Promise + onTest: (values: OpenCodeServerFormValues) => Promise +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0 + } catch { + return false + } +} + +const serverFormSchema = z.object({ + name: z.string().trim().min(1, 'Name is required').max(100, 'Name must be 100 characters or fewer'), + baseUrl: z + .string() + .trim() + .min(1, 'URL is required') + .refine(isHttpUrl, 'Enter a URL starting with http:// or https://'), + username: z + .string() + .trim() + .min(1, 'Username is required') + .max(100, 'Username must be 100 characters or fewer'), + password: z.string().max(1024, 'Password is too long'), + enabled: z.boolean(), +}) + +type ServerFormValues = z.infer + +function isPrivateHost(hostname: string): boolean { + const host = hostname.toLowerCase() + if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') return true + const octets = host.split('.').map((part) => Number(part)) + if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false + } + const [first, second] = octets + return ( + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) + ) +} + +function sendsCredentialsInClear(baseUrl: string): boolean { + try { + const url = new URL(baseUrl) + return url.protocol === 'http:' && !isPrivateHost(url.hostname) + } catch { + return false + } +} + +function buildDefaultValues(server?: OpenCodeServer | null): ServerFormValues { + return { + name: server?.name ?? '', + baseUrl: server?.baseUrl ?? '', + username: server?.username ?? OPENCODE_SERVER_DEFAULT_USERNAME, + password: '', + enabled: server?.enabled ?? true, + } +} + +function TestResultSummary({ result }: { result: OpenCodeServerTestResult }) { + if (!result.ok) { + return ( +
+ + {result.error ?? 'Connection failed'} +
+ ) + } + return ( +
+ + Connected — OpenCode {result.version ?? 'unknown version'} + {result.versionSupported ? ( + Supported + ) : ( + Unsupported version + )} +
+ ) +} + +export function OpenCodeServerDialog({ + open, + onOpenChange, + server, + onSubmit, + onTest, +}: OpenCodeServerDialogProps) { + const form = useForm({ + resolver: zodResolver(serverFormSchema), + defaultValues: buildDefaultValues(server), + }) + const [pairingLink, setPairingLink] = useState(null) + const [linkInput, setLinkInput] = useState('') + const [linkError, setLinkError] = useState(null) + const [isScannerOpen, setIsScannerOpen] = useState(false) + const [clearPassword, setClearPassword] = useState(false) + const [testResult, setTestResult] = useState(null) + const [testError, setTestError] = useState(null) + const [isTesting, setIsTesting] = useState(false) + + useEffect(() => { + if (!open) return + form.reset(buildDefaultValues(server)) + setPairingLink(null) + setLinkInput('') + setLinkError(null) + setClearPassword(false) + setTestResult(null) + setTestError(null) + }, [open, server, form]) + + const baseUrlValue = form.watch('baseUrl') + const transportWarning = sendsCredentialsInClear(baseUrlValue) + const isEditing = Boolean(server) + const isSubmitting = form.formState.isSubmitting + + const applyPairingLink = (text: string) => { + const link = parseOpenCodeServerLink(text) + if (!link) { + setLinkError('That is not a valid OpenCode pairing link.') + return + } + setPairingLink(link) + setLinkInput(text.trim()) + setLinkError(null) + setTestResult(null) + setTestError(null) + form.setValue('name', link.name, { shouldValidate: true }) + form.setValue('baseUrl', link.baseUrl, { shouldValidate: true }) + } + + const clearPairing = () => { + setPairingLink(null) + setLinkInput('') + setLinkError(null) + } + + const collectValues = (): OpenCodeServerFormValues => ({ + ...form.getValues(), + clearPassword, + pairing: pairingLink?.pairing ?? null, + }) + + const handleTest = async () => { + const isValid = await form.trigger(['baseUrl', 'username']) + if (!isValid) return + setIsTesting(true) + setTestError(null) + setTestResult(null) + try { + setTestResult(await onTest(collectValues())) + } catch (error) { + setTestError(error instanceof Error ? error.message : 'Connection test failed') + } finally { + setIsTesting(false) + } + } + + const handleSubmit = form.handleSubmit(async (values) => { + try { + await onSubmit({ ...values, clearPassword, pairing: pairingLink?.pairing ?? null }) + onOpenChange(false) + } catch (error) { + form.setError('root', { + message: error instanceof Error ? error.message : 'Failed to save the server', + }) + } + }) + + return ( + + + + {isEditing ? 'Edit server' : 'Add server'} + + Link a remote OpenCode server so its sessions appear in this Manager. + + + +
+
+
+ {!isEditing && ( +
+
+
+

Pair from QR code

+

+ Scan the code shown by the server, or paste its pairing link. +

+
+ +
+ +
+ +