import { createProxyMiddleware } from 'http-proxy-middleware'; import { applyForwardProxyResponseHeaders, collectForwardProxyHeaders, shouldForwardProxyResponseHeader, } from '../../proxy-headers.js'; import { createRealpathCache } from '../path-realpath-cache.js'; import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js'; import { recordStartupPerformance } from './startup-performance.js'; const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000; export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => { const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions }); return async (requestUrl) => { if (typeof requestUrl !== 'string' || !requestUrl.includes('directory=')) { return requestUrl; } const url = new URL(requestUrl, 'http://localhost'); const directory = url.searchParams.get('directory'); if (!directory) { return requestUrl; } const canonicalDirectory = await realpathCache.resolve(directory); if (!canonicalDirectory || canonicalDirectory === directory) { return requestUrl; } url.searchParams.set('directory', canonicalDirectory); return `${url.pathname}${url.search}`; }; }; export const normalizeForwardedDirectoryHeaders = (headers) => { const rawDirectory = headers?.['x-opencode-directory']; if (typeof rawDirectory !== 'string') { return headers; } if (headers['x-opencode-directory-encoding'] !== 'uri') { return headers; } try { headers['x-opencode-directory'] = decodeURIComponent(rawDirectory); } catch { // Leave malformed values untouched; upstream will reject invalid paths. } delete headers['x-opencode-directory-encoding']; return headers; }; const waitForSseDrain = (res, signal) => new Promise((resolve) => { if (signal?.aborted || res.writableEnded || res.destroyed) { resolve(); return; } const cleanup = () => { res.off?.('drain', onDone); res.off?.('close', onDone); res.off?.('error', onDone); signal?.removeEventListener?.('abort', onDone); }; const onDone = () => { cleanup(); resolve(); }; res.once?.('drain', onDone); res.once?.('close', onDone); res.once?.('error', onDone); signal?.addEventListener?.('abort', onDone, { once: true }); }); export const writeSseChunkWithBackpressure = async (res, value, signal) => { if (!value || value.length === 0 || signal?.aborted || res.writableEnded || res.destroyed) { return false; } const flushed = res.write(value); if (flushed !== false) { return true; } await waitForSseDrain(res, signal); return !signal?.aborted && !res.writableEnded && !res.destroyed; }; export const createSseBoundaryTracker = () => { const decoder = new TextDecoder(); let tail = ''; const normalize = (value) => value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); return { observe(value) { const text = typeof value === 'string' ? value : decoder.decode(value, { stream: true }); if (text.length > 0) { tail = `${tail}${normalize(text)}`; if (tail.length > 4096) { tail = tail.slice(-4096); } } return this.isAtBoundary(); }, isAtBoundary() { return tail.length === 0 || tail.endsWith('\n\n'); }, }; }; const SESSION_LIST_ALLOWED_FIELDS = [ 'id', 'slug', 'projectID', 'workspaceID', 'directory', 'path', 'parentID', 'title', 'agent', 'model', 'version', 'time', 'cost', 'tokens', 'share', 'metadata', 'project', ]; const sanitizeSessionListItem = (session) => { if (!session || typeof session !== 'object' || Array.isArray(session)) { return session; } const sanitized = {}; for (const key of SESSION_LIST_ALLOWED_FIELDS) { if (key in session) { sanitized[key] = session[key]; } } const summary = session.summary; if (summary && typeof summary === 'object' && !Array.isArray(summary)) { const summaryWithoutDiffs = { ...summary }; delete summaryWithoutDiffs.diffs; sanitized.summary = summaryWithoutDiffs; } const revert = session.revert; if (revert && typeof revert === 'object' && !Array.isArray(revert)) { const revertMarker = {}; if (typeof revert.messageID === 'string') { revertMarker.messageID = revert.messageID; } if (typeof revert.partID === 'string') { revertMarker.partID = revert.partID; } if (Object.keys(revertMarker).length > 0) { sanitized.revert = revertMarker; } } return sanitized; }; const sanitizeSessionListPayload = (payload) => { if (!Array.isArray(payload)) { return payload; } return payload.map((session) => sanitizeSessionListItem(session)); }; export const registerOpenCodeProxy = (app, deps) => { const { fs, os, path, OPEN_CODE_READY_GRACE_MS, LONG_REQUEST_TIMEOUT_MS, getRuntime, getOpenCodeAuthHeaders, buildOpenCodeUrl, ensureOpenCodeApiPrefix, SSE_HEARTBEAT_INTERVAL_MS = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS, SSE_UPSTREAM_STALL_TIMEOUT_MS = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS, getSseUpstreamStallTimeoutMs = () => SSE_UPSTREAM_STALL_TIMEOUT_MS, } = deps; if (app.get('opencodeProxyConfigured')) { return; } const runtime = getRuntime(); if (runtime.openCodePort) { console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`); } else { console.log('Setting up OpenCode API gate (OpenCode not started yet)'); } app.set('opencodeProxyConfigured', true); const isAbortError = (error) => error?.name === 'AbortError'; const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902'; const canonicalizeDirectoryQuery = createDirectoryQueryCanonicalizer({ realpath: fs?.promises?.realpath?.bind(fs.promises), }); const hasParsedBodyValue = (body) => { if (body === undefined || body === null) return false; if (Buffer.isBuffer(body)) return body.length > 0; if (typeof body === 'string') return body.length > 0; if (Array.isArray(body)) return body.length > 0; if (typeof body === 'object') return Object.keys(body).length > 0; return true; }; const getContentType = (proxyReq, req) => { const value = proxyReq.getHeader?.('content-type') ?? req.headers?.['content-type'] ?? ''; if (Array.isArray(value)) return value[0] || ''; return String(value || ''); }; const serializeUrlEncodedBody = (body) => { if (!body || typeof body !== 'object' || Buffer.isBuffer(body)) { return String(body ?? ''); } const params = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { for (const entry of value) { if (entry !== undefined && entry !== null) params.append(key, String(entry)); } continue; } params.append(key, String(value)); } return params.toString(); }; const serializeParsedBody = (req, proxyReq) => { if (req.method === 'GET' || req.method === 'HEAD') return null; if (req.body === undefined || req.body === null) return null; const originalContentLength = Number.parseInt(req.headers?.['content-length'] || '0', 10) || 0; if (!hasParsedBodyValue(req.body) && originalContentLength <= 0) return null; const contentType = getContentType(proxyReq, req).toLowerCase(); if (Buffer.isBuffer(req.body)) return req.body; if (contentType.includes('application/json')) return Buffer.from(JSON.stringify(req.body)); if (contentType.includes('application/x-www-form-urlencoded')) return Buffer.from(serializeUrlEncodedBody(req.body)); if (typeof req.body === 'string') return Buffer.from(req.body); return null; }; const replayParsedBody = (proxyReq, req) => { const body = serializeParsedBody(req, proxyReq); if (!body) return; proxyReq.setHeader('content-length', String(body.length)); proxyReq.write(body); }; const normalizeProxyTarget = (candidate) => { if (typeof candidate !== 'string') { return null; } const trimmed = candidate.trim(); if (!trimmed) { return null; } return trimmed.replace(/\/+$/, ''); }; // Keep generic proxy requests on the same upstream base URL that health checks // and direct fetch helpers use. This avoids split-brain state where /health // succeeds against an external host but /api/* still proxies to 127.0.0.1. const resolveProxyTarget = () => { try { const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', '')); if (resolved) { return resolved; } } catch { } const runtimeState = getRuntime(); const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl); if (externalBase) { return externalBase; } return FALLBACK_PROXY_TARGET; }; const normalizeProxyTimeout = (value) => { return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000; }; const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS); const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut'); // A provider OAuth callback blocks upstream for as long as the user takes to // sign in in their browser (device-code polling, or a loopback redirect), so // it cannot share the ordinary request deadline. Bounded by the shortest // upstream expiry we know of — GitHub device codes last ~15 minutes. const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000; const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/; const isInteractiveOAuthCallback = (req) => req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path); const isProxyTimeoutError = (error) => { const code = typeof error?.code === 'string' ? error.code : ''; const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; return code === 'ETIMEDOUT' || code === 'ESOCKETTIMEDOUT' || message.includes('timeout') || message.includes('timed out'); }; const sendProxyErrorResponse = (res, statusCode) => { if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') { return false; } res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' }); return true; }; const applyProxyResponseDeadline = (req, res, next) => { if (isInteractiveOAuthCallback(req)) { return next(); } const timeout = setTimeout(() => { req[PROXY_TIMEOUT_MARKER] = true; if (sendProxyErrorResponse(res, 504)) { res.once('finish', () => req.destroy?.()); } }, PROXY_REQUEST_TIMEOUT_MS); timeout.unref?.(); const clear = () => clearTimeout(timeout); res.once('finish', clear); res.once('close', clear); next(); }; const forwardSseRequest = async (req, res) => { const abortController = new AbortController(); const closeUpstream = () => abortController.abort(); let upstream = null; let reader = null; let heartbeatTimer = null; let upstreamStallTimer = null; let didUpstreamStall = false; let writeQueue = Promise.resolve(true); const sseBoundary = createSseBoundaryTracker(); req.on('close', closeUpstream); try { const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0 ? req.originalUrl : (typeof req.url === 'string' ? req.url : ''); const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl; const headers = normalizeForwardedDirectoryHeaders( collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()) ); headers.accept ??= 'text/event-stream'; headers['cache-control'] ??= 'no-cache'; upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), { method: 'GET', headers, signal: abortController.signal, }); res.status(upstream.status); applyForwardProxyResponseHeaders(upstream.headers, res); const contentType = upstream.headers.get('content-type') || 'text/event-stream'; const isEventStream = contentType.toLowerCase().includes('text/event-stream'); if (!upstream.body) { res.end(await upstream.text().catch(() => '')); return; } if (!isEventStream) { res.end(await upstream.text()); return; } res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } // Disable TCP Nagle's algorithm so small SSE chunks are sent immediately // instead of being buffered up to ~200ms by the TCP stack. if (res.socket && typeof res.socket.setNoDelay === 'function') { res.socket.setNoDelay(true); } const scheduleHeartbeat = () => { heartbeatTimer = setTimeout(async () => { if (abortController.signal.aborted || res.writableEnded || res.destroyed) { return; } if (!sseBoundary.isAtBoundary()) { scheduleHeartbeat(); return; } const canContinue = await enqueueSseWrite(':heartbeat\n\n'); if (canContinue) { scheduleHeartbeat(); } }, SSE_HEARTBEAT_INTERVAL_MS); }; const clearUpstreamStallTimer = () => { clearTimeout(upstreamStallTimer); upstreamStallTimer = null; }; const resetUpstreamStallTimer = () => { clearUpstreamStallTimer(); upstreamStallTimer = setTimeout(() => { didUpstreamStall = true; abortController.abort(); }, getSseUpstreamStallTimeoutMs()); upstreamStallTimer.unref?.(); }; const enqueueSseWrite = (value) => { writeQueue = writeQueue .catch(() => false) .then((canContinue) => { if (!canContinue) { return false; } return writeSseChunkWithBackpressure(res, value, abortController.signal); }); return writeQueue; }; scheduleHeartbeat(); resetUpstreamStallTimer(); reader = upstream.body.getReader(); while (!abortController.signal.aborted) { const { done, value } = await reader.read(); if (done) { break; } if (value && value.length > 0) { resetUpstreamStallTimer(); sseBoundary.observe(value); const canContinue = await enqueueSseWrite(value); if (!canContinue) { break; } } } res.end(); } catch (error) { if (isAbortError(error)) { if (didUpstreamStall && !res.writableEnded && !res.destroyed) { await writeQueue.catch(() => false); res.end(); } return; } console.error('[proxy] OpenCode SSE proxy error:', error?.message ?? error); if (!res.headersSent) { res.status(503).json({ error: 'OpenCode service unavailable' }); } else { res.end(); } } finally { if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; } if (upstreamStallTimer) { clearTimeout(upstreamStallTimer); upstreamStallTimer = null; } req.off('close', closeUpstream); try { if (reader) { await reader.cancel(); reader.releaseLock(); } else if (upstream?.body && !upstream.body.locked) { await upstream.body.cancel(); } } catch { } } }; const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => { const headers = req ? { ...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())), accept: 'application/json', 'accept-encoding': 'identity', } : { Accept: 'application/json', ...getOpenCodeAuthHeaders(), 'accept-encoding': 'identity', }; const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), { method: 'GET', headers, ...(typeof timeoutMs === 'number' ? { signal: AbortSignal.timeout(timeoutMs) } : {}), }); const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8'; const bodyText = await upstream.text(); const isJson = contentType.toLowerCase().includes('application/json'); if (!isJson) { return { upstream, contentType, bodyText, payload: null, isJson: false }; } try { const payload = JSON.parse(bodyText); return { upstream, contentType, bodyText, payload, isJson: true, parseError: null }; } catch (parseError) { return { upstream, contentType, bodyText, payload: null, isJson: true, parseError }; } }; const getRequestUpstreamPath = async (req) => { const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0 ? req.originalUrl : (typeof req.url === 'string' ? req.url : ''); const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl; return canonicalizeDirectoryQuery(upstreamPathRaw); }; const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => { try { const upstreamPath = await getRequestUpstreamPath(req); const result = await fetchSessionListPayload(upstreamPath, { req }); res.status(result.upstream.status); applyForwardProxyResponseHeaders(result.upstream.headers, res); if (!result.isJson) { res.setHeader('content-type', result.contentType); res.end(result.bodyText); return; } if (result.parseError || !Array.isArray(result.payload)) { res.setHeader('content-type', result.contentType); res.end(result.bodyText); return; } res.setHeader('content-type', result.contentType); res.json(sanitizeSessionListPayload(result.payload)); } catch (error) { if (isAbortError(error)) { return; } console.error(`[proxy] OpenCode ${logLabel} proxy error:`, error?.message ?? error); if (!res.headersSent) { res.status(503).json({ error: 'OpenCode service unavailable' }); return; } next(error); } }; // Ensure API prefix is detected before proxying app.use('/api', (_req, _res, next) => { ensureOpenCodeApiPrefix(); next(); }); // Readiness gate — while OpenCode is starting/restarting, HOLD the request and // poll readiness instead of returning 503 immediately. A bare 503 pushes the // client into an exponential-backoff retry loop (500ms → 1s → …) that wastes // seconds of cold-start time and can fail bootstrap outright. Holding the // request until OpenCode is ready (typically well under a second) lets the // first call simply succeed. We still 503 if readiness doesn't arrive within a // bounded window so genuinely-down servers fail fast. const READINESS_HOLD_POLL_MS = 75; const READINESS_HOLD_MAX_MS = 6000; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const isStillWaiting = (runtimeState) => { const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince; return ( (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || runtimeState.isRestartingOpenCode || !runtimeState.openCodePort ); }; const classifyReadinessRoute = (requestPath) => { if (/^\/session\/[^/]+\/message(?:\/|$)/.test(requestPath)) return 'session-messages'; if (requestPath === '/session' || requestPath.startsWith('/session/')) return 'session'; if (requestPath === '/event' || requestPath === '/global/event') return 'events'; return 'other'; }; app.use('/api', async (req, res, next) => { if ( req.path.startsWith('/themes/custom') || req.path.startsWith('/push') || req.path.startsWith('/config/agents') || req.path.startsWith('/config/opencode-resolution') || req.path.startsWith('/config/settings') || req.path.startsWith('/config/skills') || req.path === '/config/reload' || req.path === '/health' ) { return next(); } if (!isStillWaiting(getRuntime())) { return next(); } const holdStartedAt = performance.now(); const routeClass = classifyReadinessRoute(req.path); const deadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS); while (Date.now() < deadline) { // Client gave up (closed/aborted) — stop holding. if (res.writableEnded || req.aborted) { recordStartupPerformance('proxy.readiness-hold', { durationMs: performance.now() - holdStartedAt, outcome: 'aborted', routeClass, }); return; } await sleep(READINESS_HOLD_POLL_MS); if (!isStillWaiting(getRuntime())) { recordStartupPerformance('proxy.readiness-hold', { durationMs: performance.now() - holdStartedAt, outcome: 'ready', routeClass, }); return next(); } } recordStartupPerformance('proxy.readiness-hold', { durationMs: performance.now() - holdStartedAt, outcome: 'timeout', routeClass, }); if (!res.headersSent) { res.status(503).json({ error: 'OpenCode is restarting', restarting: true, }); } }); // Windows: session merge for cross-directory session listing if (process.platform === 'win32') { app.get('/api/session', async (req, res, next) => { const rawUrl = req.originalUrl || req.url || ''; if (rawUrl.includes('directory=')) return next(); const fetchWindowsSessionList = async (sessionPath) => { const result = await fetchSessionListPayload(sessionPath, { req, timeoutMs: 10000 }); if (!result.upstream.ok || !Array.isArray(result.payload)) return null; return sanitizeSessionListPayload(result.payload); }; try { const globalSessions = await fetchWindowsSessionList('/session').catch((error) => { console.log(`[SessionMerge] Global session list failed: ${error.message}`); return null; }); const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); let projectDirs = []; try { const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); const settings = JSON.parse(settingsRaw); projectDirs = (settings.projects || []) .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) .filter(Boolean); } catch { } const seen = new Set( (globalSessions || []) .map((session) => (session && typeof session.id === 'string' ? session.id : null)) .filter((id) => typeof id === 'string') ); const extraSessions = []; let successfulProjectReads = 0; for (const dir of projectDirs) { const candidates = Array.from(new Set([ dir, dir.replace(/\\/g, '/'), dir.replace(/\//g, '\\'), ])); for (const candidateDir of candidates) { const encoded = encodeURIComponent(candidateDir); try { const dirSessions = await fetchWindowsSessionList(`/session?directory=${encoded}`); if (dirSessions) { successfulProjectReads += 1; } for (const session of dirSessions || []) { const id = session && typeof session.id === 'string' ? session.id : null; if (id && !seen.has(id)) { seen.add(id); extraSessions.push(session); } } } catch { } } } if (!globalSessions && successfulProjectReads === 0) { return res.status(504).json({ error: 'OpenCode session list timed out' }); } const merged = [...(globalSessions || []), ...extraSessions]; merged.sort((a, b) => { const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; return bTime - aTime; }); console.log(`[SessionMerge] ${globalSessions?.length || 0} global + ${extraSessions.length} extra = ${merged.length} total`); return res.json(sanitizeSessionListPayload(merged)); } catch (error) { console.log(`[SessionMerge] Error: ${error.message}`); return res.status(500).json({ error: error.message || 'Failed to merge Windows sessions' }); } }); } app.get('/api/session', (req, res, next) => { return forwardSanitizedSessionListRequest(req, res, next, 'session.list'); }); app.get('/api/global/event', forwardSseRequest); app.get('/api/event', forwardSseRequest); app.get('/api/experimental/session', (req, res, next) => { return forwardSanitizedSessionListRequest(req, res, next, 'experimental.session'); }); // Generic proxy for non-SSE OpenCode API routes. const createApiProxy = (timeoutMs) => createProxyMiddleware({ target: resolveProxyTarget(), changeOrigin: true, pathRewrite: { '^/api': '' }, timeout: timeoutMs, proxyTimeout: timeoutMs, // Dynamic target — port can change after restart router: () => resolveProxyTarget(), on: { proxyReq: (proxyReq, req) => { // Inject OpenCode auth headers const authHeaders = getOpenCodeAuthHeaders(); if (authHeaders.Authorization) { proxyReq.setHeader('Authorization', authHeaders.Authorization); } if (req.headers?.['x-opencode-directory-encoding'] === 'uri') { const rawDirectory = req.headers['x-opencode-directory']; if (typeof rawDirectory === 'string') { try { proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory)); } catch { proxyReq.setHeader('x-opencode-directory', rawDirectory); } } proxyReq.removeHeader?.('x-opencode-directory-encoding'); } // Defensive: request identity encoding from upstream OpenCode. // This avoids compressed-body/header mismatches in multi-proxy setups. proxyReq.setHeader('accept-encoding', 'identity'); replayParsedBody(proxyReq, req); }, proxyRes: (proxyRes) => { for (const key of Object.keys(proxyRes.headers || {})) { if (!shouldForwardProxyResponseHeader(key)) { delete proxyRes.headers[key]; } } }, error: (err, req, res) => { console.error('[proxy] OpenCode proxy error:', err.message); if (req?.[PROXY_TIMEOUT_MARKER]) { return; } const statusCode = isProxyTimeoutError(err) ? 504 : 503; sendProxyErrorResponse(res, statusCode); }, }, }); const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS); const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS); // Best-effort fallback for stale clients still sending symlink paths. // Settings and project selection normalize at source; this cached async path // avoids blocking the proxy hot path on every directory-scoped request. app.use('/api', async (req, _res, next) => { try { const rewrittenUrl = await canonicalizeDirectoryQuery(req.url); if (rewrittenUrl !== req.url) { req.url = rewrittenUrl; } } catch { // Pass through as-is if URL parsing or realpath resolution fails. } next(); }); app.use('/api', applyProxyResponseDeadline); app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy); // OpenCode's native MCP OAuth flow: the request blocks until the user // finishes authorization in the browser (up to OpenCode's 5-minute callback // timeout), so it needs the interactive-OAuth deadline, not the default one. app.post('/api/mcp/:name/auth/authenticate', interactiveOAuthProxy); app.use('/api', apiProxy); };