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..7d981017c --- /dev/null +++ b/backend/src/db/opencode-servers.ts @@ -0,0 +1,161 @@ +import { randomBytes } from 'node:crypto' +import type { Database } from 'bun:sqlite' +import type { OpenCodeServer } from '@opencode-manager/shared/schemas' +import { trimUrlTrailingSlashes } from '@opencode-manager/shared/utils' +import { deleteSecret, readEncryptedSecret, writeEncryptedSecret } from './secrets' + +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 PASSWORD_SECRET_KEY_PREFIX = 'opencode_server:' +const PASSWORD_SECRET_KEY_SUFFIX = ':password' + +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 = '${PASSWORD_SECRET_KEY_PREFIX}' || s.id || '${PASSWORD_SECRET_KEY_SUFFIX}' +` + +function passwordSecretKey(serverId: string): string { + return `${PASSWORD_SECRET_KEY_PREFIX}${serverId}${PASSWORD_SECRET_KEY_SUFFIX}` +} + +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 { + return readEncryptedSecret(db, 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, + trimUrlTrailingSlashes(input.baseUrl), + input.username, + input.enabled ? 1 : 0, + now, + now, + ) + if (input.password) { + writeEncryptedSecret(db, passwordSecretKey(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(trimUrlTrailingSlashes(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) { + deleteSecret(db, passwordSecretKey(serverId)) + } else if (patch.password !== undefined) { + writeEncryptedSecret(db, passwordSecretKey(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) + deleteSecret(db, passwordSecretKey(serverId)) + return result.changes > 0 + }) + + return remove() +} diff --git a/backend/src/db/secrets.ts b/backend/src/db/secrets.ts new file mode 100644 index 000000000..50c539389 --- /dev/null +++ b/backend/src/db/secrets.ts @@ -0,0 +1,27 @@ +import type { Database } from 'bun:sqlite' +import { decryptSecret, encryptSecret } from '../utils/crypto' +import { logger } from '../utils/logger' + +export function readEncryptedSecret(db: Database, key: string): string | null { + const row = db.prepare('SELECT value FROM app_secrets WHERE key = ?').get(key) as { value: string } | undefined + if (!row) return null + + try { + return decryptSecret(row.value) + } catch (error) { + logger.error(`Failed to decrypt ${key}`, error) + return null + } +} + +export function writeEncryptedSecret(db: Database, key: string, value: 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(key, encryptSecret(value), now, now) +} + +export function deleteSecret(db: Database, key: string): void { + db.prepare('DELETE FROM app_secrets WHERE key = ?').run(key) +} 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..b86041b3d --- /dev/null +++ b/backend/src/routes/opencode-server-proxy.ts @@ -0,0 +1,149 @@ +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, NULL_BODY_STATUSES } 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']) + +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 } +} + +type UpstreamTarget = { basePath: string; url: URL } + +/** + * Builds the upstream URL for a resolved API path. The returned `basePath` is the + * connection base path without trailing slashes and is used to bound the final, + * URL-normalized pathname to the connection's own `/api/` space. + */ +function resolveUpstreamTarget( + connection: OpenCodeServerConnection, + path: string, + search: string, +): UpstreamTarget { + const url = new URL(connection.baseUrl) + const basePath = url.pathname.replace(/\/+$/, '') + url.pathname = `${basePath}${path}` + url.search = search + return { basePath, url } +} + +function isWithinUpstreamApiPath(basePath: string, pathname: string): boolean { + return pathname.startsWith(`${basePath}/api/`) +} + +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 upstream = resolveUpstreamTarget(connection, upstreamPath.path, url.search) + if (!isWithinUpstreamApiPath(upstream.basePath, upstream.url.pathname)) { + return c.json({ error: 'Invalid proxy path' }, 400) + } + + 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' + + try { + const upstreamResponse = await fetchFn(upstream.url.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} ${upstream.url.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..b6f2fb6c8 --- /dev/null +++ b/backend/src/routes/opencode-servers.ts @@ -0,0 +1,99 @@ +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) => { + 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-event-routing.ts b/backend/src/services/opencode-event-routing.ts new file mode 100644 index 000000000..877e2b949 --- /dev/null +++ b/backend/src/services/opencode-event-routing.ts @@ -0,0 +1,140 @@ +import { EventSource } from 'eventsource' +import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { logger } from '../utils/logger' + +const MULTILINE_PATTERN = /[\r\n]/ + +export type ReplayEventType = 'permission.asked' | 'form.created' | 'session.status' + +export type ReplayEventBuilder = (type: ReplayEventType, directory: string, data: unknown) => V2Event + +export interface RoutedOpenCodeEvent { + event: V2Event + payloadJson: string + directory: string | undefined + resolvedDirectory: string | undefined +} + +export interface PendingReplayRequest { + api: OpenCodeApi + directory: string + buildEvent: ReplayEventBuilder + logScope: string + logTarget: string +} + +export interface PendingReplayEvents { + permissions: V2Event[] + forms: V2Event[] +} + +export interface SessionStatusReplayPlan { + idle: Array<{ sessionID: string; directory: string }> + busy: string[] +} + +export type EventSourceInit = ConstructorParameters[1] + +/** + * Partitions tracked sessions into those that must be replayed as idle and the + * active sessions that must be replayed as busy. + */ +export function planSessionStatusReplay( + active: Record, + tracked: ReadonlyMap, +): SessionStatusReplayPlan { + const busy = Object.keys(active ?? {}) + const busySet = new Set(busy) + const idle: Array<{ sessionID: string; directory: string }> = [] + for (const [sessionID, directory] of tracked) { + if (!busySet.has(sessionID)) idle.push({ sessionID, directory }) + } + return { idle, busy } +} + +export function createReplayEventBuilder(idPrefix: string): ReplayEventBuilder { + let count = 0 + return (type, directory, data) => { + count += 1 + return { + id: `${idPrefix}${count}`, + created: Date.now(), + type, + location: { directory }, + data, + } as V2Event + } +} + +export function parseOpenCodeEvent(data: string, sessionDirectories: Map): RoutedOpenCodeEvent | null { + let event: V2Event + try { + event = JSON.parse(data) as V2Event + } catch { + return null + } + + if (!event?.type) return null + + 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 ? sessionDirectories.get(forkParentId) : undefined + const rememberedDirectory = directory ?? inheritedDirectory + const resolvedDirectory = rememberedDirectory ?? (sessionID ? sessionDirectories.get(sessionID) : undefined) + + if (sessionID && event.type === 'session.deleted') { + sessionDirectories.delete(sessionID) + } else if (sessionID && rememberedDirectory) { + sessionDirectories.set(sessionID, rememberedDirectory) + } + + return { event, payloadJson, directory, resolvedDirectory } +} + +export async function collectPendingReplayEvents(request: PendingReplayRequest): Promise { + const { api, directory, buildEvent, logScope, logTarget } = request + const [permissionsResult, formsResult] = await Promise.allSettled([ + api.permission.request.list(openCodeLocation(directory)), + api.form.list(openCodeLocation(directory)), + ]) + + const permissions: V2Event[] = [] + if (permissionsResult.status === 'rejected') { + logger.warn(`${logScope}: failed to fetch pending permissions for ${logTarget}: ${String(permissionsResult.reason)}`) + } else { + for (const permission of permissionsResult.value.data) { + permissions.push(buildEvent('permission.asked', directory, permission)) + } + } + + const forms: V2Event[] = [] + if (formsResult.status === 'rejected') { + logger.warn(`${logScope}: failed to fetch pending forms for ${logTarget}: ${String(formsResult.reason)}`) + } else { + for (const form of formsResult.value.data) { + forms.push(buildEvent('form.created', directory, { form })) + } + } + + return { permissions, forms } +} + +export function createAuthorizedEventSourceInit(authorization: string | null): EventSourceInit { + if (!authorization) return undefined + return { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...(init?.headers ?? {}), + Authorization: authorization, + }, + }), + } +} + +export function nextBackoffDelay(currentDelayMs: number, maxDelayMs: number): number { + return Math.min(currentDelayMs * 2, maxDelayMs) +} 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..168dcb818 --- /dev/null +++ b/backend/src/services/opencode-server-registry.test.ts @@ -0,0 +1,604 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import type { OpenCodeServer } from '@opencode-manager/shared/schemas' +import { encryptOpenCodePairingCredentials, verifyOpenCodePairingProof } from '@opencode-manager/shared/opencode' +import { migrate } from '../db/migration-runner' +import { allMigrations } from '../db/migrations' +import { decryptSecret } from '../utils/crypto' +import { + OpenCodeServerCredentialsRequiredError, + 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 +} + +function flipLastByte(value: string): string { + const bytes = Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + const last = bytes.length - 1 + bytes[last] = (bytes[last] ?? 0) ^ 0x01 + return bytes.toString('base64url') +} + +describe('OpenCodeServerRegistry', () => { + let db: Database + let localClient: OpenCodeClient + let fetchFn: FetchMock + let registry: OpenCodeServerRegistry + + type CreateInput = Parameters[0] + + function createServer(overrides: Partial = {}): Promise { + return registry.create({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + username: 'opencode', + enabled: true, + ...overrides, + }) + } + + 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 createServer({ + 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 createServer({ name: 'Open', baseUrl: 'http://localhost:4096' }) + + expect(server.hasPassword).toBe(false) + expect(readStoredSecret(db, server.id)).toBeUndefined() + }) + + it('redeems a pairing code and stores the returned credentials', async () => { + const requests: Array<{ nonce: string; proof: string }> = [] + fetchFn.mockImplementationOnce(async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { nonce: string; proof: string } + requests.push(body) + return jsonResponse(await encryptOpenCodePairingCredentials({ + code: PAIR_CODE, + nonce: body.nonce, + credentials: { username: 'alice', password: 'paired-pass' }, + })) + }) + + const server = await createServer({ + 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(requests).toHaveLength(1) + expect(Object.keys(requests[0]!).sort()).toEqual(['nonce', 'proof']) + expect(String(init?.body)).not.toContain(PAIR_CODE) + await expect(verifyOpenCodePairingProof(PAIR_CODE, requests[0]!.nonce, requests[0]!.proof)).resolves.toBe(true) + }) + + it('rejects a tampered encrypted payload', async () => { + fetchFn.mockImplementationOnce(async (_url, init) => { + const { nonce } = JSON.parse(String(init?.body)) as { nonce: string } + const encrypted = await encryptOpenCodePairingCredentials({ + code: PAIR_CODE, + nonce, + credentials: { username: 'alice', password: 'paired-pass' }, + }) + return jsonResponse({ ...encrypted, ciphertext: flipLastByte(encrypted.ciphertext) }) + }) + + const error = await createServer({ + 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 { status?: number }).status).toBe(502) + expect(registry.list()).toEqual([]) + }) + + it('rejects a response without an encrypted payload', async () => { + fetchFn.mockResolvedValueOnce(jsonResponse({ username: 'alice', password: 'paired-pass' })) + + const error = await createServer({ + name: 'Paired', + baseUrl: 'https://remote.example.com', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeServerPairingError) + expect(registry.list()).toEqual([]) + }) + + it('rejects decrypted credentials with an empty username', async () => { + fetchFn.mockImplementationOnce(async (_url, init) => { + const { nonce } = JSON.parse(String(init?.body)) as { nonce: string } + return jsonResponse(await encryptOpenCodePairingCredentials({ + code: PAIR_CODE, + nonce, + credentials: { username: '', password: 'paired-pass' }, + })) + }) + + const error = await createServer({ + name: 'Paired', + baseUrl: 'https://remote.example.com', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeServerPairingError) + expect(registry.list()).toEqual([]) + }) + + it('fails with a pairing error when the pairing endpoint rejects the code', async () => { + fetchFn.mockResolvedValueOnce(new Response(null, { status: 403 })) + + await expect( + createServer({ + 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 createServer({ + 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 createServer({ + 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 createServer({ + 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, + ) + }) + + it('rejects an origin change that would reuse the stored password', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const error = await registry + .update(server.id, { baseUrl: 'https://other-host.example.com' }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(OpenCodeServerCredentialsRequiredError) + expect((error as { status?: number }).status).toBe(400) + expect((error as Error).message).toBe( + 'Changing the server address requires re-entering the password or pairing again', + ) + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('super-secret') + expect(registry.get(server.id)?.baseUrl).toBe('https://studio.example.com') + }) + + it('allows a path change on the same origin without new credentials', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const updated = await registry.update(server.id, { baseUrl: 'https://studio.example.com/opencode/' }) + + expect(updated.baseUrl).toBe('https://studio.example.com/opencode') + expect(updated.hasPassword).toBe(true) + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('super-secret') + }) + + it('allows an origin change with a new password', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const updated = await registry.update(server.id, { + baseUrl: 'https://other-host.example.com', + password: 'next-secret', + }) + + expect(updated.baseUrl).toBe('https://other-host.example.com') + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('next-secret') + }) + + it('allows an origin change with an explicit password clear', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const updated = await registry.update(server.id, { + baseUrl: 'https://other-host.example.com', + password: null, + }) + + expect(updated.baseUrl).toBe('https://other-host.example.com') + expect(updated.hasPassword).toBe(false) + expect(readStoredSecret(db, server.id)).toBeUndefined() + }) + + it('allows an origin change when the server has no stored password', async () => { + const server = await createServer({ name: 'Open', baseUrl: 'https://studio.example.com' }) + + const updated = await registry.update(server.id, { baseUrl: 'https://other-host.example.com' }) + + expect(updated.baseUrl).toBe('https://other-host.example.com') + expect(updated.hasPassword).toBe(false) + }) + + it('allows an origin change with pairing credentials', async () => { + fetchFn.mockImplementationOnce(async (_url, init) => { + const { nonce } = JSON.parse(String(init?.body)) as { nonce: string } + return jsonResponse(await encryptOpenCodePairingCredentials({ + code: PAIR_CODE, + nonce, + credentials: { username: 'alice', password: 'paired-pass' }, + })) + }) + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + + const updated = await registry.update(server.id, { + baseUrl: 'https://other-host.example.com', + pairing: { pairUrl: PAIR_URL, code: PAIR_CODE }, + }) + + expect(updated.baseUrl).toBe('https://other-host.example.com') + expect(updated.username).toBe('alice') + expect(decryptSecret(readStoredSecret(db, server.id)!.value)).toBe('paired-pass') + }) + }) + + describe('delete', () => { + it('removes the server row and its stored password', async () => { + const server = await createServer({ + 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 createServer({ + 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 createServer({ + 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 createServer({ + 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 createServer({ + 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', username: 'opencode' }) + + 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 createServer({ + 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('reuses the stored password for a draft on the same origin', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + + const result = await registry.test({ + baseUrl: 'https://studio.example.com/opencode', + username: 'alice', + serverId: server.id, + }) + + expect(result.ok).toBe(true) + expect(fetchFn.mock.calls[0]?.[0]).toBe('https://studio.example.com/opencode/api/info') + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`) + }) + + it('does not send the stored password to a draft on a different origin', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + + const result = await registry.test({ + baseUrl: 'https://relocated.example.com', + username: 'alice', + serverId: server.id, + }) + + expect(result.ok).toBe(true) + expect(fetchFn.mock.calls[0]?.[0]).toBe('https://relocated.example.com/api/info') + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBeNull() + }) + + it('prefers an explicit draft password over the stored one', async () => { + const server = await createServer({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + password: 'super-secret', + }) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + + await registry.test({ + baseUrl: 'https://studio.example.com', + username: 'alice', + password: 'draft-secret', + serverId: server.id, + }) + + const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) + expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:draft-secret')}`) + }) + + it('throws a not-found error for an unknown server id on a draft', async () => { + await expect( + registry.test({ + baseUrl: 'https://studio.example.com', + username: 'opencode', + serverId: 'srv_missing', + }), + ).rejects.toBeInstanceOf(OpenCodeServerNotFoundError) + expect(fetchFn).not.toHaveBeenCalled() + }) + + 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', username: 'opencode' }) + + 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', username: 'opencode' }) + + 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 createServer({ name: 'Studio', baseUrl: 'https://studio.example.com' }) + registry.delete(server.id) + unsubscribe() + await createServer({ 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..9c6f27f22 --- /dev/null +++ b/backend/src/services/opencode-server-registry.ts @@ -0,0 +1,352 @@ +import type { Database } from 'bun:sqlite' +import { z } from 'zod' +import { + CreateOpenCodeServerRequestSchema, + LOCAL_OPENCODE_SERVER_ID, + TestOpenCodeServerRequestSchema, + UpdateOpenCodeServerRequestSchema, + type OpenCodeServer, + type OpenCodeServerPairing, + type OpenCodeServerTestResult, +} from '@opencode-manager/shared/schemas' +import { + buildOpenCodeBasicAuth, + createOpenCodePairingNonce, + createOpenCodePairingProof, + decryptOpenCodePairingCredentials, + isSupportedOpenCodeVersion, +} from '@opencode-manager/shared/opencode' +import { haveSameUrlOrigin, trimUrlTrailingSlashes } from '@opencode-manager/shared/utils' +import { + deleteOpenCodeServer, + getOpenCodeServer, + insertOpenCodeServer, + listOpenCodeServers, + readOpenCodeServerPassword, + updateOpenCodeServer, +} from '../db/opencode-servers' +import { logger } from '../utils/logger' +import { ServiceError } from '../utils/service-error' +import { FetchOpenCodeClient, type OpenCodeClient } from './opencode/client' + +const PAIRING_TIMEOUT_MS = 10_000 +const TEST_TIMEOUT_MS = 5_000 + +const PairingEncryptedPayloadSchema = z.object({ + iv: z.string().min(1), + ciphertext: z.string().min(1), +}) + +type CreateServerInput = z.output +type UpdateServerInput = z.output +type TestServerInput = z.output +type TestServerSource = TestServerInput | { serverId: string } + +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 OpenCodeServerPairingError extends OpenCodeServerError { + constructor(message: string) { + super(message, 502) + this.name = 'OpenCodeServerPairingError' + } +} + +/** Raised when a server address change would reuse stored credentials on a different origin. */ +export class OpenCodeServerCredentialsRequiredError extends OpenCodeServerError { + constructor() { + super('Changing the server address requires re-entering the password or pairing again', 400) + this.name = 'OpenCodeServerCredentialsRequiredError' + } +} + +function reusesStoredPasswordOnNewOrigin(server: OpenCodeServer, request: UpdateServerInput): boolean { + if (request.baseUrl === undefined) return false + if (!server.hasPassword) return false + if (request.pairing) return false + if (request.password !== undefined) return false + return !haveSameUrlOrigin(server.baseUrl, request.baseUrl) +} + +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(request: CreateServerInput): Promise { + 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, request: UpdateServerInput): Promise { + const existing = this.requireServer(serverId) + if (reusesStoredPasswordOnNewOrigin(existing, request)) { + throw new OpenCodeServerCredentialsRequiredError() + } + + 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 connection = this.getConnection(serverId) + const client = new FetchOpenCodeClient({ + baseUrl: connection.baseUrl, + basicAuth: connection.authorization, + 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 : buildOpenCodeBasicAuth(password, server.username), + } + } + + async test(input: TestServerSource): Promise { + const target = this.resolveTestTarget(input) + const headers: Record = { Accept: 'application/json' } + if (target.password !== null) { + headers.Authorization = buildOpenCodeBasicAuth(target.password, target.username) + } + + 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: TestServerSource): TestTarget { + if (!('baseUrl' in input)) { + const server = this.requireServer(input.serverId) + return { + baseUrl: server.baseUrl, + username: server.username, + password: readOpenCodeServerPassword(this.db, input.serverId), + } + } + + const baseUrl = trimUrlTrailingSlashes(input.baseUrl) + if (input.password !== undefined || input.serverId === undefined) { + return { + baseUrl, + username: input.username, + password: input.password ?? null, + } + } + + const server = this.requireServer(input.serverId) + return { + baseUrl, + username: input.username, + password: haveSameUrlOrigin(server.baseUrl, baseUrl) + ? readOpenCodeServerPassword(this.db, input.serverId) + : 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 }> { + const nonce = createOpenCodePairingNonce() + const proof = await createOpenCodePairingProof(pairing.code, nonce) + + let response: Response + try { + response = await this.fetchFn(pairing.pairUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nonce, proof }), + 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 = PairingEncryptedPayloadSchema.safeParse(payload) + if (!parsed.success) { + throw new OpenCodeServerPairingError('Pairing returned an invalid response') + } + + try { + return await decryptOpenCodePairingCredentials({ + code: pairing.code, + nonce, + iv: parsed.data.iv, + ciphertext: parsed.data.ciphertext, + }) + } catch { + throw new OpenCodeServerPairingError('Pairing returned invalid credentials') + } + } + + 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) + } + } + } +} diff --git a/backend/src/services/opencode/client.ts b/backend/src/services/opencode/client.ts index 98777a4af..8dc63166a 100644 --- a/backend/src/services/opencode/client.ts +++ b/backend/src/services/opencode/client.ts @@ -1,7 +1,7 @@ import { logger } from '../../utils/logger' import { ENV } from '@opencode-manager/shared/config/env' import { createOpenCodeApi, type OpenCodeApi } from '@opencode-manager/shared/opencode' -import { filterProxyHeaders } from '../../utils/proxy-headers' +import { filterProxyHeaders, NULL_BODY_STATUSES } from '../../utils/proxy-headers' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './auth' import { getOpenCodeUpstreamBaseUrl, withDefaultOpenCodeDirectory } from './upstream' @@ -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() @@ -76,8 +81,7 @@ export class FetchOpenCodeClient implements OpenCodeClient { const filteredHeaders = filterProxyHeaders(response.headers) - const noBodyStatuses = new Set([101, 204, 205, 304]) - if (noBodyStatuses.has(response.status)) { + if (NULL_BODY_STATUSES.has(response.status)) { return new Response(null, { status: response.status, statusText: response.statusText, diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts new file mode 100644 index 000000000..0d48cc127 --- /dev/null +++ b/backend/src/services/remote-server-events.ts @@ -0,0 +1,428 @@ +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 { type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { logger } from '../utils/logger' +import type { + OpenCodeServerChange, + OpenCodeServerConnection, + OpenCodeServerRegistry, +} from './opencode-server-registry' +import { + collectPendingReplayEvents, + createAuthorizedEventSourceInit, + createReplayEventBuilder, + nextBackoffDelay, + parseOpenCodeEvent, + planSessionStatusReplay, + type ReplayEventBuilder, +} from './opencode-event-routing' +import type { RemoteReplayHandler } from './sse-aggregator' + +const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE + +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): 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 + versionProbeToken: number + sessionDirectories: Map +} + +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 versionProbeSequence = 0 + private readonly buildReplayEvent: ReplayEventBuilder = createReplayEventBuilder('ocm_replay_remote_') + + 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 events = await collectPendingReplayEvents({ + api, + directory, + buildEvent: this.buildReplayEvent, + logScope: 'remote replay', + logTarget: key, + }) + + this.emitReplayEventsToClient(clientId, serverId, directory, events.permissions) + this.emitReplayEventsToClient(clientId, serverId, directory, events.forms) + } + + 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 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 = createAuthorizedEventSourceInit(authorization) + + 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) + void this.refreshVersion(serverId) + void this.replaySessionStatuses(serverId, source) + } + + 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 = nextBackoffDelay(current.reconnectDelay, this.maxReconnectDelayMs) + this.connectServer(serverId) + }, delay) + } + + private handleMessage(serverId: string, data: string): void { + const stream = this.streams.get(serverId) + if (!stream) return + + const routed = parseOpenCodeEvent(data, stream.sessionDirectories) + if (!routed) return + + try { + this.aggregator.deliverRemoteEnvelope(serverId, routed.resolvedDirectory ?? null, routed.payloadJson) + } catch (error) { + logger.error(`Remote event stream failed to handle ${routed.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 + stream.version = null + stream.versionRequested = false + stream.versionProbeToken = 0 + } + this.connectServer(change.serverId) + } + + private isStreamCurrent(serverId: string, stream: RemoteServerStream, source: RemoteEventSource): boolean { + return this.streams.get(serverId) === stream && stream.source === source && stream.connected + } + + private async replaySessionStatuses(serverId: string, source: RemoteEventSource): Promise { + const stream = this.streams.get(serverId) + if (!stream || !this.isStreamCurrent(serverId, stream, source)) return + + let api: OpenCodeApi + try { + api = this.registry.getClient(serverId).api + } catch (error) { + logger.warn(`remote status replay: no client for ${serverId}: ${String(error)}`) + return + } + + let active: Record + try { + active = await api.session.active() + } catch (error) { + logger.warn(`remote status replay: failed to fetch active sessions for ${serverId}: ${String(error)}`) + return + } + + if (!this.isStreamCurrent(serverId, stream, source)) return + + const plan = planSessionStatusReplay(active, stream.sessionDirectories) + let replayed = 0 + + for (const { sessionID, directory } of plan.idle) { + const event = this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } }) + this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + replayed++ + } + + for (const sessionID of plan.busy) { + const directory = stream.sessionDirectories.get(sessionID) ?? null + const event = this.buildReplayEvent('session.status', directory ?? '', { sessionID, status: { type: 'busy' } }) + this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + replayed++ + } + + if (replayed > 0) { + logger.info(`remote status replay: re-emitted ${replayed} session status(es) for ${serverId}`) + } + } + + private async refreshVersion(serverId: string): Promise { + const stream = this.streams.get(serverId) + if (!stream || stream.versionRequested) return + + this.versionProbeSequence += 1 + const token = this.versionProbeSequence + stream.versionRequested = true + stream.versionProbeToken = token + + let version: string | null = null + try { + const result = await this.registry.test({ serverId }) + version = result.ok && result.version ? result.version : null + } catch (error) { + logger.warn(`Failed to read the version of OpenCode server ${serverId}: ${String(error)}`) + } + + const current = this.streams.get(serverId) + if (!current || current.versionProbeToken !== token) return + if (version) { + current.version = version + return + } + current.versionRequested = false + } + + 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, + versionProbeToken: 0, + 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/settings.ts b/backend/src/services/settings.ts index 7dd427d79..cee87177d 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -2,7 +2,8 @@ import { Database } from 'bun:sqlite' import { randomBytes } from 'node:crypto' import { logger } from '../utils/logger' import { parseJsonc } from '@opencode-manager/shared/utils' -import { encryptSecret, decryptSecret } from '../utils/crypto' +import { encryptSecret } from '../utils/crypto' +import { deleteSecret, readEncryptedSecret, writeEncryptedSecret } from '../db/secrets' import { ENV } from '@opencode-manager/shared/config/env' import type { UserPreferences, @@ -142,16 +143,7 @@ export class SettingsService { } private readSecret(key: string): string | null { - const row = this.db.prepare('SELECT value FROM app_secrets WHERE key = ?').get(key) as { value: string } | undefined - if (!row) { - return null - } - try { - return decryptSecret(row.value) - } catch (error) { - logger.error(`Failed to decrypt ${key}`, error) - return null - } + return readEncryptedSecret(this.db, key) } getOrCreateManagedOpenCodeServerPassword(): string { @@ -218,15 +210,10 @@ export class SettingsService { } setOpenCodeServerPassword(password: string): void { - const now = Date.now() - const encrypted = encryptSecret(password) - this.db.prepare(` - INSERT INTO app_secrets (key, value, created_at, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `).run(CUSTOM_OPENCODE_SERVER_PASSWORD_KEY, encrypted, now, now) + writeEncryptedSecret(this.db, CUSTOM_OPENCODE_SERVER_PASSWORD_KEY, password) } clearOpenCodeServerPassword(): void { - this.db.prepare('DELETE FROM app_secrets WHERE key = ?').run(CUSTOM_OPENCODE_SERVER_PASSWORD_KEY) + deleteSecret(this.db, CUSTOM_OPENCODE_SERVER_PASSWORD_KEY) } } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 4b5cedd50..097b24459 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -1,15 +1,27 @@ import { EventSource } from 'eventsource' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' -import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { buildServerDirectoryKey, LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-manager/shared/schemas' +import { type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' import { getOpenCodeUpstreamBaseUrl } from './opencode/upstream' import { encodeSSEFrame } from '../utils/sse-frame' +import { + collectPendingReplayEvents, + createAuthorizedEventSourceInit, + createReplayEventBuilder, + nextBackoffDelay, + parseOpenCodeEvent, + planSessionStatusReplay, + type ReplayEventBuilder, +} from './opencode-event-routing' 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 @@ -30,14 +42,12 @@ export interface ScheduledSessionRef { directory: string } -type ReplayEventType = 'permission.asked' | 'form.created' | 'session.status' - const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE -const MULTILINE_PATTERN = /[\r\n]/ const LOCATION_CATALOG_EVENT_TYPES = new Set(['command.updated', 'agent.updated', 'skill.updated', 'model.updated', 'provider.updated', 'integration.updated']) -function serializeEnvelope(directory: string | null, payloadJson: string): string { - return '{"directory":' + JSON.stringify(directory) + ',"payload":' + payloadJson + '}' +function serializeEnvelope(directory: string | null, payloadJson: string, serverId?: string): string { + const serverField = serverId === undefined ? '' : ',"serverId":' + JSON.stringify(serverId) + return '{"directory":' + JSON.stringify(directory) + serverField + ',"payload":' + payloadJson + '}' } class SSEAggregator { @@ -58,7 +68,8 @@ class SSEAggregator { private pendingActionsFetcher: PendingActionsFetcher | null = null private passwordResolver: OpenCodePasswordResolver | null = null private scheduledSessionsResolver: (() => ScheduledSessionRef[]) | null = null - private replayEventCount = 0 + private remoteReplayHandler: RemoteReplayHandler | null = null + private readonly buildReplayEvent: ReplayEventBuilder = createReplayEventBuilder('ocm_replay_') private constructor() {} @@ -74,6 +85,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 +133,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 +142,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 +167,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 +203,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 @@ -199,26 +255,16 @@ class SSEAggregator { directory: string, fetcher: PendingActionsFetcher, ): Promise { - const [permissionsResult, formsResult] = await Promise.allSettled([ - fetcher.api.permission.request.list(openCodeLocation(directory)), - fetcher.api.form.list(openCodeLocation(directory)), - ]) - - if (permissionsResult.status === 'rejected') { - logger.warn(`replay: failed to fetch pending permissions for ${directory}: ${String(permissionsResult.reason)}`) - } else { - this.emitReplayEventsToClient(clientId, directory, permissionsResult.value.data.map(request => - this.buildReplayEvent('permission.asked', directory, request) - )) - } + const events = await collectPendingReplayEvents({ + api: fetcher.api, + directory, + buildEvent: this.buildReplayEvent, + logScope: 'replay', + logTarget: directory, + }) - if (formsResult.status === 'rejected') { - logger.warn(`replay: failed to fetch pending forms for ${directory}: ${String(formsResult.reason)}`) - } else { - this.emitReplayEventsToClient(clientId, directory, formsResult.value.data.map(form => - this.buildReplayEvent('form.created', directory, { form }) - )) - } + this.emitReplayEventsToClient(clientId, directory, events.permissions) + this.emitReplayEventsToClient(clientId, directory, events.forms) } private emitReplayEventsToClient(clientId: string, directory: string, events: V2Event[]): void { @@ -252,18 +298,16 @@ class SSEAggregator { } const tracked = this.getTrackedSessions() - const runningIDs = Object.keys(running ?? {}) - const runningSet = new Set(runningIDs) + const plan = planSessionStatusReplay(running, tracked) let replayed = 0 - for (const [sessionID, directory] of tracked) { - if (runningSet.has(sessionID)) continue + for (const { sessionID, directory } of plan.idle) { this.deliverEvent(directory, this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } })) replayed++ } - const runningDirectories = await Promise.all(runningIDs.map(async (sessionID) => ({ + const runningDirectories = await Promise.all(plan.busy.map(async (sessionID) => ({ sessionID, directory: tracked.get(sessionID) ?? await this.resolveSessionDirectory(fetcher, sessionID), }))) @@ -302,17 +346,6 @@ class SSEAggregator { } } - private buildReplayEvent(type: ReplayEventType, directory: string, data: unknown): V2Event { - this.replayEventCount += 1 - return { - id: `ocm_replay_${this.replayEventCount}`, - created: Date.now(), - type, - location: { directory }, - data, - } as V2Event - } - private async connectUpstream(): Promise { if (!this.started) return if (this.upstream) { @@ -330,18 +363,7 @@ class SSEAggregator { if (!this.started) return - const init: ConstructorParameters[1] = authHeader - ? { - fetch: (input, fetchInit) => - fetch(input, { - ...fetchInit, - headers: { - ...(fetchInit?.headers ?? {}), - Authorization: authHeader, - }, - }), - } - : undefined + const init: ConstructorParameters[1] = createAuthorizedEventSourceInit(authHeader) const es = new EventSource(url, init) this.upstream = es @@ -393,7 +415,7 @@ class SSEAggregator { if (!this.started || this.reconnectTimeout) return this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null - this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS) + this.reconnectDelay = nextBackoffDelay(this.reconnectDelay, MAX_RECONNECT_DELAY_MS) void this.connectUpstream() }, this.reconnectDelay) } @@ -409,31 +431,12 @@ class SSEAggregator { } private handleUpstreamMessage(data: string): void { - let event: V2Event - try { - event = JSON.parse(data) as V2Event - } catch { - return - } - - if (!event?.type) return + const routed = parseOpenCodeEvent(data, this.sessionDirectories) + if (!routed) 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 ? this.sessionDirectories.get(forkParentId) : undefined + const { event, payloadJson, directory, resolvedDirectory } = routed try { - const rememberedDirectory = directory ?? inheritedDirectory - const resolvedDirectory = rememberedDirectory ?? (sessionID ? this.sessionDirectories.get(sessionID) : undefined) - - if (sessionID && event.type === 'session.deleted') { - this.sessionDirectories.delete(sessionID) - } else if (sessionID && rememberedDirectory) { - this.sessionDirectories.set(sessionID, rememberedDirectory) - } - if (directory) { if (LOCATION_CATALOG_EVENT_TYPES.has(event.type)) { this.deliverResolvedEvent(directory, event, payloadJson) @@ -486,12 +489,65 @@ class SSEAggregator { const client = this.clients.get(clientId) if (!client) continue frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson)) + this.writeFrame(client, frame, 'Failed to send to client') + } + } + + private writeFrame(client: SSEClient, frame: Uint8Array, errorLabel: string): void { + try { + client.writeFrame(frame) + } catch (error) { + logger.error(`${errorLabel} ${client.id}:`, error) + } + } + + 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', serializeEnvelope(directory, payloadJson, serverId)) + for (const clientId of clientIds) { + const client = this.clients.get(clientId) + if (!client) continue + this.writeFrame(client, frame, 'Failed to send remote event to client') + } + } + + 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', serializeEnvelope(directory, payloadJson, serverId)) + this.writeFrame(client, frame, 'Failed to send remote event to client') + } + + sendRemoteResync(serverId: string): void { + const data = JSON.stringify({ timestamp: Date.now(), serverId }) + for (const clientId of this.clientIdsForServer(serverId)) { + const client = this.clients.get(clientId) + if (!client) continue try { - client.writeFrame(frame) - } catch (error) { - logger.error(`Failed to send to client ${client.id}:`, error) + 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 { @@ -652,6 +708,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/src/utils/proxy-headers.ts b/backend/src/utils/proxy-headers.ts index 6dc9fa6ac..3db799bf5 100644 --- a/backend/src/utils/proxy-headers.ts +++ b/backend/src/utils/proxy-headers.ts @@ -1,3 +1,5 @@ +export const NULL_BODY_STATUSES: ReadonlySet = new Set([101, 204, 205, 304]) + const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', 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..fb7cea9ae --- /dev/null +++ b/backend/test/routes/opencode-server-proxy.test.ts @@ -0,0 +1,247 @@ +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 400 for repeated encoded traversal that survives segment decoding', async () => { + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request('/api/opencode/servers/srv_1/api/%25252e%25252e/config') + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid proxy path' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 400 when a prefixed base URL would be escaped by path normalization', async () => { + getConnectionMock.mockReturnValue({ baseUrl: 'http://remote.example:4096/prefix', authorization: basicAuth }) + const fetchMock = createFetchMock() + const app = buildApp(fetchMock as unknown as typeof fetch) + + const res = await app.request( + '/api/opencode/servers/srv_1/api/session/%25252e%25252e/%25252e%25252e/config', + ) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid proxy path' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('proxies a normal API path under a prefixed base URL unchanged', async () => { + getConnectionMock.mockReturnValue({ baseUrl: 'http://remote.example:4096/prefix', authorization: basicAuth }) + const fetchMock = createFetchMock() + 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(200) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://remote.example:4096/prefix/api/session') + }) + + 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..7a2d28990 --- /dev/null +++ b/backend/test/routes/opencode-servers.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenCodeServerRoutes } from '../../src/routes/opencode-servers' +import { + OpenCodeServerCredentialsRequiredError, + 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.create).toHaveBeenCalledWith({ + name: 'Studio', + baseUrl: 'https://studio.example.com', + username: 'opencode', + password: 'super-secret', + enabled: true, + }) + 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 400 when an origin change would reuse the stored password', async () => { + registry.update.mockRejectedValue(new OpenCodeServerCredentialsRequiredError()) + + const response = await app.request('/srv_1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseUrl: 'https://other-host.example.com' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Changing the server address requires re-entering the password or pairing again', + }) + }) + + 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('tests a draft server against a saved server id', 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/opencode', serverId: 'srv_1' }), + }) + + expect(response.status).toBe(200) + expect(registry.test).toHaveBeenCalledWith({ + baseUrl: 'https://studio.example.com/opencode', + username: 'opencode', + serverId: 'srv_1', + }) + }) + + it('returns 404 when testing a draft against an unknown server id', async () => { + registry.test.mockRejectedValue(new OpenCodeServerNotFoundError('srv_missing')) + + const response = await app.request('/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseUrl: 'https://studio.example.com', serverId: 'srv_missing' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'OpenCode server not found: srv_missing' }) + }) + + 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 }) + }) +}) 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..afe98cd84 --- /dev/null +++ b/backend/test/services/remote-server-events.test.ts @@ -0,0 +1,918 @@ +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, OpenCodeServerTestResult } 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 + active?: 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)] ?? [], + })), + }, + session: { + active: vi.fn(async () => fixture.active ?? {}), + }, + } 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() + }) + + async function openThenReconnect(): Promise { + hub.start() + sources.sources[0]!.open() + await flushPromises() + sources.sources[0]!.fail({ message: 'connection lost' }) + await vi.advanceTimersByTimeAsync(1000) + sources.sources[1]!.open() + await flushPromises() + } + + 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)).toEqual({ timestamp: expect.any(Number), serverId: 'srv_a' }) + 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('probes the version again after a rejected probe', async () => { + vi.useFakeTimers() + vi.mocked(fake.registry.test) + .mockRejectedValueOnce(new Error('no info endpoint')) + .mockResolvedValueOnce({ ok: true, version: '2.0.15', versionSupported: true, error: null }) + + await openThenReconnect() + + expect(fake.registry.test).toHaveBeenCalledTimes(2) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: true, version: '2.0.15', error: null }, + ]) + }) + + it('probes the version again after a probe without a version', async () => { + vi.useFakeTimers() + vi.mocked(fake.registry.test) + .mockResolvedValueOnce({ ok: false, version: null, versionSupported: false, error: 'unreachable' }) + .mockResolvedValueOnce({ ok: true, version: '2.0.15', versionSupported: true, error: null }) + + await openThenReconnect() + + expect(fake.registry.test).toHaveBeenCalledTimes(2) + expect(hub.getStatuses()).toEqual([ + { serverId: 'srv_a', connected: true, version: '2.0.15', error: null }, + ]) + }) + + it('clears the version and probes again when the server is updated', async () => { + hub.start() + sources.sources[0]!.open() + await flushPromises() + expect(hub.getStatuses()[0]?.version).toBe('2.0.15') + + vi.mocked(fake.registry.test).mockResolvedValueOnce({ ok: true, version: '2.1.0', versionSupported: true, error: null }) + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + + expect(hub.getStatuses()[0]?.version).toBeNull() + + sources.sources[1]!.open() + await flushPromises() + + expect(fake.registry.test).toHaveBeenCalledTimes(2) + expect(hub.getStatuses()[0]?.version).toBe('2.1.0') + }) + + it('ignores a probe that started before the server was updated', async () => { + let resolveFirstProbe: (result: OpenCodeServerTestResult) => void = () => {} + vi.mocked(fake.registry.test).mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstProbe = resolve + })) + + hub.start() + sources.sources[0]!.open() + await flushPromises() + expect(fake.registry.test).toHaveBeenCalledTimes(1) + + vi.mocked(fake.registry.test).mockResolvedValueOnce({ ok: true, version: '2.1.0', versionSupported: true, error: null }) + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + sources.sources[1]!.open() + await flushPromises() + + resolveFirstProbe({ ok: true, version: '1.0.0', versionSupported: true, error: null }) + await flushPromises() + + expect(fake.registry.test).toHaveBeenCalledTimes(2) + expect(hub.getStatuses()[0]?.version).toBe('2.1.0') + }) + + 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 }, + ]) + }) +}) + +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) + }) +}) + +describe('RemoteServerEventHub session status replay', () => { + let hub: RemoteServerEventHub + let fake: FakeRegistry + let sources: ReturnType + + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + sources = createSourceFactory() + }) + + afterEach(() => { + hub.shutdown() + sseAggregator.shutdown() + }) + + function createHub(fixture: ApiFixture = {}): void { + fake = createRegistry([SERVER_A], fixture) + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + } + + function statusFrames(client: CapturedClient) { + return client.frames.map(parseFrame).filter(frame => frame.payload.type === 'session.status') + } + + async function trackSessionThenReconnect(sessionID: string, directory: string): Promise { + hub.start() + const first = sources.sources[0]! + first.open() + await flushPromises() + first.message(remoteEvent('session.created', { sessionID }, directory)) + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + await flushPromises() + } + + it('replays idle for a tracked session that is no longer active', async () => { + createHub() + await trackSessionThenReconnect('ses_1', '/repo') + const client = createCapturingClient() + sseAggregator.addClient('status-idle', client.callback, client.writeFrame, ['srv_a|/repo']) + + sources.sources[1]!.open() + await flushPromises() + + const statuses = statusFrames(client) + expect(statuses).toHaveLength(1) + expect(statuses[0]?.serverId).toBe('srv_a') + expect(statuses[0]?.directory).toBe('/repo') + expect(statuses[0]?.payload.id).toMatch(/^ocm_replay_remote_/) + expect(statuses[0]?.payload.data).toEqual({ sessionID: 'ses_1', status: { type: 'idle' } }) + }) + + it('replays busy for active sessions, using a null directory when the session is untracked', async () => { + createHub({ active: { ses_1: { type: 'running' }, ses_2: { type: 'running' } } }) + await trackSessionThenReconnect('ses_1', '/repo') + const client = createCapturingClient() + sseAggregator.addClient('status-busy', client.callback, client.writeFrame, ['srv_a|/repo']) + + sources.sources[1]!.open() + await flushPromises() + + const statuses = statusFrames(client) + expect(statuses.map(frame => frame.directory)).toEqual(['/repo', null]) + expect(statuses.map(frame => frame.payload.data)).toEqual([ + { sessionID: 'ses_1', status: { type: 'busy' } }, + { sessionID: 'ses_2', status: { type: 'busy' } }, + ]) + }) + + it('does not replay or throw when the active session lookup fails', async () => { + createHub() + vi.mocked(fake.api.session.active).mockRejectedValue(new Error('sessions unavailable')) + await trackSessionThenReconnect('ses_1', '/repo') + const client = createCapturingClient() + sseAggregator.addClient('status-failure', client.callback, client.writeFrame, ['srv_a|/repo']) + + sources.sources[1]!.open() + await flushPromises() + + expect(fake.api.session.active).toHaveBeenCalledTimes(2) + expect(statusFrames(client)).toHaveLength(0) + }) + + it('skips the replay when the stream disconnects before the active sessions resolve', async () => { + createHub() + const client = createCapturingClient() + sseAggregator.addClient('status-stale', client.callback, client.writeFrame, ['srv_a|/repo']) + + hub.start() + const first = sources.sources[0]! + first.open() + await flushPromises() + first.message(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) + + let resolveActive: (value: Record) => void = () => {} + vi.mocked(fake.api.session.active).mockReturnValueOnce(new Promise>((resolve) => { + resolveActive = resolve + }) as never) + + fake.emit({ type: 'upserted', serverId: 'srv_a' }) + const second = sources.sources[1]! + second.open() + await flushPromises() + expect(fake.api.session.active).toHaveBeenCalledTimes(2) + + second.fail({ message: 'connection lost' }) + resolveActive({}) + await flushPromises() + + expect(statusFrames(client)).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..66cc60f5f --- /dev/null +++ b/backend/test/services/sse-aggregator-remote.test.ts @@ -0,0 +1,217 @@ +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') + + expect(subscribed.events).toHaveLength(1) + expect(subscribed.events[0]!.event).toBe('resync') + expect(JSON.parse(subscribed.events[0]!.data)).toEqual({ timestamp: expect.any(Number), serverId: 'srv_a' }) + 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/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index 378f641e4..a912e3ffb 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -571,7 +571,7 @@ describe('SSEAggregator upstream resynchronization signal', () => { const resyncs = client.events.filter(event => event.event === 'resync') expect(resyncs).toHaveLength(2) - expect(JSON.parse(resyncs[0]!.data)).toMatchObject({ timestamp: expect.any(Number) }) + expect(JSON.parse(resyncs[0]!.data)).toEqual({ timestamp: expect.any(Number) }) 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/activity/ActivityList.test.tsx b/frontend/src/components/activity/ActivityList.test.tsx index 313525326..55450ada8 100644 --- a/frontend/src/components/activity/ActivityList.test.tsx +++ b/frontend/src/components/activity/ActivityList.test.tsx @@ -45,9 +45,12 @@ function createRepo(id: number, name: string): Repo { function createItem( overrides: Partial & Pick, ): ActiveWorkItem { + const repoId = overrides.repo?.id ?? 0 return { - key: `${overrides.repo.id}:${overrides.session.id}`, - path: `/repos/${overrides.repo.id}/sessions/${overrides.session.id}`, + key: `${repoId}:${overrides.session.id}`, + serverId: null, + serverName: null, + path: `/repos/${repoId}/sessions/${overrides.session.id}`, updatedAt: overrides.session.time.updated, ...overrides, } @@ -175,6 +178,24 @@ describe('ActivityList', () => { expect(screen.getByRole('button', { name: /Other session/ })).not.toHaveAttribute('aria-current') }) + it('labels a remote row with its server name and marks it current on its server route', () => { + const item = createItem({ + state: 'needs-input', + session: createSession('s1', 'Remote session'), + repo: null, + serverId: 'srv_kitchen', + serverName: 'Kitchen', + path: '/servers/srv_kitchen/sessions/s1', + }) + setGroups({ needsInput: [item] }) + + renderList(vi.fn(), '/servers/srv_kitchen/sessions/s1') + + const row = screen.getByRole('button', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + expect(row).toHaveAttribute('aria-current', 'true') + }) + it('shows a loading state', () => { mocks.useActiveWork.mockReturnValue({ groups: { needsInput: [], running: [], recent: [] }, diff --git a/frontend/src/components/activity/ActivityList.tsx b/frontend/src/components/activity/ActivityList.tsx index 00654c78e..55cb5f85e 100644 --- a/frontend/src/components/activity/ActivityList.tsx +++ b/frontend/src/components/activity/ActivityList.tsx @@ -2,7 +2,7 @@ import { useLocation } from 'react-router-dom' import { CircleAlert, CircleCheck, CircleX } from 'lucide-react' import { useActiveWork } from '@/hooks/useActiveWork' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' -import { parseRepoRoute } from '@/lib/navigation' +import { parseRepoRoute, parseServerSessionRoute } from '@/lib/navigation' import { cn, formatShortRelativeTime, getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' import { ACTIVE_WORK_GROUPS, @@ -28,8 +28,27 @@ interface ActivityListProps { variant?: 'full' | 'compact' } -function isCurrentItem(item: ActiveWorkItem, sessionID: string | null, repoID: number | null) { - return item.repo.id === repoID && item.session.id === sessionID +function isCurrentItem( + item: ActiveWorkItem, + sessionID: string | null, + repoID: number | null, + serverID: string | null, +) { + if (item.session.id !== sessionID) return false + if (item.serverId) return item.serverId === serverID + return item.repo?.id === repoID +} + +/** The row's short label: the local repo's name or the owning linked server's name. */ +function activityItemLabel(item: ActiveWorkItem): string { + return item.repo ? getRepoDisplayName(item.repo) : item.serverName ?? item.serverId ?? '' +} + +/** The row's identifying line: a local repo with its branch, or the owning linked server's name. */ +function activityItemLine(item: ActiveWorkItem): string { + if (!item.repo) return activityItemLabel(item) + const branch = item.worktreeBranch ?? getRepoBranchLabel(item.repo) + return branch ? `${getRepoDisplayName(item.repo)} · ${branch}` : getRepoDisplayName(item.repo) } function activityRowClass(isCurrent: boolean, compact: boolean) { @@ -63,11 +82,6 @@ interface ActivityRowProps { } function ActivityRow({ item, isCurrent, onSelect }: ActivityRowProps) { - const branch = item.worktreeBranch ?? getRepoBranchLabel(item.repo) - const repoLine = branch - ? `${getRepoDisplayName(item.repo)} · ${branch}` - : getRepoDisplayName(item.repo) - return ( - - : } - 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 e000ef239..ed9bc907d 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -9,6 +9,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, @@ -102,6 +103,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(handleClose).not.toHaveBeenCalled() }) + it('offers only the remote-capable project 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('MCP')).toBeInTheDocument() + expect(screen.getByText('Skills')).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('Preview')).not.toBeInTheDocument() + expect(screen.queryByText('Schedules')).not.toBeInTheDocument() + expect(screen.queryByText('Reset Permissions')).not.toBeInTheDocument() + expect(screen.queryByText('Repos')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Activity' })).not.toBeInTheDocument() + }) + it('shows unread reports and marks them all read', () => { const markAllReportsViewed = vi.fn() mockAuth() diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 48f1c26f1..83582333c 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -11,11 +11,12 @@ import { SideDrawer, SideDrawerContent } from '@/components/ui/side-drawer' import { CompactActivityList } from '@/components/activity/CompactActivityList' import { ScheduleReportsList } from '@/components/notifications/ScheduleReportsBell' import { useMarkAllScheduleRunsViewed, useUnreadScheduleRuns } from '@/hooks/useSchedules' -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 @@ -78,6 +79,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() const { data: unreadReports } = useUnreadScheduleRuns() const markAllReportsViewed = useMarkAllScheduleRunsViewed() @@ -141,11 +143,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 @@ -201,11 +205,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/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index 1ff5144d2..6f0a26866 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -149,6 +149,23 @@ function HomeHarness() { ) } +function RemoteHarness() { + const panel = useToolPanel(true) + return ( + <> + + + + ) +} + beforeEach(() => { vi.clearAllMocks() activityMocks.useActiveWorkCounts.mockReturnValue({ needsInput: 0, running: 0 }) @@ -330,6 +347,25 @@ describe('ToolSidePanel', () => { expect(buttons[0]).toHaveAccessibleName('Activity') }) + it('shows only the remote-capable tools on a linked-server route', async () => { + const user = userEvent.setup() + render( + + + , + ) + + const rail = screen.getByRole('navigation', { name: 'Tools' }) + expect( + within(rail) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()), + ).toEqual(['Activity', 'MCP', 'Skills']) + + await user.click(screen.getByRole('button', { name: 'MCP' })) + expect(await screen.findByText('mcp-tool')).toBeInTheDocument() + }) + it('opens the Activity panel with rows and the window label when clicked', async () => { const user = userEvent.setup() setActivityGroups([createActivityItem('other', 'Other Session', 2, 'Beta')]) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index ed36232da..e65d0eb5c 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -31,11 +31,14 @@ import { formatActivityWindow } from '@/lib/activeWork' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { restoreOverlayFocus } from '@/lib/overlayFocus' import { cn } from '@/lib/utils' +import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' const PANEL_WIDTH_CLASSES: Partial> = { activity: 'w-80', } +const REMOTE_TOOL_KEYS: ReadonlySet = new Set(['activity', 'mcp', 'skills']) + function ActivityWindowLabel() { const minutes = useActivityWindowMinutes() return ( @@ -157,6 +160,7 @@ export function ToolSidePanel({ const { activeTool, toggleTool, closePanel } = panel const location = useLocation() const openNavItem = useOpenNavItem() + const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) const railRef = useRef(null) const previousActiveToolRef = useRef(activeTool) @@ -173,6 +177,7 @@ export function ToolSidePanel({ const panelTools: Array<{ item: MoreDrawerItem; tool: PanelTool }> = [] const actionItems: MoreDrawerItem[] = [] for (const item of buildToolItems(location.pathname)) { + if (isRemoteRoute && !REMOTE_TOOL_KEYS.has(item.key)) continue const tool = toolKeyOf(item) if (isPanelTool(tool)) panelTools.push({ item, tool }) else actionItems.push(item) diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 8d759616b..bb03bc405 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -62,6 +62,11 @@ describe('buildMoreItems', () => { expect(items[12].key).toBe('logout') }) + it('returns only the server tools a linked server can serve for /servers/:id/sessions/:sid', () => { + const items = buildMoreItems('/servers/srv_kitchen/sessions/abc') + expect(items.map((item) => item.key)).toEqual(['home', 'mcp', 'skills', 'settings', 'logout']) + }) + it('omits the Actions item for an Assistant session', () => { const items = buildMoreItems('/repos/0/sessions/abc') expect(items).toHaveLength(12) @@ -164,6 +169,14 @@ describe('buildToolItems', () => { ]) }) + it('returns the linked-server session tools for the tool rail', () => { + expect(buildToolItems('/servers/srv_kitchen/sessions/abc').map((item) => item.key)).toEqual([ + 'activity', + 'mcp', + 'skills', + ]) + }) + it('returns the home route tools', () => { expect(buildToolItems('/').map((item) => item.key)).toEqual(['activity', 'all-schedules', 'files']) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index d142cc192..9871bfd71 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -48,6 +48,7 @@ function getBaseItems(): MoreDrawerItem[] { } const SESSION_DETAIL_PATH = /^\/repos\/(\d+)\/sessions\/[^/]+$/ +const SERVER_SESSION_DETAIL_PATH = /^\/servers\/[^/]+\/sessions\/[^/]+$/ export function isSessionDetailPath(pathname: string): boolean { return SESSION_DETAIL_PATH.test(pathname) @@ -55,6 +56,23 @@ 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', + '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)) @@ -69,6 +87,17 @@ export function toolKeyOf(item: MoreDrawerItem): string | null { function buildRouteNavModel(pathname: string): NavModel { const baseItems = getBaseItems() + if (SERVER_SESSION_DETAIL_PATH.test(pathname)) { + return { + primary: [getAssistantNavItem()], + items: [ + { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, + { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, + ...baseItems, + ], + } + } + const repoDetailMatch = /^\/repos\/(\d+)$/.exec(pathname) if (repoDetailMatch) { const id = repoDetailMatch[1] 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} ({ + getStatus: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + removeAuth: vi.fn(), + startAuth: vi.fn(), +})) + +vi.mock('@/api/mcp', () => ({ mcpApi: mocks })) + +vi.mock('@/components/settings/McpOAuthDialog', () => ({ + McpOAuthDialog: () =>
, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})) + +function renderContent(serverId: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return render(, { wrapper }) +} + +describe('RepoMcpContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads MCP status from the linked server for the session directory', async () => { + mocks.getStatus.mockResolvedValue({ + server: { status: 'connected', integrationID: 'int_1' }, + }) + + renderContent('srv_kitchen') + + await waitFor(() => { + expect(mocks.getStatus).toHaveBeenCalledWith('/home/me/app', 'srv_kitchen') + }) + }) + + it('connects and disconnects MCP servers against the linked server', async () => { + mocks.getStatus.mockResolvedValue({ server: { status: 'connected' } }) + mocks.disconnect.mockResolvedValue(undefined) + const user = userEvent.setup() + + renderContent('srv_kitchen') + + const toggle = await screen.findByRole('switch') + await user.click(toggle) + + await waitFor(() => { + expect(mocks.disconnect).toHaveBeenCalledWith('server', '/home/me/app', 'srv_kitchen') + }) + }) + + it('hides every Manager OAuth action for a linked server', async () => { + mocks.getStatus.mockResolvedValue({ + server: { status: 'connected', integrationID: 'int_1' }, + authServer: { status: 'needs_auth', error: 'auth required', integrationID: 'int_2' }, + }) + + renderContent('srv_kitchen') + + await screen.findByText('Auth Required') + + expect(screen.queryByTitle('Click for options')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Auth' })).not.toBeInTheDocument() + expect(screen.queryByText('Re-authenticate')).not.toBeInTheDocument() + expect(screen.queryByText('Remove Auth')).not.toBeInTheDocument() + expect(screen.queryByTestId('mcp-oauth-dialog')).not.toBeInTheDocument() + expect(mocks.removeAuth).not.toHaveBeenCalled() + expect(mocks.startAuth).not.toHaveBeenCalled() + }) + + it('keeps the Manager OAuth actions for the local server', async () => { + mocks.getStatus.mockResolvedValue({ + server: { status: 'connected', integrationID: 'int_1' }, + }) + const user = userEvent.setup() + + renderContent('local') + + expect(await screen.findByTestId('mcp-oauth-dialog')).toBeInTheDocument() + + await user.click(screen.getByTitle('Click for options')) + + expect(await screen.findByText('Re-authenticate')).toBeInTheDocument() + expect(screen.getByText('Remove Auth')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/repo/RepoMcpDialog.tsx b/frontend/src/components/repo/RepoMcpDialog.tsx index c0e2405d9..316f022e7 100644 --- a/frontend/src/components/repo/RepoMcpDialog.tsx +++ b/frontend/src/components/repo/RepoMcpDialog.tsx @@ -4,6 +4,7 @@ import { RepoMcpServerList } from './RepoMcpServerList' import { DeleteDialog } from '@/components/ui/delete-dialog' import { McpOAuthDialog } from '@/components/settings/McpOAuthDialog' import { mcpApi, type McpStatus, type McpAuthStartResponse } from '@/api/mcp' +import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { useMutation, useQueryClient } from '@tanstack/react-query' import { invalidateSessionCaches } from '@/lib/queryInvalidation' import { showToast } from '@/lib/toast' @@ -21,6 +22,8 @@ interface RepoMcpContentProps { } export function RepoMcpContent({ open, directory }: RepoMcpContentProps) { + const openCodeServerId = useOpenCodeServerId() + const isRemoteServer = !isLocalOpenCodeServer(openCodeServerId) const queryClient = useQueryClient() const [localStatus, setLocalStatus] = useState>({}) const [isLoadingStatus, setIsLoadingStatus] = useState(false) @@ -33,19 +36,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 +83,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 @@ -106,36 +109,41 @@ export function RepoMcpContent({ open, directory }: RepoMcpContentProps) { serverIds={serverIds} isLoadingStatus={isLoadingStatus} localStatus={localStatus} + oauthAvailable={!isRemoteServer} toggleMutation={toggleMutation} removeAuthMutation={removeAuthMutation} onAuthClick={setAuthDialogServerId} onRemoveAuthClick={setRemoveAuthConfirmServer} /> - setRemoveAuthConfirmServer(null)} - onConfirm={() => { - if (removeAuthConfirmServer) { - removeAuthMutation.mutate(removeAuthConfirmServer) - } - }} - onCancel={() => setRemoveAuthConfirmServer(null)} - title="Remove Authentication" - description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." - itemName={removeAuthConfirmServer ? formatMcpServerName(removeAuthConfirmServer) : ''} - isDeleting={removeAuthMutation.isPending} - /> - - !o && setAuthDialogServerId(null)} - serverName={authDialogServerId || ''} - onStartAuth={handleOAuthStartAuth} - onCheckStatus={handleOAuthCheckStatus} - onSuccess={handleOAuthSuccess} - directory={directory} - /> + {!isRemoteServer && ( + setRemoveAuthConfirmServer(null)} + onConfirm={() => { + if (removeAuthConfirmServer) { + removeAuthMutation.mutate(removeAuthConfirmServer) + } + }} + onCancel={() => setRemoveAuthConfirmServer(null)} + title="Remove Authentication" + description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." + itemName={removeAuthConfirmServer ? formatMcpServerName(removeAuthConfirmServer) : ''} + isDeleting={removeAuthMutation.isPending} + /> + )} + + {!isRemoteServer && ( + !o && setAuthDialogServerId(null)} + serverName={authDialogServerId || ''} + onStartAuth={handleOAuthStartAuth} + onCheckStatus={handleOAuthCheckStatus} + onSuccess={handleOAuthSuccess} + directory={directory} + /> + )}
) } diff --git a/frontend/src/components/repo/RepoMcpServerList.tsx b/frontend/src/components/repo/RepoMcpServerList.tsx index 3edaa5288..bd5669142 100644 --- a/frontend/src/components/repo/RepoMcpServerList.tsx +++ b/frontend/src/components/repo/RepoMcpServerList.tsx @@ -12,6 +12,7 @@ interface RepoMcpServerListProps { serverIds: string[] isLoadingStatus: boolean localStatus: Record + oauthAvailable: boolean toggleMutation: { mutate: (variables: { serverId: string; enable: boolean }) => void isPending: boolean @@ -29,6 +30,7 @@ export function RepoMcpServerList({ serverIds, isLoadingStatus, localStatus, + oauthAvailable, toggleMutation, removeAuthMutation, onAuthClick, @@ -56,7 +58,8 @@ export function RepoMcpServerList({ const failed = status?.status === 'failed' const isOAuthServer = !!status?.integrationID const connectedWithOAuth = isOAuthServer && isConnected - const showAuthButton = needsAuth || (isOAuthServer && failed) + const showAuthButton = oauthAvailable && (needsAuth || (isOAuthServer && failed)) + const showOAuthMenu = oauthAvailable && (showAuthButton || connectedWithOAuth) return (
{formatMcpServerName(serverId)}

- {(showAuthButton || connectedWithOAuth) ? ( + {showOAuthMenu ? ( + {!isRemoteServer && ( + + )}
- - - !isOpen && setDeleteSkill(null)} - onConfirm={confirmDelete} - onCancel={() => setDeleteSkill(null)} - title="Delete Skill" - description="Delete this managed skill directory and bundled files? This action cannot be undone." - itemName={deleteSkill?.name} - isDeleting={isDeleting} - /> + {!isRemoteServer && ( + + )} + + {!isRemoteServer && ( + !isOpen && setDeleteSkill(null)} + onConfirm={confirmDelete} + onCancel={() => setDeleteSkill(null)} + title="Delete Skill" + description="Delete this managed skill directory and bundled files? This action cannot be undone." + itemName={deleteSkill?.name} + isDeleting={isDeleting} + /> + )}
) } 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.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index bc3eb83f9..0e08e4037 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Repo, Session } from '@/api/types' import type { ActiveWorkGroups, ActiveWorkItem, ActiveWorkState } from '@/lib/activeWork' import { stubMatchMedia } from '@/test/test-utils' import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { SessionPickerDialog } from './SessionPickerDialog' const mocks = vi.hoisted(() => ({ @@ -21,6 +23,7 @@ const mocks = vi.hoisted(() => ({ sessionsHook: vi.fn(), fetchNextPage: vi.fn(), deleteSessionMock: vi.fn(), + deletedSessionServerId: null as string | null, togglePinMock: vi.fn(), getOpenCodeLocation: vi.fn(), sessionPins: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>, @@ -48,6 +51,7 @@ vi.mock('@/api/opencode', async (importOriginal) => { vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() + const { useOpenCodeServerId } = await import('@/contexts/OpenCodeServerContext') return { ...actual, useSessionsAcrossDirectories: ( @@ -73,7 +77,16 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { isFetchNextPageError: false, } }, - useDeleteSession: () => ({ mutateAsync: mocks.deleteSessionMock, isPending: false }), + useDeleteSession: () => { + const serverId = useOpenCodeServerId() + return { + mutateAsync: (target: unknown) => { + mocks.deletedSessionServerId = serverId + return mocks.deleteSessionMock(target) + }, + isPending: false, + } + }, } }) @@ -136,13 +149,15 @@ function activeWorkItem( key: `${directory}:${id}`, session: itemSession, repo: itemRepo, + serverId: null, + serverName: null, state, updatedAt: itemSession.time.updated, path: `/repos/${itemRepo.id}/sessions/${id}`, } } -function renderWithClient(ui: React.ReactElement) { +function renderWithClient(ui: React.ReactElement, serverId?: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const directory = mocks.repos[0]?.fullPath if (mocks.locationData && directory) { @@ -150,7 +165,13 @@ function renderWithClient(ui: React.ReactElement) { } return render( - {ui} + + {serverId ? ( + {ui} + ) : ( + ui + )} + , ) } @@ -190,6 +211,7 @@ describe('SessionPickerDialog', () => { stubMatchMedia(true) localStorage.clear() mocks.deleteSessionMock.mockResolvedValue(undefined) + mocks.deletedSessionServerId = null mocks.locationData = { directory: '/w/a', project: { id: 'global', directory: '/w/a', canonical: '/w/a' }, @@ -877,4 +899,112 @@ describe('SessionPickerDialog', () => { pinned: true, }) }) + + describe('linked-server mode', () => { + beforeEach(() => { + mocks.repos = [repo(1, 'alpha', '/w/a')] + mocks.sessionsData = [session('ses_remote', 'Remote session', '/home/me/app')] + }) + + it('lists the project sessions without Manager-only tabs and reports the chosen server session', async () => { + const onSelectRemoteSession = vi.fn() + renderPicker({ + remote: { + serverId: 'srv_kitchen', + serverName: 'Kitchen', + projectDirectory: '/home/me/app', + }, + onSelectRemoteSession, + }) + + expect(screen.queryByRole('tab', { name: 'Projects' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Active' })).toBeInTheDocument() + + const row = await screen.findByRole('option', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + + fireEvent.click(row) + + expect(onSelectRemoteSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses_remote' }), + 'srv_kitchen', + ) + }) + + it('navigates a remote active work row through the owning server', async () => { + const onSelectRemoteSession = vi.fn() + mocks.activeWorkGroups = { + needsInput: [ + { + key: '/home/me/app/ses_remote', + session: session('ses_remote', 'Remote pending', '/home/me/app'), + repo: null, + serverId: 'srv_kitchen', + serverName: 'Kitchen', + state: 'needs-input', + updatedAt: Date.now(), + path: '/servers/srv_kitchen/sessions/ses_remote', + }, + ], + running: [], + recent: [], + } + renderPicker({ + remote: { + serverId: 'srv_kitchen', + serverName: 'Kitchen', + projectDirectory: '/home/me/app', + }, + onSelectRemoteSession, + }) + openActiveTab() + + const row = await screen.findByRole('option', { name: /Remote pending/ }) + expect(row).toHaveTextContent('Kitchen') + expect(screen.queryByRole('button', { name: 'Session actions' })).not.toBeInTheDocument() + + fireEvent.click(row) + + expect(onSelectRemoteSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses_remote' }), + 'srv_kitchen', + ) + }) + + it('deletes a local active work row through the local server', async () => { + const user = userEvent.setup() + mocks.activeWorkGroups = { + needsInput: [], + running: [activeWorkItem('ses_local', 'Local task', '/w/a', 'running')], + recent: [], + } + renderWithClient( + , + 'srv_kitchen', + ) + openActiveTab() + + await screen.findByRole('option', { name: /Local task/ }) + + await user.click(screen.getByRole('button', { name: 'Session actions' })) + await user.click(await screen.findByText('Delete')) + + const dialog = await screen.findByRole('dialog', { name: 'Delete Session' }) + await user.click(within(dialog).getByRole('button', { name: 'Delete' })) + + await waitFor(() => + expect(mocks.deleteSessionMock).toHaveBeenCalledWith({ id: 'ses_local', directory: '/w/a' }), + ) + expect(mocks.deletedSessionServerId).toBe(LOCAL_OPENCODE_SERVER_ID) + }) + }) }) diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 677c73501..fb4fb849c 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -2,7 +2,10 @@ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from ' import { useQuery } from '@tanstack/react-query' import { format, startOfDay } from 'date-fns' import { MoreHorizontal, Pin, PinOff } from 'lucide-react' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { getOpenCodeLocation } from '@/api/opencode' +import { OpenCodeServerProvider, 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' @@ -46,6 +49,7 @@ const NEAR_BOTTOM_PX = 240 type SessionPickerTab = 'active' | 'sessions' | 'projects' const SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'projects'] +const REMOTE_SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions'] const SESSION_PICKER_TAB_LABELS: Record = { active: 'Active', @@ -53,9 +57,15 @@ const SESSION_PICKER_TAB_LABELS: Record = { projects: 'Projects', } -function nextSessionPickerTab(tab: SessionPickerTab): SessionPickerTab { - const index = SESSION_PICKER_TABS.indexOf(tab) - return SESSION_PICKER_TABS[(index + 1) % SESSION_PICKER_TABS.length] +function nextSessionPickerTab(tab: SessionPickerTab, tabs: SessionPickerTab[]): SessionPickerTab { + const index = tabs.indexOf(tab) + return tabs[(index + 1) % tabs.length] +} + +interface RemotePickerScope { + serverId: string + serverName?: string + projectDirectory?: string } interface SessionPickerDialogProps { @@ -64,14 +74,19 @@ interface SessionPickerDialogProps { currentRepo: Repo | undefined activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void onSelectRepo: (repo: Repo) => void + /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ + remote?: RemotePickerScope } interface SessionPickerRow { key: string session: Session - repo: Repo + repo?: Repo + serverId?: string + serverName?: string updatedAt?: number } @@ -85,14 +100,11 @@ function buildSections( sessions: Session[], pinnedKeys: ReadonlySet, keyFn: (session: Session) => string, - resolveRepo: (session: Session) => Repo | undefined, + resolveRepo?: (session: Session) => Repo | undefined, ): SessionPickerSection[] { const { pinned, today, older } = partitionSessions(sessions, pinnedKeys, keyFn) const toRows = (group: Session[]): SessionPickerRow[] => - group.flatMap((session) => { - const repo = resolveRepo(session) - return repo ? [{ key: keyFn(session), session, repo }] : [] - }) + group.map((session) => ({ key: keyFn(session), session, repo: resolveRepo?.(session) })) const sections: SessionPickerSection[] = [] const pinnedRows = toRows(pinned) @@ -187,6 +199,7 @@ interface SessionPickerRowItemProps { isPinned: boolean isPendingDelete: boolean showRepo: boolean + showActions: boolean label?: string onOpen: (row: SessionPickerRow) => void onHover: (index: number) => void @@ -204,6 +217,7 @@ const SessionPickerRowItem = memo(function SessionPickerRowItem({ isPinned, isPendingDelete, showRepo, + showActions, label, onOpen, onHover, @@ -261,13 +275,18 @@ const SessionPickerRowItem = memo(function SessionPickerRowItem({ : 'text-muted-foreground', )} > - {showRepo && {getRepoDisplayName(row.repo)}} + {showRepo && row.repo ? ( + {getRepoDisplayName(row.repo)} + ) : null} + {showRepo && !row.repo && (row.serverName || row.serverId) ? ( + {row.serverName ?? row.serverId} + ) : null} {!showRepo && label && {label}} {formatShortRelativeTime(new Date(row.updatedAt ?? row.session.time.updated))}
- + {row.serverId || !showActions ? null :
) @@ -299,6 +318,7 @@ interface SessionPickerRowActionsOptions { directories: string[] activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void } @@ -323,6 +343,7 @@ function useSessionPickerRowActions({ directories, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, }: SessionPickerRowActionsOptions): SessionPickerRowActions { const { data: sessionPins } = useSessionPins() @@ -339,9 +360,13 @@ function useSessionPickerRowActions({ const openRow = useCallback( (row: SessionPickerRow | undefined) => { if (!row) return - onSelectSession(row.session, row.repo) + if (row.serverId) { + onSelectRemoteSession?.(row.session, row.serverId) + return + } + if (row.repo) onSelectSession(row.session, row.repo) }, - [onSelectSession], + [onSelectSession, onSelectRemoteSession], ) const performDelete = useCallback( @@ -356,7 +381,7 @@ function useSessionPickerRowActions({ const deleteRow = useCallback( (row: SessionPickerRow | undefined) => { - if (!row) return + if (!row || row.serverId) return if (pendingDeleteKey !== row.key) { setPendingDeleteKey(row.key) return @@ -370,6 +395,7 @@ function useSessionPickerRowActions({ const handleTogglePin = useCallback( (row: SessionPickerRow) => { + if (row.serverId) return togglePin.mutate({ sessionId: row.session.id, directory: row.session.location.directory, @@ -429,6 +455,7 @@ interface SessionPickerListProps { pinnedKeys: ReadonlySet pendingDeleteKey: string | null showRepo: boolean + showRowActions?: boolean labelFor?: (row: SessionPickerRow) => string | undefined emptyState: React.ReactNode onOpenRow: (row: SessionPickerRow) => void @@ -453,6 +480,7 @@ function SessionPickerList({ pinnedKeys, pendingDeleteKey, showRepo, + showRowActions = true, labelFor, emptyState, onOpenRow, @@ -502,6 +530,7 @@ function SessionPickerList({ isPinned={pinnedKeys.has(row.key)} isPendingDelete={pendingDeleteKey === row.key} showRepo={showRepo} + showActions={showRowActions} label={labelFor?.(row)} onOpen={onOpenRow} onHover={onHoverRow} @@ -525,6 +554,7 @@ interface SessionPickerContentProps { currentRepo: Repo | undefined activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void switchHint: string searchInputRef: React.RefObject @@ -536,6 +566,7 @@ function SessionPickerContent({ currentRepo, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, switchHint, searchInputRef, @@ -553,9 +584,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, }) @@ -613,11 +645,18 @@ function SessionPickerContent({ directories, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, }) const sections = useMemo( - () => buildSections(filteredSessions, pinnedKeys, getSessionKey, resolveRepo), + () => + buildSections( + filteredSessions.filter((session) => Boolean(resolveRepo(session))), + pinnedKeys, + getSessionKey, + resolveRepo, + ), [filteredSessions, pinnedKeys, resolveRepo], ) const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]) @@ -784,6 +823,7 @@ interface ActivePickerContentProps { open: boolean activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void switchHint: string searchInputRef: React.RefObject @@ -793,6 +833,7 @@ function ActivePickerContent({ open, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, switchHint, searchInputRef, @@ -812,7 +853,14 @@ function ActivePickerContent({ const rows: SessionPickerRow[] = [] for (const item of groups[definition.key]) { if (needle && !(item.session.title ?? '').toLowerCase().includes(needle)) continue - rows.push({ key: item.key, session: item.session, repo: item.repo, updatedAt: item.updatedAt }) + rows.push({ + key: item.key, + session: item.session, + repo: item.repo ?? undefined, + serverId: item.serverId ?? undefined, + serverName: item.serverName ?? undefined, + updatedAt: item.updatedAt, + }) } return rows.length > 0 ? [{ id: definition.key, heading: definition.heading, rows }] : [] }) @@ -835,7 +883,10 @@ function ActivePickerContent({ } = usePickerCursor(rows, defaultCursorKey) const directories = useMemo( - () => Array.from(new Set(rows.map((row) => row.session.location.directory))), + () => + Array.from( + new Set(rows.filter((row) => !row.serverId).map((row) => row.session.location.directory)), + ), [rows], ) @@ -852,6 +903,7 @@ function ActivePickerContent({ directories, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, }) @@ -931,14 +983,179 @@ function ActivePickerContent({ ) } +const EMPTY_PINNED_KEYS: ReadonlySet = new Set() + +interface RemoteSessionPickerContentProps { + open: boolean + serverName?: string + projectDirectory?: string + activeSessionID?: string + onSelectSession: (session: Session) => void + switchHint: string + searchInputRef: React.RefObject +} + +function RemoteSessionPickerContent({ + open, + serverName, + projectDirectory, + activeSessionID, + onSelectSession, + switchHint, + searchInputRef, +}: RemoteSessionPickerContentProps) { + const listRef = useRef(null) + const listId = useId() + const optionBaseId = useId() + + const { + query, + setQuery, + trimmedQuery, + filteredSessions, + isSearchPending, + isLoading, + fetchNextPage, + isFetchingNextPage, + isFetchNextPageError, + canFetchNextPage, + } = useSessionSearch(projectDirectory ? [projectDirectory] : []) + + const sections = useMemo( + () => buildSections(filteredSessions, EMPTY_PINNED_KEYS, getSessionKey), + [filteredSessions], + ) + const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]) + + const activeRow = useMemo( + () => rows.find((row) => row.session.id === activeSessionID), + [rows, activeSessionID], + ) + const defaultCursorKey = !trimmedQuery && activeRow ? activeRow.key : rows[0]?.key ?? null + + const { + rowIndexByKey, + cursorIndex, + cursorRow, + registerRef, + moveCursor, + resetCursor, + } = usePickerCursor(rows, defaultCursorKey) + + const openRow = useCallback( + (row: SessionPickerRow | undefined) => { + if (row) onSelectSession(row.session) + }, + [onSelectSession], + ) + + useEffect(() => { + resetCursor() + }, [trimmedQuery, resetCursor]) + + const handleSearchKeyDown = usePickerKeyNav({ + rows, + cursorIndex, + cursorRow, + moveCursor, + onOpenRow: openRow, + }) + + const handleListScroll = useCallback( + (event: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget + if (scrollHeight - scrollTop - clientHeight <= NEAR_BOTTOM_PX && canFetchNextPage) { + void fetchNextPage() + } + }, + [canFetchNextPage, fetchNextPage], + ) + + const labelFor = useCallback(() => serverName, [serverName]) + + return ( +
+ = 0 ? optionId(optionBaseId, cursorIndex) : undefined} + value={query} + onChange={setQuery} + onKeyDown={handleSearchKeyDown} + placeholder="Search sessions..." + name="remote-session-picker-search" + /> + + undefined} + onRequestDelete={() => undefined} + registerRef={registerRef} + onScroll={handleListScroll} + > + {isFetchNextPageError && ( +
+

Failed to load more sessions

+ +
+ )} + {isFetchingNextPage && rows.length > 0 && ( +
Loading more sessions...
+ )} +
+ +
+ ↑↓ move · Enter open + {switchHint ? ` · ${switchHint} sessions` : ''} +
+
+ ) +} + export function SessionPickerDialog({ open, onOpenChange, currentRepo, activeSessionID, onSelectSession, + onSelectRemoteSession, onDeleteActiveSession, onSelectRepo, + remote, }: SessionPickerDialogProps) { const searchInputRef = useRef(null) const activeSearchInputRef = useRef(null) @@ -957,6 +1174,8 @@ export function SessionPickerDialog({ }, [preferences], ) + const isRemote = remote !== undefined + const pickerTabs = isRemote ? REMOTE_SESSION_PICKER_TABS : SESSION_PICKER_TABS const [tab, setTab] = useState('sessions') const [prevOpen, setPrevOpen] = useState(open) @@ -965,10 +1184,12 @@ export function SessionPickerDialog({ if (open) setTab('sessions') } + const activeTab: SessionPickerTab = isRemote && tab === 'projects' ? 'sessions' : tab + const toggleTab = useCallback(() => { focusSearchAfterSwitchRef.current = true - setTab((current) => nextSessionPickerTab(current)) - }, []) + setTab((current) => nextSessionPickerTab(current, pickerTabs)) + }, [pickerTabs]) useShortcutActions({ switchPickerTab: open ? toggleTab : undefined }) @@ -980,12 +1201,12 @@ export function SessionPickerDialog({ if (!hasFinePointer && !focusSearchAfterSwitchRef.current) return const frame = requestAnimationFrame(() => { focusSearchAfterSwitchRef.current = false - if (tab === 'projects') projectSearchInputRef.current?.focus() - else if (tab === 'active') activeSearchInputRef.current?.focus() + if (activeTab === 'projects') projectSearchInputRef.current?.focus() + else if (activeTab === 'active') activeSearchInputRef.current?.focus() else searchInputRef.current?.focus() }) return () => cancelAnimationFrame(frame) - }, [open, tab, hasFinePointer]) + }, [open, activeTab, hasFinePointer]) return ( @@ -1003,58 +1224,76 @@ export function SessionPickerDialog({ }} > setTab(value as SessionPickerTab)} className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" >
- {SESSION_PICKER_TAB_LABELS[tab]} + {SESSION_PICKER_TAB_LABELS[activeTab]} Active Sessions - Projects + {isRemote ? null : Projects}
- + + + - - - - + {remote ? ( + onSelectRemoteSession?.(session, remote.serverId)} + switchHint={switchHint} + searchInputRef={searchInputRef} + /> + ) : ( + + )} + {isRemote ? null : ( + + + + )}
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..24aae1a81 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx @@ -0,0 +1,180 @@ +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, OPENCODE_SERVER_LINK_VERSION, 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, +} + +const originChangeWarning = 'Changing the server address requires re-entering the password or pairing again.' + +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('explains that a pairing link from an unsupported version cannot be used', async () => { + const user = userEvent.setup() + renderDialog() + + const outdated = pairingLink.replace(`v=${OPENCODE_SERVER_LINK_VERSION}`, 'v=1') + await user.type(screen.getByLabelText('Pairing link'), outdated) + await user.click(screen.getByRole('button', { name: 'Use link' })) + + expect(screen.getByRole('alert')).toHaveTextContent( + `This pairing link uses link version 1, but this Manager expects version ${OPENCODE_SERVER_LINK_VERSION}. Update the Manager or the ocm CLI and generate a new QR code.`, + ) + 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() + }) + + it('warns that changing the server origin requires credentials', async () => { + const user = userEvent.setup() + renderDialog({ server: savedServer }) + + expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument() + + await user.clear(screen.getByLabelText('URL')) + await user.type(screen.getByLabelText('URL'), 'https://relocated.example.com') + + expect(screen.getByText(originChangeWarning)).toBeInTheDocument() + }) + + it('does not warn for a same-origin address change', async () => { + const user = userEvent.setup() + renderDialog({ server: savedServer }) + + await user.clear(screen.getByLabelText('URL')) + await user.type(screen.getByLabelText('URL'), 'https://office.example.com/api') + + expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument() + }) + + it('does not warn once a password is entered for the new origin', async () => { + const user = userEvent.setup() + renderDialog({ server: savedServer }) + + await user.clear(screen.getByLabelText('URL')) + await user.type(screen.getByLabelText('URL'), 'https://relocated.example.com') + await user.type(screen.getByLabelText('Password'), 'new-password') + + expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument() + }) + + it('does not warn about an origin change when no password is stored', async () => { + const user = userEvent.setup() + renderDialog({ server: { ...savedServer, hasPassword: false } }) + + await user.clear(screen.getByLabelText('URL')) + await user.type(screen.getByLabelText('URL'), 'https://relocated.example.com') + + expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/OpenCodeServerDialog.tsx b/frontend/src/components/settings/OpenCodeServerDialog.tsx new file mode 100644 index 000000000..8c22a20ff --- /dev/null +++ b/frontend/src/components/settings/OpenCodeServerDialog.tsx @@ -0,0 +1,467 @@ +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_LINK_VERSION, + OPENCODE_SERVER_USERNAME, + parseOpenCodeServerLink, + readOpenCodeServerLinkVersion, + type OpenCodeServer, + type OpenCodeServerLink, + type OpenCodeServerTestResult, +} from '@opencode-manager/shared/schemas' +import { haveSameUrlOrigin } from '@opencode-manager/shared/utils' +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_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 passwordValue = form.watch('password') + const transportWarning = sendsCredentialsInClear(baseUrlValue) + const isEditing = Boolean(server) + const originChanged = Boolean( + server && isHttpUrl(baseUrlValue) && !haveSameUrlOrigin(server.baseUrl, baseUrlValue), + ) + const needsCredentialsForOriginChange = + Boolean(server?.hasPassword) && originChanged && !passwordValue && !clearPassword && !pairingLink + const isSubmitting = form.formState.isSubmitting + + const applyPairingLink = (text: string) => { + const link = parseOpenCodeServerLink(text) + if (!link) { + const version = readOpenCodeServerLinkVersion(text) + setLinkError(version !== null && version !== OPENCODE_SERVER_LINK_VERSION + ? `This pairing link uses link version ${version}, but this Manager expects version ${OPENCODE_SERVER_LINK_VERSION}. Update the Manager or the ocm CLI and generate a new QR code.` + : '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. +

+
+ +
+ +
+ +