import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify" import cors from "@fastify/cors" import fastifyStatic from "@fastify/static" import replyFrom from "@fastify/reply-from" import fs from "fs" import path from "path" import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" import { EventBus } from "../events/bus" import { registerWorkspaceRoutes } from "./routes/workspaces" import { registerSettingsRoutes } from "./routes/settings" import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" interface HttpServerDeps { bindHost: string bindPort: number /** When bindPort is 0, try this first. */ defaultPort: number protocol: "http" | "https" httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } workspaceManager: WorkspaceManager settings: SettingsService fileSystemBrowser: FileSystemBrowser eventBus: EventBus serverMeta: ServerMeta instanceStore: InstanceStore speechService: SpeechService authManager: AuthManager uiStaticDir: string uiDevServerUrl?: string logger: Logger } interface HttpServerStartResult { port: number url: string displayHost: string } export function createHttpServer(deps: HttpServerDeps) { // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS. // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance. const app = Fastify( ({ logger: false, ...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}), } as unknown) as any, ) as unknown as FastifyInstance const proxyLogger = deps.logger.child({ component: "proxy" }) const apiLogger = deps.logger.child({ component: "http" }) const sseLogger = deps.logger.child({ component: "sse" }) const sseClients = new Set<() => void>() const registerSseClient = (cleanup: () => void) => { sseClients.add(cleanup) return () => sseClients.delete(cleanup) } const closeSseClients = () => { for (const cleanup of Array.from(sseClients)) { cleanup() } sseClients.clear() } app.addHook("onRequest", (request, _reply, done) => { ;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = { start: process.hrtime.bigint(), } done() }) app.addHook("onResponse", (request, reply, done) => { const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined const base = { method: request.method, url: request.url, status: reply.statusCode, durationMs, } apiLogger.debug(base, "HTTP request completed") if (apiLogger.isLevelEnabled("trace")) { apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload") } done() }) const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]) const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const getSelfOrigins = (): Set => { const origins = new Set() const candidates: Array = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl] for (const candidate of candidates) { if (!candidate) continue try { origins.add(new URL(candidate).origin) } catch { // ignore } } for (const addr of deps.serverMeta.addresses ?? []) { try { origins.add(new URL(addr.remoteUrl).origin) } catch { // ignore } } return origins } app.register(cors, { origin: (origin, cb) => { if (!origin) { cb(null, true) return } const selfOrigins = getSelfOrigins() if (selfOrigins.has(origin)) { cb(null, true) return } if (allowedDevOrigins.has(origin)) { cb(null, true) return } // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access. if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) { cb(null, true) return } cb(null, false) }, credentials: true, }) app.register(replyFrom, { contentTypesToEncode: [], undici: { connections: 16, pipelining: 1, bodyTimeout: 0, headersTimeout: 0, }, }) const backgroundProcessManager = new BackgroundProcessManager({ workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger.child({ component: "background-processes" }), }) const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" })) const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) const voiceModeManager = new VoiceModeManager({ connections: clientConnectionManager, channel: pluginChannel, logger: deps.logger.child({ component: "voice-mode" }), }) registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { const rawUrl = request.raw.url ?? request.url const pathname = (rawUrl.split("?")[0] ?? "").trim() const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]) const publicPagePaths = new Set(["/login"]) if (deps.authManager.isTokenBootstrapEnabled()) { publicPagePaths.add("/auth/token") } if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) { done() return } const session = deps.authManager.getSessionFromRequest(request) const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") if (requiresAuthForApi && !session) { // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) if (pluginMatch) { const workspaceId = pluginMatch[1] const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) const provided = Array.isArray(request.headers.authorization) ? request.headers.authorization[0] : request.headers.authorization if (expected && provided && provided === expected) { done() return } } sendUnauthorized(request, reply) return } if (!session && wantsHtml(request)) { reply.redirect("/login") return } done() }) app.get("/", async (request, reply) => { const session = deps.authManager.getSessionFromRequest(request) if (!session) { reply.redirect("/login") return } if (deps.uiDevServerUrl) { await proxyToDevServer(request, reply, deps.uiDevServerUrl) return } const uiDir = deps.uiStaticDir const indexPath = path.join(uiDir, "index.html") if (uiDir && fs.existsSync(indexPath)) { reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8")) return } reply.code(404).send({ message: "UI bundle missing" }) }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger, connectionManager: clientConnectionManager, }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger, channel: pluginChannel, voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) if (deps.uiDevServerUrl) { setupDevProxy(app, deps.uiDevServerUrl, deps.authManager) } else { setupStaticUi(app, deps.uiStaticDir, deps.authManager) } return { instance: app, start: async (): Promise => { const attemptListen = async (requestedPort: number) => { const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost }) return { addressInfo, requestedPort } } const autoPortRequested = deps.bindPort === 0 const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort const shouldRetryWithEphemeral = (error: unknown) => { if (!autoPortRequested) return false const err = error as NodeJS.ErrnoException | undefined return Boolean(err && err.code === "EADDRINUSE") } let listenResult try { listenResult = await attemptListen(primaryPort) } catch (error) { if (!shouldRetryWithEphemeral(error)) { throw error } deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port") listenResult = await attemptListen(0) } let actualPort = listenResult.requestedPort if (typeof listenResult.addressInfo === "string") { try { const parsed = new URL(listenResult.addressInfo) actualPort = Number(parsed.port) || listenResult.requestedPort } catch { actualPort = listenResult.requestedPort } } else { const address = app.server.address() if (typeof address === "object" && address) { actualPort = address.port } } const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}` deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening") return { port: actualPort, url: serverUrl, displayHost } }, stop: () => { closeSseClients() clientConnectionManager.shutdown() return app.close() }, } } interface InstanceProxyDeps { workspaceManager: WorkspaceManager logger: Logger } function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() instance.addContentTypeParser("*", (req, body, done) => done(null, body)) const proxyBaseHandler = async ( request: FastifyRequest<{ Params: { id: string; slug: string } }>, reply: FastifyReply, ) => { await proxyWorkspaceRequest({ request, reply, workspaceManager: deps.workspaceManager, worktreeSlug: request.params.slug, pathSuffix: "", logger: deps.logger, }) } const proxyWildcardHandler = async ( request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>, reply: FastifyReply, ) => { await proxyWorkspaceRequest({ request, reply, workspaceManager: deps.workspaceManager, worktreeSlug: request.params.slug, pathSuffix: request.params["*"] ?? "", logger: deps.logger, }) } instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler) instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler) }) } const INSTANCE_PROXY_HOST = "127.0.0.1" // Special-case OpenCode directory override. // // UI clients may need to scope certain requests to an arbitrary directory that is not // part of the Git worktree list. Since the OpenCode SDK does not reliably support // injecting per-request headers, we encode an override into the *path* and strip it // before proxying to the instance. // // Example proxied request path: // /workspaces/:id/worktrees/:slug/instance/__dir//session/create // // The server will decode -> absolute directory, validate it, then set // x-opencode-directory accordingly and forward the request to /session/create. const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/" const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096 async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply workspaceManager: WorkspaceManager logger: Logger worktreeSlug: string pathSuffix?: string }) { const { request, reply, workspaceManager, logger, worktreeSlug } = args const workspaceId = (request.params as { id: string }).id const workspace = workspaceManager.get(workspaceId) const bodyToJson = (body: unknown): unknown => { if (body == null) return null const anyBody = body as any if (anyBody && typeof anyBody.pipe === "function") { // Don't consume streams (would break proxying). // Best-effort: if the stream already has buffered chunks, parse those. try { const buffered = anyBody?._readableState?.buffer if (Array.isArray(buffered) && buffered.length > 0) { const chunks: Buffer[] = [] for (const entry of buffered) { if (!entry) continue if (Buffer.isBuffer(entry)) { chunks.push(entry) continue } const data = (entry as any).data if (Buffer.isBuffer(data)) { chunks.push(data) } } if (chunks.length > 0) { const text = Buffer.concat(chunks).toString("utf-8") try { return JSON.parse(text) } catch { return { __raw: text } } } } } catch { // fall through } return { __stream: true } } const maybeParse = (input: string): unknown => { try { return JSON.parse(input) } catch { return { __raw: input } } } if (Buffer.isBuffer(body)) { return maybeParse(body.toString("utf-8")) } if (typeof body === "string") { return maybeParse(body) } if (typeof body === "object") { return body } return body } if (!workspace) { reply.code(404).send({ error: "Workspace not found" }) return } const port = workspaceManager.getInstancePort(workspaceId) if (!port) { reply.code(502).send({ error: "Workspace instance is not ready" }) return } if (!isValidWorktreeSlug(worktreeSlug)) { reply.code(400).send({ error: "Invalid worktree slug" }) return } let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } try { extracted = extractOpencodeDirectoryOverride(args.pathSuffix) } catch (error) { const message = error instanceof Error ? error.message : "Invalid directory override" reply.code(400).send({ error: message }) return } let directory: string | null = null let forwardedSuffix = extracted.forwardedSuffix if (extracted.overrideDirectory) { try { directory = validateAndNormalizeOverrideDirectory({ overrideDirectory: extracted.overrideDirectory, workspaceRoot: workspace.path, }) } catch (error) { const message = error instanceof Error ? error.message : "Invalid directory override" reply.code(400).send({ error: message }) return } } else { directory = await resolveWorktreeDirectory({ workspaceId, workspacePath: workspace.path, worktreeSlug, logger, }) if (!directory) { reply.code(404).send({ error: "Worktree not found" }) return } } const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance") if (logger.isLevelEnabled("trace")) { logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload") } return reply.from(targetUrl, { rewriteRequestHeaders: (_originalRequest, headers) => { if (instanceAuthHeader) { headers.authorization = instanceAuthHeader } // OpenCode expects the *full* path; we send it via header to avoid query tampering. const isNonASCII = /[^\x00-\x7F]/.test(directory) const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory // Overwrite any client-provided value (case-insensitive headers are normalized by Node). ;(headers as Record)["x-opencode-directory"] = encodedDirectory if (logger.isLevelEnabled("trace")) { const outgoing: Record = {} for (const [key, value] of Object.entries(headers as Record)) { outgoing[key] = value } // Redact sensitive headers. for (const key of Object.keys(outgoing)) { const lower = key.toLowerCase() if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { outgoing[key] = "" } } logger.trace( { workspaceId, method: request.method, targetUrl, worktreeSlug, directory, contentType: request.headers["content-type"], body: bodyToJson(request.body), headers: outgoing, }, "Proxy -> OpenCode request", ) } return headers }, onError: (proxyReply, { error }) => { logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") if (!proxyReply.sent) { proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) } }, }) } function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): { overrideDirectory: string | null forwardedSuffix: string | undefined } { if (!pathSuffix) { return { overrideDirectory: null, forwardedSuffix: pathSuffix } } // Fastify wildcard param does not include a leading slash. const trimmed = pathSuffix.replace(/^\/+/, "") if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) { return { overrideDirectory: null, forwardedSuffix: pathSuffix } } const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length) const slashIndex = rest.indexOf("/") const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim() const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : "" if (!encoded) { throw new Error("Missing directory override") } if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) { throw new Error("Directory override too large") } let overrideDirectory = "" try { overrideDirectory = decodeBase64Url(encoded) } catch { throw new Error("Invalid directory override") } const forwardedSuffix = remaining return { overrideDirectory, forwardedSuffix } } function decodeBase64Url(input: string): string { // base64url -> base64 const normalized = input.replace(/-/g, "+").replace(/_/g, "/") const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) const base64 = `${normalized}${padding}` return Buffer.from(base64, "base64").toString("utf-8") } function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string { const raw = params.overrideDirectory.trim() if (!raw) { throw new Error("Override directory is empty") } if (!path.isAbsolute(raw)) { throw new Error("Override directory must be an absolute path") } if (!fs.existsSync(raw)) { throw new Error(`Override directory does not exist: ${raw}`) } const stats = fs.statSync(raw) if (!stats.isDirectory()) { throw new Error(`Override path is not a directory: ${raw}`) } const normalizedOverride = fs.realpathSync(raw) const normalizedRoot = fs.realpathSync(params.workspaceRoot) if (!isSubpath(normalizedOverride, normalizedRoot)) { throw new Error("Override directory must be within the workspace root") } return normalizedOverride } function isSubpath(candidate: string, root: string): boolean { const rel = path.relative(root, candidate) if (rel === "") return true if (rel === "..") return false if (rel.startsWith(`..${path.sep}`)) return false if (path.isAbsolute(rel)) return false return true } function normalizeInstanceSuffix(pathSuffix: string | undefined) { if (!pathSuffix || pathSuffix === "/") { return "/" } const trimmed = pathSuffix.replace(/^\/+/, "") return trimmed.length === 0 ? "/" : `/${trimmed}` } type WorktreeCacheEntry = { expiresAt: number repoRoot: string worktrees: Array<{ slug: string; directory: string }> } const WORKTREE_CACHE_TTL_MS = 2000 const worktreeCache = new Map() async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) { const cached = worktreeCache.get(params.workspaceId) const now = Date.now() if (cached && cached.expiresAt > now) { return cached } const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) const entry: WorktreeCacheEntry = { expiresAt: now + WORKTREE_CACHE_TTL_MS, repoRoot, worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })), } worktreeCache.set(params.workspaceId, entry) return entry } async function resolveWorktreeDirectory(params: { workspaceId: string workspacePath: string worktreeSlug: string logger: Logger }): Promise { const { worktreeSlug } = params const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug) if (match) { return match.directory } // If the slug is new (e.g., created moments ago), refresh once. worktreeCache.delete(params.workspaceId) const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null } function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") return } if (!fs.existsSync(uiDir)) { app.log.warn({ uiDir }, "UI static directory missing; API endpoints only") return } app.register(fastifyStatic, { root: uiDir, prefix: "/", decorateReply: false, }) const indexPath = path.join(uiDir, "index.html") app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { const url = request.raw.url ?? "" if (isApiRequest(url)) { reply.code(404).send({ message: "Not Found" }) return } const session = authManager.getSessionFromRequest(request) if (!session && wantsHtml(request)) { reply.redirect("/login") return } if (fs.existsSync(indexPath)) { reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8")) } else { reply.code(404).send({ message: "UI bundle missing" }) } }) } function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) { app.log.info({ upstreamBase }, "Proxying UI requests to development server") app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { const url = request.raw.url ?? "" if (isApiRequest(url)) { reply.code(404).send({ message: "Not Found" }) return } const session = authManager.getSessionFromRequest(request) if (!session && wantsHtml(request)) { reply.redirect("/login") return } void proxyToDevServer(request, reply, upstreamBase) }) } async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) { try { const targetUrl = new URL(request.raw.url ?? "/", upstreamBase) const response = await fetch(targetUrl, { method: request.method, headers: buildProxyHeaders(request.headers), }) response.headers.forEach((value, key) => { reply.header(key, value) }) reply.code(response.status) if (!response.body || request.method === "HEAD") { reply.send() return } const buffer = Buffer.from(await response.arrayBuffer()) reply.send(buffer) } catch (error) { request.log.error({ err: error }, "Failed to proxy UI request to dev server") if (!reply.sent) { reply.code(502).send("UI dev server is unavailable") } } } function isApiRequest(rawUrl: string | null | undefined) { if (!rawUrl) return false const pathname = rawUrl.split("?")[0] ?? "" return pathname === "/api" || pathname.startsWith("/api/") } function buildProxyHeaders(headers: FastifyRequest["headers"]): Record { const result: Record = {} for (const [key, value] of Object.entries(headers ?? {})) { if (!value || key.toLowerCase() === "host") continue result[key] = Array.isArray(value) ? value.join(",") : value } return result }