From 690ac62f19c35ed77490df0b54de63a48921df2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Quang=20=C4=90=C3=A3ng?= Date: Tue, 12 May 2026 14:11:57 +0000 Subject: [PATCH 1/7] Port security & bug fixes from 9router (CWE-1385, dropdown dark theme, DATA_DIR EACCES, Fal.ai endpoint, developer role) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/src/components/page.tsx: scope OAuth callback postMessage to the existing expectedOrigins allowlist instead of '*' (CWE-1385, 9router #998). The wildcard origin leaked the live OAuth code/state to any page that could open the popup against the well-known redirect_uri. - web/src/shared/components/styles/global.css: add explicit color-scheme rules for ) => setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} + + +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 || selectedApiKey ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_openproxy (default)"} + + )} +
+ +
+ + Model + + + arrow_forward + +
+ ) => setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+ + + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + {status?.hasOpenProxy && ( + + )} + +
+ + )} + + )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + toolName={tool.name} + configs={getManualConfigs()} + /> + + ); +} diff --git a/web/src/components/cli-tools/KiloToolCard.tsx b/web/src/components/cli-tools/KiloToolCard.tsx new file mode 100644 index 00000000..df25c262 --- /dev/null +++ b/web/src/components/cli-tools/KiloToolCard.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { ChangeEvent } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import EndpointPresetControl from "./EndpointPresetControl"; + +interface Tool { + name: string; + description: string; +} + +interface ApiKey { + id: string; + key: string; +} + +interface KiloStatus { + installed: boolean; + error?: string; + hasOpenProxy?: boolean; + settings?: { + auth?: string[]; + }; + authPath?: string; +} + +interface Message { + type: "success" | "error"; + text: string; +} + +interface KiloToolCardProps { + tool: Tool; + isExpanded: boolean; + onToggle: () => void; + baseUrl: string; + apiKeys: ApiKey[]; + activeProviders: string[]; + cloudEnabled: boolean; + initialStatus?: KiloStatus | null; +} + +export default function KiloToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, +}: KiloToolCardProps): React.ReactNode { + const [status, setStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + + const normalizeLocalhost = (url: string): string => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = (): string => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:4623"; + }; + + // Kilo expects base WITH /v1. We always render with /v1 and the backend + // appends it if missing. + const getDisplayUrl = (): string => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getEffectiveBaseUrl = (): string => getDisplayUrl(); + + const hasCustomSelectedApiKey = + selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); + + const getConfigStatus = (): "configured" | "not_configured" | "other" | null => { + if (!status?.installed) return null; + if (!status.hasOpenProxy) return "not_configured"; + // Kilo backend already verifies localhost/127.0.0.1/openproxy in the URL; + // if hasOpenProxy is true we treat it as connected. + return "configured"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key); + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !status) { + void checkStatus(); + } + }, [isExpanded]); + + const checkStatus = async (): Promise => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/kilo-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, error: (error as Error).message }); + } finally { + setChecking(false); + } + }; + + // Kilo expects base WITH /v1 (the backend ensures the trailing /v1). + const handleApply = async (): Promise => { + setApplying(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : null); + + const res = await fetch("/api/cli-tools/kilo-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async (): Promise => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model: { value: string }): void => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = (): Array<{ filename: string; content: string }> => { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : ""); + const effectiveUrl = getEffectiveBaseUrl(); + return [ + { + filename: "~/.local/share/kilo/auth.json", + content: JSON.stringify( + { + "openai-compatible": { + type: "api-key", + apiKey: keyToUse, + baseUrl: effectiveUrl, + model: selectedModel || "provider/model-id", + }, + }, + null, + 2, + ), + }, + ]; + }; + + return ( + +
+
+
+ {tool.name}) => { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && ( + + Connected + + )} + {configStatus === "not_configured" && ( + + Not configured + + )} + {configStatus === "other" && ( + + Other + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Kilo Code CLI... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

+ Kilo Code CLI not detected locally +

+

+ Install Kilo Code from{" "} + + kilocode.ai + {" "} + or use Manual Config below. +

+
+
+
+ +
+
+
+ )} + + {!checking && status?.installed && ( + <> +
+ {status?.settings?.openAiBaseUrl && ( +
+ + Current + + + arrow_forward + + + {status.settings.openAiBaseUrl} + +
+ )} + + + +
+ + Base URL + + + arrow_forward + + ) => setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 || selectedApiKey ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_openproxy (default)"} + + )} +
+ +
+ + Model + + + arrow_forward + +
+ ) => setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + {status?.hasOpenProxy && ( + + )} + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + toolName={tool.name} + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/web/src/components/cli-tools/index.ts b/web/src/components/cli-tools/index.ts index 06b45ccc..b396c868 100644 --- a/web/src/components/cli-tools/index.ts +++ b/web/src/components/cli-tools/index.ts @@ -1,4 +1,6 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; diff --git a/web/src/components/cli-tools/index.tsx b/web/src/components/cli-tools/index.tsx index 06b45ccc..b396c868 100644 --- a/web/src/components/cli-tools/index.tsx +++ b/web/src/components/cli-tools/index.tsx @@ -1,4 +1,6 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; diff --git a/web/src/shared/constants/cliTools.ts b/web/src/shared/constants/cliTools.ts index 4da41582..33403ca1 100644 --- a/web/src/shared/constants/cliTools.ts +++ b/web/src/shared/constants/cliTools.ts @@ -209,7 +209,7 @@ export const CLI_TOOLS: Record = { image: "/providers/cline.png", color: "#00D1B2", description: "Cline AI Coding Assistant", - configType: "guide", + configType: "custom", guideSteps: [ { step: 1, title: "Open Settings", desc: "Go to Cline Settings panel" }, { step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" }, @@ -224,7 +224,7 @@ export const CLI_TOOLS: Record = { image: "/providers/kilocode.png", color: "#FF6B6B", description: "Kilo Code AI Assistant", - configType: "guide", + configType: "custom", guideSteps: [ { step: 1, title: "Open Settings", desc: "Go to Kilo Code Settings panel" }, { step: 2, title: "Select Provider", desc: "Choose API Provider → OpenAI Compatible" }, From b72a5e50e227aec79fbc95e9da9af041f788de80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Quang=20=C4=90=C3=A3ng?= Date: Wed, 13 May 2026 11:20:33 +0000 Subject: [PATCH 3/7] Bulk API key import + ModelSelectModal Done button + 3-min SSE stall timeout Three medium-priority features ported from 9router: 1. Bulk API key import (web/src/components/providers/AddApiKeyModal.tsx) - New Single/Bulk toggle at the top of the modal - Bulk mode accepts one key per line in 'name|apiKey' or just 'apiKey' (auto-numbered) - Posts each line directly to POST /api/providers (bypassing onSave so the loop is not interrupted by the modal closing on each success) - Hidden for Azure/Cloudflare-AI/Ollama-local (they need per-key extra fields) - Parent ConnectionsCard + ProviderDetailPageClient now refresh on onClose so bulk results show up after the user dismisses the modal. 2. ModelSelectModal: optional Done button (web/src/shared/components/ModelSelectModal.tsx) - New 'closeOnSelect' prop (defaults true). When false, picking a model no longer auto-closes the modal and a 'Done' footer button is rendered. - Lets callers use the modal for multi-select / toggling without round-trips. 3. SSE stream stall timeout, 3 min (src/server/api/chat.rs) - Wrap upstream bytes_stream / Hyper body.frame() awaits with tokio::time::timeout(SSE_STALL_TIMEOUT). If the upstream goes silent for 180s the stream is closed with usage_live.finish_request(error=true). - 180s > the longest keep-alive any major provider sends (OpenAI ~30s, Anthropic ~60s, Gemini ~30s) so legitimate quiet periods do not trip it. - Applied to both Reqwest and Hyper UpstreamResponse stream paths. cargo fmt clean, 54 test binaries pass, web build succeeds. Refs: 9router AddApiKeyModal.js bulk mode, ModelSelectModal.js Done footer, CHANGELOG 'Stream stall timeout (3 min) in open-sse'. --- src/server/api/chat.rs | 51 ++++++++- .../components/providers/AddApiKeyModal.tsx | 102 ++++++++++++++++++ .../components/providers/ConnectionsCard.tsx | 7 +- .../providers/ProviderDetailPageClient.tsx | 4 + .../shared/components/ModelSelectModal.tsx | 24 ++++- 5 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/server/api/chat.rs b/src/server/api/chat.rs index f573db9f..d26f46a1 100644 --- a/src/server/api/chat.rs +++ b/src/server/api/chat.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashSet}; +use std::time::Duration; use axum::body::Body; use axum::extract::rejection::JsonRejection; @@ -29,6 +30,12 @@ use crate::types::{AppDb, ProviderConnection, TokenUsage}; use super::auth_error_response; +/// Maximum time we'll wait for the next byte from an upstream SSE stream before +/// considering the connection stalled. 3 minutes matches what most providers +/// use for their keep-alive heartbeats (OpenAI sends a comment every ~30s, +/// Anthropic every ~60s, Gemini every ~30s — 180s is well past any of them). +const SSE_STALL_TIMEOUT: Duration = Duration::from_secs(180); + pub async fn cors_options() -> Response { cors_preflight_response("GET, POST, OPTIONS") } @@ -948,8 +955,24 @@ async fn proxy_response_with_pending_tracking( let stream = async_stream::stream! { let mut upstream = response.bytes_stream(); loop { - match upstream.try_next().await { - Ok(Some(chunk)) => { + let next = tokio::time::timeout(SSE_STALL_TIMEOUT, upstream.try_next()).await; + match next { + Err(_elapsed) => { + // Upstream went silent for SSE_STALL_TIMEOUT; treat + // as an error so the client can retry. + tracing::warn!( + target: "openproxy::chat::stream", + provider = %provider, + model = %model, + "SSE stalled, closing stream" + ); + state + .usage_live + .finish_request(&model, &provider, connection_id.as_deref(), true) + .await; + return; + } + Ok(Ok(Some(chunk))) => { if let Some(transformer) = transformer.as_mut() { for line in transform_dashboard_sse_chunk(&chunk, transformer.as_mut(), &mut pending_text) { if let Some(frame) = sse_frame_for_dashboard(&line) { @@ -960,8 +983,8 @@ async fn proxy_response_with_pending_tracking( yield Ok::(chunk); } } - Ok(None) => break, - Err(_) => { + Ok(Ok(None)) => break, + Ok(Err(_)) => { state .usage_live .finish_request(&model, &provider, connection_id.as_deref(), true) @@ -993,7 +1016,25 @@ async fn proxy_response_with_pending_tracking( let mut transformer = transformer; let mut pending_text = String::new(); let stream = async_stream::stream! { - while let Some(frame_result) = body.frame().await { + loop { + let next = tokio::time::timeout(SSE_STALL_TIMEOUT, body.frame()).await; + let frame_result = match next { + Err(_elapsed) => { + tracing::warn!( + target: "openproxy::chat::stream", + provider = %provider, + model = %model, + "SSE stalled, closing stream" + ); + state + .usage_live + .finish_request(&model, &provider, connection_id.as_deref(), true) + .await; + return; + } + Ok(Some(result)) => result, + Ok(None) => break, + }; match frame_result { Ok(frame) => { if let Ok(data) = frame.into_data() { diff --git a/web/src/components/providers/AddApiKeyModal.tsx b/web/src/components/providers/AddApiKeyModal.tsx index 77dfea42..28924cc5 100644 --- a/web/src/components/providers/AddApiKeyModal.tsx +++ b/web/src/components/providers/AddApiKeyModal.tsx @@ -53,6 +53,47 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); const [saving, setSaving] = useState(false); + // Bulk add: one key per line in `name|apiKey` or just `apiKey` (auto-named). + // Skipped for Azure/Cloudflare/Ollama since they need extra fields per key. + const supportsBulk = !isOllamaLocal && !isAzure && !isCloudflareAi; + const [mode, setMode] = useState<"single" | "bulk">("single"); + const [bulkText, setBulkText] = useState(""); + const [bulkResult, setBulkResult] = useState<{ success: number; failed: number } | null>(null); + + const handleBulkSubmit = async (): Promise => { + if (!provider) return; + const lines = bulkText + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (!lines.length) return; + setSaving(true); + setBulkResult(null); + let success = 0; + let failed = 0; + // POST directly: onSave from the parent closes the modal on success which + // would interrupt the loop. The parent should refresh on onClose. + for (let i = 0; i < lines.length; i++) { + const parts = lines[i].split("|"); + const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim(); + const baseName = parts.length >= 2 ? parts[0].trim() : "Key"; + const name = `${baseName} ${i + 1}`; + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, name, apiKey, priority: 1, testStatus: "unknown" }), + }); + if (res.ok) success++; + else failed++; + } catch { + failed++; + } + } + setSaving(false); + setBulkResult({ success, failed }); + }; + const buildProviderSpecificData = (): any => { if (isOllamaLocal && formData.ollamaHostUrl.trim()) { return { baseUrl: formData.ollamaHostUrl.trim() }; @@ -134,6 +175,65 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa return (
+ {supportsBulk && ( +
+ + +
+ )} + + {supportsBulk && mode === "bulk" && ( +
+

+ One key per line. Format: name|apiKey or just apiKey (auto-named by index). +

+