diff --git a/packages/desktop/scripts/desktop-dev.mjs b/packages/desktop/scripts/desktop-dev.mjs index a52dfd61..f7315b47 100644 --- a/packages/desktop/scripts/desktop-dev.mjs +++ b/packages/desktop/scripts/desktop-dev.mjs @@ -18,7 +18,16 @@ function spawnProcess(command, args, opts = {}) { } async function main() { - const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']); + const tauriProcess = spawnProcess('bun', [ + '--cwd', + desktopDir, + 'tauri', + 'dev', + '--features', + 'devtools', + '--config', + './src-tauri/tauri.dev.conf.json', + ]); let cleaning = false; diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index c4739030..74066bc4 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -1187,6 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3411,6 +3412,7 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index fc3715e4..170581a2 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ devtools = ["tauri/devtools"] anyhow = "1.0.86" base64 = "0.22.1" log = "0.4.28" -reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" tauri = { version = "2.9.4", features = ["macos-private-api"] } diff --git a/packages/desktop/src-tauri/icons/dev-icon.icns b/packages/desktop/src-tauri/icons/dev-icon.icns new file mode 100644 index 00000000..04ac8d54 Binary files /dev/null and b/packages/desktop/src-tauri/icons/dev-icon.icns differ diff --git a/packages/desktop/src-tauri/icons/dev-icon.png b/packages/desktop/src-tauri/icons/dev-icon.png new file mode 100644 index 00000000..bab0ca21 Binary files /dev/null and b/packages/desktop/src-tauri/icons/dev-icon.png differ diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 138633ce..d361738e 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1476,10 +1476,27 @@ fn kill_sidecar(app: tauri::AppHandle) { return; }; + let sidecar_url = state.url.lock().expect("sidecar url mutex").clone(); + if let Some(url) = sidecar_url { + let shutdown_url = format!("{}/api/system/shutdown", url.trim_end_matches('/')); + if let Ok(client) = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(1500)) + .build() + { + if let Ok(resp) = client.post(shutdown_url).send() { + if resp.status().is_success() { + std::thread::sleep(Duration::from_millis(100)); + } + } + } + } + let mut guard = state.child.lock().expect("sidecar mutex"); if let Some(child) = guard.take() { let _ = child.kill(); } + *state.url.lock().expect("sidecar url mutex") = None; } fn build_local_url(port: u16) -> String { @@ -1627,6 +1644,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { .args(["--port", &port.to_string()]) .env("OPENCHAMBER_HOST", "127.0.0.1") .env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) + .env("OPENCHAMBER_RUNTIME", "desktop") .env("OPENCHAMBER_DESKTOP_NOTIFY", "true") .env("PATH", augmented_path.clone()) .env("NO_PROXY", no_proxy) @@ -1643,6 +1661,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { } } + if let Ok(password) = env::var("OPENCODE_SERVER_PASSWORD") { + let trimmed = password.trim(); + if !trimmed.is_empty() { + cmd = cmd.env("OPENCODE_SERVER_PASSWORD", trimmed); + } + } + let (rx, child) = match cmd.spawn() { Ok(v) => v, Err(err) => { diff --git a/packages/desktop/src-tauri/tauri.dev.conf.json b/packages/desktop/src-tauri/tauri.dev.conf.json new file mode 100644 index 00000000..79324ff9 --- /dev/null +++ b/packages/desktop/src-tauri/tauri.dev.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/schema.json", + "bundle": { + "icon": [ + "icons/dev-icon.icns", + "icons/dev-icon.png" + ] + } +} diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index a0476ed3..7854e1cb 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -91,6 +91,19 @@ export const VSCodeLayout: React.FC = () => { return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; }, [currentSessionId, sessions]); const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); + const isSyncingMessages = useSessionStore((state) => state.isSyncing); + const hasActiveSessionWork = useSessionStore((state) => { + const statuses = state.sessionStatus; + if (!statuses || statuses.size === 0) { + return false; + } + for (const status of statuses.values()) { + if (status?.type === 'busy' || status?.type === 'retry') { + return true; + } + } + return false; + }); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( () => (typeof window !== 'undefined' @@ -129,10 +142,37 @@ export const VSCodeLayout: React.FC = () => { return; } - if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') { - setCurrentView('sessions'); + if (currentView !== 'chat') { + return; } - }, [currentSessionId, newSessionDraftOpen, currentView, viewMode]); + + if (currentSessionId || newSessionDraftOpen || isSyncingMessages || hasActiveSessionWork) { + return; + } + + const timeoutId = window.setTimeout(() => { + const state = useSessionStore.getState(); + const stillNoSession = !state.currentSessionId; + const draftStillClosed = !state.newSessionDraft?.open; + const stillSyncing = state.isSyncing; + const stillActiveWork = (() => { + const statuses = state.sessionStatus; + if (!statuses || statuses.size === 0) return false; + for (const status of statuses.values()) { + if (status?.type === 'busy' || status?.type === 'retry') return true; + } + return false; + })(); + + if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) { + setCurrentView('sessions'); + } + }, 900); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [currentSessionId, newSessionDraftOpen, currentView, viewMode, isSyncingMessages, hasActiveSessionWork]); const handleBackToSessions = React.useCallback(() => { setCurrentView('sessions'); diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index fd5a096f..18b07601 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -486,7 +486,7 @@ export const useEventStream = () => { // Note: needs_attention logic is now handled by the server // Server maintains authoritative state based on view tracking and message events - if (prevType !== nextType) { + if (process.env.NODE_ENV === 'development' && prevType !== nextType) { try { console.info('[SESSION-STATUS]', { sessionId, diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 28a3e92b..5e0620a0 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -264,11 +264,29 @@ export const debugUtils = { const resp = await fetch('/api/health'); const contentType = resp.headers.get('content-type') || ''; const body = await safeText(resp); + const isJson = contentType.toLowerCase().includes('application/json'); + let parsed: Record | null = null; + if (isJson && body) { + try { + const candidate = JSON.parse(body) as unknown; + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + parsed = candidate as Record; + } + } catch { + parsed = null; + } + } opencodeHealth = { status: resp.status, ok: resp.ok, contentType, - type: contentType.includes('application/json') ? 'json' : 'html', + type: isJson ? 'json' : 'html', + openCodePort: parsed?.openCodePort ?? null, + openCodeRunning: parsed?.openCodeRunning ?? null, + openCodeSecureConnection: parsed?.openCodeSecureConnection ?? null, + openCodeAuthSource: parsed?.openCodeAuthSource ?? null, + isOpenCodeReady: parsed?.isOpenCodeReady ?? null, + lastOpenCodeError: parsed?.lastOpenCodeError ?? null, preview: body ? body.slice(0, 120) : null, }; } catch (error) { diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 959cce49..0527d2b5 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -13,6 +13,8 @@ type ProbeResult = { type OpenChamberHealthSnapshot = { openCodePort?: unknown; openCodeRunning?: unknown; + openCodeSecureConnection?: unknown; + openCodeAuthSource?: unknown; isOpenCodeReady?: unknown; lastOpenCodeError?: unknown; opencodeBinaryResolved?: unknown; @@ -100,6 +102,19 @@ const formatIso = (timestamp: number | null | undefined): string => { } }; +const normalizePort = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +}; + export const buildOpenCodeStatusReport = async (): Promise => { const now = new Date(); const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)'; @@ -215,6 +230,18 @@ export const buildOpenCodeStatusReport = async (): Promise => { lines.push(`Directory: ${directory || '(none)'}`); lines.push(`Platform: ${platform}`); + const runtimeOpenCodePort = normalizePort(openChamberHealth?.openCodePort); + lines.push(`OpenCode runtime port: ${runtimeOpenCodePort ?? '(unknown)'}`); + if (typeof openChamberHealth?.openCodeRunning === 'boolean') { + lines.push(`OpenCode runtime running: ${openChamberHealth.openCodeRunning ? 'yes' : 'no'}`); + } + if (typeof openChamberHealth?.openCodeSecureConnection === 'boolean') { + lines.push(`Secure OpenCode connection: ${openChamberHealth.openCodeSecureConnection ? 'true' : 'false'}`); + } + if (typeof openChamberHealth?.openCodeAuthSource === 'string' && openChamberHealth.openCodeAuthSource.trim()) { + lines.push(`OpenCode auth source: ${openChamberHealth.openCodeAuthSource}`); + } + if (typeof window !== 'undefined') { const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__; if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) { diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index d2b99481..3ab582ef 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -1707,12 +1707,14 @@ export const useMessageStore = create()( }; if (messageIndex === -1) { - console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { - sessionId, - messageId, - messageInfo, - existingCount: normalizedSessionMessages.length, - }); + if (process.env.NODE_ENV === 'development') { + console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { + sessionId, + messageId, + messageInfo, + existingCount: normalizedSessionMessages.length, + }); + } if (normalizedSessionMessages.length > 0) { const firstMessage = normalizedSessionMessages[0]; diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 6d6b2581..1ca10988 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -176,7 +176,10 @@ export class AgentManagerPanelProvider { let response: Response; let wrapAsGlobal = false; - const requestHeaders = this._buildSseHeaders(headers || {}); + const requestHeaders = this._buildSseHeaders({ + ...(headers || {}), + ...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}), + }); try { response = await fetch(targetUrl, { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 545be6e8..6fdaf8c1 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -204,7 +204,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { let response: Response; let wrapAsGlobal = false; - const requestHeaders = this._buildSseHeaders(headers || {}); + const requestHeaders = this._buildSseHeaders({ + ...(headers || {}), + ...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}), + }); try { response = await fetch(targetUrl, { diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 70f2f40f..5790c3fc 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -199,7 +199,10 @@ export class SessionEditorPanelProvider { let response: Response; let wrapAsGlobal = false; - const requestHeaders = this._buildSseHeaders(headers || {}); + const requestHeaders = this._buildSseHeaders({ + ...(headers || {}), + ...(this._openCodeManager?.getOpenCodeAuthHeaders() || {}), + }); try { response = await fetch(targetUrl, { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 8e3e6881..c79bc8e3 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -806,7 +806,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const base = `${apiUrl.replace(/\/+$/, '')}/`; const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = sanitizeForwardHeaders(headers); + const requestHeaders: Record = { + ...sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; // Ensure SSE requests are negotiated correctly. if (normalizedPath === '/event' || normalizedPath === '/global/event') { @@ -875,7 +878,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const base = `${apiUrl.replace(/\/+$/, '')}/`; const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = sanitizeForwardHeaders(headers); + const requestHeaders: Record = { + ...sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; try { const response = await fetch(targetUrl, { diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 04352756..1a7e7152 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -362,10 +362,11 @@ export async function activate(context: vscode.ExtensionContext) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const startedAt = Date.now(); + const openCodeAuthHeaders = openCodeManager?.getOpenCodeAuthHeaders() || {}; try { const resp = await fetch(input, { method: 'GET', - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', ...openCodeAuthHeaders }, signal: controller.signal, }); const elapsedMs = Date.now() - startedAt; @@ -465,7 +466,13 @@ export async function activate(context: vscode.ExtensionContext) { ? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})` : `OpenCode mode: (unknown)`, debug - ? `OpenCode CLI path: ${debug.cliPath || '(not found - SDK manages process)'}` + ? `Secure OpenCode connection: ${debug.secureConnection ? 'true' : 'false'}` + : `Secure OpenCode connection: (unknown)`, + debug + ? `OpenCode auth source: ${debug.authSource ?? '(none)'}` + : `OpenCode auth source: (unknown)`, + debug + ? `OpenCode CLI path: ${debug.cliPath || '(not found)'}` : `OpenCode CLI path: (unknown)`, debug ? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}` diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index adbdddc2..65f920af 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -2,12 +2,13 @@ import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; +import * as net from 'net'; import { execSync } from 'child_process'; import { spawnSync } from 'child_process'; -import { createOpencodeServer } from '@opencode-ai/sdk/v2/server'; +import { spawn } from 'child_process'; +import { randomBytes } from 'crypto'; const READY_CHECK_TIMEOUT_MS = 30000; - export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; export type OpenCodeDebugInfo = { @@ -32,6 +33,8 @@ export type OpenCodeDebugInfo = { lastReadyAttempts: number | null; lastStartAttempts: number | null; version: string | null; + secureConnection: boolean; + authSource: 'user-env' | 'generated' | 'rotated' | null; }; export interface OpenCodeManager { @@ -41,12 +44,43 @@ export interface OpenCodeManager { setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>; getStatus(): ConnectionStatus; getApiUrl(): string | null; + getOpenCodeAuthHeaders(): Record; getWorkingDirectory(): string; isCliAvailable(): boolean; getDebugInfo(): OpenCodeDebugInfo; onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable; } +function generateSecureOpenCodePassword(): string { + return randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function buildOpenCodeAuthHeader(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`; +} + +function isValidOpenCodePassword(password: string): boolean { + return typeof password === 'string' && password.trim().length > 0; +} + +function readOpenChamberSettings(): Record { + const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + try { + const raw = fs.readFileSync(settingsPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + function resolvePortFromUrl(url: string): number | null { try { const parsed = new URL(url); @@ -110,13 +144,8 @@ function resolveOpencodeCliPath(): string | null { const sharedFromOpenChamber = (() => { try { - const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); - const raw = fs.readFileSync(settingsPath, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null; - } - const candidate = (parsed as Record).opencodeBinary; + const settings = readOpenChamberSettings(); + const candidate = settings.opencodeBinary; if (typeof candidate !== 'string') { return null; } @@ -258,7 +287,11 @@ function getCandidateBaseUrls(serverUrl: string): string[] { } } -async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise { +async function waitForReady( + serverUrl: string, + timeoutMs = 15000, + authHeaders: Record = {} +): Promise { const outputChannel = vscode.window.createOutputChannel('OpenChamberManager'); const start = Date.now(); const candidates = getCandidateBaseUrls(serverUrl); @@ -275,7 +308,7 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise void }> { + const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)]; + const child = spawn(binary, args, { + cwd: workingDirectory, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const url = await new Promise((resolve, reject) => { + let output = ''; + let settled = false; + + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + child.off('error', onError); + }; + + const onStdout = (chunk: Buffer) => { + output += chunk.toString(); + const lines = output.split('\n'); + for (const line of lines) { + if (!line.startsWith('opencode server listening')) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match) { + cleanup(); + reject(new Error(`Failed to parse server url from output: ${line}`)); + return; + } + cleanup(); + resolve(match[1]); + return; + } + }; + + const onStderr = (chunk: Buffer) => { + output += chunk.toString(); + }; + + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`OpenCode exited with code ${code}. Output: ${output}`)); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.on('exit', onExit); + child.on('error', onError); + }); + + return { + url, + close: () => { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + }, + }; +} + +async function allocateManagedOpenCodePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once('error', (error) => { + reject(error); + }); + + server.once('listening', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close(() => { + if (port > 0) { + resolve(port); + return; + } + reject(new Error('Failed to allocate OpenCode port')); + }); + }); + + server.listen(0, '127.0.0.1'); + }); +} + export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager { - // Discard unused parameter - reserved for future use (state persistence, subscriptions) void _context; let server: { url: string; close: () => void } | null = null; let managedApiUrlOverride: string | null = null; + let managedPassword: string | null = null; + let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null; + const userProvidedEnvPassword = (() => { + const normalized = (process.env.OPENCODE_SERVER_PASSWORD || '').trim(); + return isValidOpenCodePassword(normalized) ? normalized : null; + })(); let status: ConnectionStatus = 'disconnected'; let lastError: string | undefined; const listeners = new Set<(status: ConnectionStatus, error?: string) => void>(); @@ -375,7 +518,48 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return null; }; - async function startInternal(workdir?: string): Promise { + const getOpenCodeAuthHeaders = (): Record => { + const password = (managedPassword || userProvidedEnvPassword || process.env.OPENCODE_SERVER_PASSWORD || '').trim(); + if (!password) { + return {}; + } + return { Authorization: buildOpenCodeAuthHeader(password) }; + }; + + const setManagedPasswordState = ( + password: string, + source: 'user-env' | 'generated' | 'rotated' + ): string => { + const normalized = password.trim(); + managedPassword = normalized; + managedPasswordSource = source; + process.env.OPENCODE_SERVER_PASSWORD = normalized; + return normalized; + }; + + const ensureManagedOpenCodeServerPassword = async ({ rotateManaged = false }: { rotateManaged?: boolean } = {}): Promise => { + if (userProvidedEnvPassword) { + return setManagedPasswordState(userProvidedEnvPassword, 'user-env'); + } + + if (rotateManaged) { + return setManagedPasswordState(generateSecureOpenCodePassword(), 'rotated'); + } + + if (managedPassword && isValidOpenCodePassword(managedPassword)) { + return setManagedPasswordState( + managedPassword, + managedPasswordSource || 'generated' + ); + } + + return setManagedPasswordState(generateSecureOpenCodePassword(), 'generated'); + }; + + async function startInternal( + workdir?: string, + options: { rotateManaged?: boolean } = {} + ): Promise { startCount += 1; setStatus('connecting'); lastStartAt = Date.now(); @@ -418,18 +602,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo process.env.OPENCODE_BINARY = resolvedCli; } + const password = await ensureManagedOpenCodeServerPassword({ + rotateManaged: options.rotateManaged === true, + }); + process.env.OPENCODE_SERVER_PASSWORD = password; + // SDK spawns `opencode serve` in current process cwd. // Some OpenCode endpoints behave differently based on server process cwd, // so ensure we start it from the workspace directory. const originalCwd = process.cwd(); try { process.chdir(workingDirectory); - server = await createOpencodeServer({ - hostname: '127.0.0.1', - port: 0, - timeout: READY_CHECK_TIMEOUT_MS, - signal: undefined, - }); + const port = await allocateManagedOpenCodePort(); + server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS); } finally { try { process.chdir(originalCwd); @@ -440,7 +625,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo if (server && server.url) { // Validate readiness for the current workspace context. - const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS); + const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS, getOpenCodeAuthHeaders()); lastReadyElapsedMs = ready.elapsedMs; lastReadyAttempts = ready.attempts; if (ready.ok) { @@ -496,7 +681,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo server = null; } - // SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary. // Kill any process listening on our port to clean up orphaned children. if (portToKill) { try { @@ -530,7 +714,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo restartCount += 1; await stopInternal(); await new Promise(r => setTimeout(r, 250)); - await startInternal(); + await startInternal(undefined, { rotateManaged: true }); } async function start(workdir?: string): Promise { @@ -541,7 +725,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } } lastStartAttempts = 1; - pendingOperation = startInternal(workdir); + pendingOperation = startInternal(workdir, { rotateManaged: true }); try { await pendingOperation; } finally { @@ -603,31 +787,37 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo setWorkingDirectory, getStatus: () => status, getApiUrl, + getOpenCodeAuthHeaders, getWorkingDirectory: () => workingDirectory, isCliAvailable: () => !cliMissing, - getDebugInfo: () => ({ - mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed', - status, - lastError, - workingDirectory, - cliAvailable: !cliMissing, - cliPath, - configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null, - configuredPort, - detectedPort, - apiPrefix: '', - apiPrefixDetected: true, - startCount, - restartCount, - lastStartAt, - lastConnectedAt, - lastExitCode, - serverUrl: getApiUrl(), - lastReadyElapsedMs, - lastReadyAttempts, - lastStartAttempts, - version, - }), + getDebugInfo: () => { + const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization); + return { + mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed', + status, + lastError, + workingDirectory, + cliAvailable: !cliMissing, + cliPath, + configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null, + configuredPort, + detectedPort, + apiPrefix: '', + apiPrefixDetected: true, + startCount, + restartCount, + lastStartAt, + lastConnectedAt, + lastExitCode, + serverUrl: getApiUrl(), + lastReadyElapsedMs, + lastReadyAttempts, + lastStartAttempts, + version, + secureConnection, + authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null), + }; + }, onStatusChange(callback) { listeners.add(callback); callback(status, lastError); diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index 8f82b68e..82423d3e 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -191,11 +191,13 @@ export const startGlobalEventWatcher = async ( } const url = buildOpenCodeUrl('/global/event', baseUrl); + const authHeaders = manager.getOpenCodeAuthHeaders(); upstream = await fetch(url, { headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', + ...authHeaders, }, signal, }); diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 16ff0ad2..f7ff5e88 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -303,6 +303,19 @@ const decodeBase64 = (value: string): Uint8Array => { return bytes; }; +const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304; + +const buildProxiedResponse = ( + proxied: { status: number; headers: Record; bodyBase64?: string } +): Response => { + if (isNullBodyStatus(proxied.status)) { + return new Response(null, { status: proxied.status, headers: proxied.headers }); + } + + const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); + return new Response(body, { status: proxied.status, headers: proxied.headers }); +}; + const encodeBase64 = (bytes: Uint8Array): string => { const CHUNK = 0x8000; let binary = ''; @@ -375,6 +388,47 @@ const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/m const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const pathname = url.pathname; + const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname; + const method = ((init?.method || 'GET') as string).toUpperCase(); + + if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { + return new Response(JSON.stringify({ + statusSessions: {}, + attentionSessions: {}, + serverTime: Date.now(), + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (normalizedPathname === '/api/tts/status' && method === 'GET') { + return new Response(JSON.stringify({ available: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (normalizedPathname === '/api/tts/say/status' && method === 'GET') { + return new Response(JSON.stringify({ available: false, voices: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/tts/summarize') && method === 'POST') { + return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), { + status: 501, + headers: { 'Content-Type': 'application/json' }, + }); + } // Health endpoints: reflect actual connection status if (pathname === '/health' || pathname === '/api/health') { @@ -792,8 +846,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) { const bodyText = await extractBodyText(input, init, method); const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText }); - const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); - const response = new Response(body, { status: proxied.status, headers: proxied.headers }); + const response = buildProxiedResponse(proxied); recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); return response; @@ -801,8 +854,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const bodyBase64 = await extractBodyBase64(input, init, method); const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 }); - const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array(); - const response = new Response(body, { status: proxied.status, headers: proxied.headers }); + const response = buildProxiedResponse(proxied); recordBootstrapFetch(targetUrl.pathname, response.ok); maybeHideLoadingOverlay(); return response; diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index aeb7572e..34396026 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -450,6 +450,23 @@ function isProcessRunning(pid) { } } +async function requestServerShutdown(port) { + if (!Number.isFinite(port) || port <= 0) return false; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + try { + const resp = await fetch(`http://127.0.0.1:${port}/api/system/shutdown`, { + method: 'POST', + signal: controller.signal, + }); + return resp.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + const commands = { async serve(options) { options.port = await resolveAvailablePort(options.port); @@ -675,6 +692,7 @@ const commands = { console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`); try { + await requestServerShutdown(targetInstance.port); process.kill(targetInstance.pid, 'SIGTERM'); let attempts = 0; @@ -709,6 +727,7 @@ const commands = { console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`); try { + await requestServerShutdown(instance.port); process.kill(instance.pid, 'SIGTERM'); let attempts = 0; @@ -814,6 +833,7 @@ const commands = { // Stop the instance try { + await requestServerShutdown(instance.port); process.kill(instance.pid, 'SIGTERM'); // Wait for it to stop let attempts = 0; @@ -970,6 +990,7 @@ const commands = { console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`); for (const instance of runningInstances) { try { + await requestServerShutdown(instance.port); process.kill(instance.pid, 'SIGTERM'); let attempts = 0; while (isProcessRunning(instance.pid) && attempts < 20) { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index f412becb..ae6ee87b 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -3,6 +3,7 @@ import path from 'path'; import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import http from 'http'; +import net from 'net'; import { WebSocketServer } from 'ws'; import { fileURLToPath } from 'url'; import os from 'os'; @@ -20,7 +21,6 @@ import { pruneRebindTimestamps, readTerminalInputWsControlFrame, } from './lib/terminal-input-ws-protocol.js'; -import { createOpencodeServer } from '@opencode-ai/sdk/server'; import webPush from 'web-push'; const __filename = fileURLToPath(import.meta.url); @@ -2734,15 +2734,30 @@ const getHmrState = () => { globalThis[HMR_STATE_KEY] = { openCodeProcess: null, openCodePort: null, - openCodeWorkingDirectory: os.homedir(), - isShuttingDown: false, - signalsAttached: false, - }; + openCodeWorkingDirectory: os.homedir(), + isShuttingDown: false, + signalsAttached: false, + userProvidedOpenCodePassword: undefined, + openCodeAuthPassword: null, + openCodeAuthSource: null, + }; } return globalThis[HMR_STATE_KEY]; }; const hmrState = getHmrState(); +const normalizeOpenCodePassword = (value) => { + if (typeof value !== 'string') { + return ''; + } + return value.trim(); +}; + +if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') { + const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD); + hmrState.userProvidedOpenCodePassword = initialPassword || null; +} + // Non-HMR state (safe to reset on reload) let healthCheckInterval = null; let server = null; @@ -2762,6 +2777,18 @@ let exitOnShutdown = true; let uiAuthController = null; let cloudflareTunnelController = null; let terminalInputWsServer = null; +const userProvidedOpenCodePassword = + typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 + ? hmrState.userProvidedOpenCodePassword + : null; +let openCodeAuthPassword = + typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 + ? hmrState.openCodeAuthPassword + : userProvidedOpenCodePassword; +let openCodeAuthSource = + typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 + ? hmrState.openCodeAuthSource + : (userProvidedOpenCodePassword ? 'user-env' : null); // Sync helper - call after modifying any HMR state variable const syncToHmrState = () => { @@ -2770,6 +2797,8 @@ const syncToHmrState = () => { hmrState.isShuttingDown = isShuttingDown; hmrState.signalsAttached = signalsAttached; hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory; + hmrState.openCodeAuthPassword = openCodeAuthPassword; + hmrState.openCodeAuthSource = openCodeAuthSource; }; // Sync helper - call to restore state from HMR (e.g., on module reload) @@ -2779,6 +2808,14 @@ const syncFromHmrState = () => { isShuttingDown = hmrState.isShuttingDown; signalsAttached = hmrState.signalsAttached; openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; + openCodeAuthPassword = + typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 + ? hmrState.openCodeAuthPassword + : userProvidedOpenCodePassword; + openCodeAuthSource = + typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 + ? hmrState.openCodeAuthSource + : (userProvidedOpenCodePassword ? 'user-env' : null); }; // Module-level variables that shadow HMR state @@ -2858,18 +2895,13 @@ const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true'; // OpenCode server authentication (Basic Auth with username "opencode") -const ENV_OPENCODE_SERVER_PASSWORD = (() => { - const pwd = process.env.OPENCODE_SERVER_PASSWORD; - return typeof pwd === 'string' && pwd.length > 0 ? pwd : null; -})(); /** * Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set. * Uses Basic Auth with username "opencode" and the password from the env variable. */ function getOpenCodeAuthHeaders() { - // Re-read from env each time in case it wasn't set at module load (HMR issue) - const password = ENV_OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD; + const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || ''); if (!password) { return {}; @@ -2879,6 +2911,60 @@ function getOpenCodeAuthHeaders() { return { Authorization: `Basic ${credentials}` }; } +function isOpenCodeConnectionSecure() { + return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization'); +} + +function generateSecureOpenCodePassword() { + return crypto + .randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function isValidOpenCodePassword(password) { + return typeof password === 'string' && password.trim().length > 0; +} + +function setOpenCodeAuthState(password, source) { + const normalized = normalizeOpenCodePassword(password); + if (!isValidOpenCodePassword(normalized)) { + openCodeAuthPassword = null; + openCodeAuthSource = null; + delete process.env.OPENCODE_SERVER_PASSWORD; + syncToHmrState(); + return null; + } + + openCodeAuthPassword = normalized; + openCodeAuthSource = source; + process.env.OPENCODE_SERVER_PASSWORD = normalized; + syncToHmrState(); + return normalized; +} + +async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) { + if (isValidOpenCodePassword(userProvidedOpenCodePassword)) { + return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env'); + } + + if (rotateManaged) { + const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated'); + console.log('Rotated secure password for managed local OpenCode instance'); + return rotatedPassword; + } + + if (isValidOpenCodePassword(openCodeAuthPassword)) { + return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated'); + } + + const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated'); + console.log('Generated secure password for managed local OpenCode instance'); + return generatedPassword; +} + const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' ); @@ -4422,7 +4508,6 @@ function parseArgs(argv = process.argv.slice(2)) { function killProcessOnPort(port) { if (!port) return; try { - // SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary. // Kill any process listening on our port to clean up orphaned children. const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 }); const output = result.stdout || ''; @@ -4442,27 +4527,143 @@ function killProcessOnPort(port) { } } +async function createManagedOpenCodeServerProcess({ + hostname, + port, + timeout, + cwd, + env, +}) { + const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + const args = ['serve', '--hostname', hostname, '--port', String(port)]; + const child = spawn(binary, args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const url = await new Promise((resolve, reject) => { + let output = ''; + let done = false; + const finish = (handler, value) => { + if (done) return; + done = true; + clearTimeout(timer); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + child.off('error', onError); + handler(value); + }; + + const onStdout = (chunk) => { + output += chunk.toString(); + const lines = output.split('\n'); + for (const line of lines) { + if (!line.startsWith('opencode server listening')) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match) { + finish(reject, new Error(`Failed to parse server url from output: ${line}`)); + return; + } + finish(resolve, match[1]); + return; + } + }; + + const onStderr = (chunk) => { + output += chunk.toString(); + }; + + const onExit = (code) => { + finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); + }; + + const onError = (error) => { + finish(reject, error); + }; + + const timer = setTimeout(() => { + finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); + }, timeout); + + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.on('exit', onExit); + child.on('error', onError); + }); + + return { + url, + close() { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + }, + }; +} + +async function resolveManagedOpenCodePort(requestedPort) { + if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { + return requestedPort; + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + const cleanup = () => { + server.removeAllListeners('error'); + server.removeAllListeners('listening'); + }; + + server.once('error', (error) => { + cleanup(); + reject(error); + }); + + server.once('listening', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close(() => { + cleanup(); + if (port > 0) { + resolve(port); + return; + } + reject(new Error('Failed to allocate OpenCode port')); + }); + }); + + server.listen(0, '127.0.0.1'); + }); +} + async function startOpenCode() { const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0; + const spawnPort = await resolveManagedOpenCodePort(desiredPort); console.log( desiredPort > 0 ? `Starting OpenCode on requested port ${desiredPort}...` - : 'Starting OpenCode with dynamic port assignment...' + : `Starting OpenCode on allocated port ${spawnPort}...` ); - // Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK. await applyOpencodeBinaryFromSettings(); ensureOpencodeCliEnv(); + const openCodePassword = await ensureLocalOpenCodeServerPassword({ + rotateManaged: true, + }); try { - const serverInstance = await createOpencodeServer({ + const serverInstance = await createManagedOpenCodeServerProcess({ hostname: '127.0.0.1', - port: desiredPort, + port: spawnPort, timeout: 30000, + cwd: openCodeWorkingDirectory, env: { ...process.env, - // Pass minimal config to avoid pollution, but inherit PATH etc - } + OPENCODE_SERVER_PASSWORD: openCodePassword, + }, }); if (!serverInstance || !serverInstance.url) { @@ -5350,6 +5551,8 @@ async function main(options = {}) { timestamp: new Date().toISOString(), openCodePort: openCodePort, openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), + openCodeSecureConnection: isOpenCodeConnectionSecure(), + openCodeAuthSource: openCodeAuthSource || null, openCodeApiPrefix: '', openCodeApiPrefixDetected: true, isOpenCodeReady, @@ -5362,6 +5565,13 @@ async function main(options = {}) { }); }); + app.post('/api/system/shutdown', (req, res) => { + res.json({ ok: true }); + gracefulShutdown({ exitProcess: false }).catch((error) => { + console.error('Shutdown request failed:', error?.message || error); + }); + }); + app.use((req, res, next) => { if ( req.path.startsWith('/api/config/agents') ||