From 034cce47e07c538a330f0184f902cb2c235f839f Mon Sep 17 00:00:00 2001 From: Oleksandr Liakhevych Date: Thu, 25 Jun 2026 17:31:14 +0300 Subject: [PATCH] fix(ai): Codex CLI provider reliability fixes for DBX Desktop * fix(ai): resolve Codex CLI from shell PATH on macOS * fix(ai): hide Codex CLI subprocess windows on Windows * feat(ai): allow to set environment variable for Codex CLI * fix(ai): parse Codex MCP tool events * fix(mcp): honor Postgres TLS settings in direct queries --- .../src/components/editor/AiAssistant.vue | 12 +- .../editor/EditorSettingsDialog.vue | 99 +++++++++- apps/desktop/src/i18n/locales/en.ts | 8 + apps/desktop/src/i18n/locales/es.ts | 8 + apps/desktop/src/i18n/locales/it.ts | 8 + apps/desktop/src/i18n/locales/ja.ts | 8 + apps/desktop/src/i18n/locales/pt-BR.ts | 8 + apps/desktop/src/i18n/locales/zh-CN.ts | 8 + apps/desktop/src/i18n/locales/zh-TW.ts | 8 + apps/desktop/src/stores/settingsStore.ts | 14 ++ crates/dbx-core/src/ai.rs | 14 ++ crates/dbx-core/src/ai_cli_agent.rs | 58 +++++- crates/dbx-core/src/ai_codex_cli.rs | 187 ++++++++++++++++-- packages/node-core/src/database.ts | 93 ++++++++- packages/node-core/tests/postgres-url.test.ts | 70 +++++++ src-tauri/src/commands/ai.rs | 30 +++ src-tauri/src/commands/mcp.rs | 10 +- 17 files changed, 613 insertions(+), 30 deletions(-) create mode 100644 packages/node-core/tests/postgres-url.test.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index d34fbabf65..60b755c88e 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -403,10 +403,20 @@ function agentStepClass(tone: AiAgentStepTone): string { function extractToolResultContent(result: unknown): string | undefined { if (!result) return undefined; if (typeof result === "string") return result; + if (Array.isArray(result)) return result.map(extractToolResultContent).filter(Boolean).join("\n"); if (typeof result === "object" && result !== null && "content" in result) { const content = (result as Record).content; + if (Array.isArray(content)) return content.map(extractToolResultContent).filter(Boolean).join("\n"); return typeof content === "string" ? content : JSON.stringify(content); } + if (typeof result === "object" && result !== null && "text" in result) { + const text = (result as Record).text; + if (typeof text === "string") return text; + } + if (typeof result === "object" && result !== null && "message" in result) { + const message = (result as Record).message; + if (typeof message === "string") return message; + } return JSON.stringify(result); } @@ -454,7 +464,7 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u titleParams: { tool: event.tool_name || "" }, toolName: event.tool_name, toolArgs: event.type === "tool_call_start" ? (event.args as Record) : undefined, - toolResult: event.type === "tool_call_end" && !event.is_error ? extractToolResultContent(event.result) : undefined, + toolResult: event.type === "tool_call_end" ? extractToolResultContent(event.result) : undefined, explainData: event.type === "tool_call_end" ? extractExplainData(event.result) : undefined, isError: event.type === "tool_call_end" ? event.is_error : undefined, }; diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index c9275c975b..67b526016c 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -3,7 +3,7 @@ import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } fr import type { Ref } from "vue"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { useI18n } from "vue-i18n"; -import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, RefreshCw, RotateCcw, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue"; +import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, Plus, RefreshCw, RotateCcw, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -25,6 +25,7 @@ import { DEFAULT_EDITOR_SETTINGS, DEFAULT_DESKTOP_SETTINGS, DEFAULT_SIDEBAR_TABLE_PAGE_SIZE, + normalizeAiEnv, type AiProvider, type AiApiStyle, type AiAuthMethod, @@ -112,6 +113,12 @@ interface TableColumnTemplateGridRow { overrides: TableColumnTemplateOverrideRow[]; } +interface AiEnvRow { + id: string; + key: string; + value: string; +} + function tableColumnTemplateRowsFromSettings(lines: readonly string[]): TableColumnTemplateGridRow[] { return parseTableColumnTemplateFields([...lines]).map((field) => ({ id: uuid(), @@ -1477,6 +1484,7 @@ const aiEditEnableThinking = ref(settingsStore.aiConfig.enableThinking ?? true); const aiEditReasoningLevel = ref(settingsStore.aiConfig.reasoningLevel || "default"); const aiEditContextWindow = ref(settingsStore.aiConfig.contextWindow); const aiEditCodexCliPath = ref(settingsStore.aiConfig.codexCliPath || ""); +const aiEditCodexCliEnvRows = ref(aiEnvRowsFromConfig(settingsStore.aiConfig.codexCliEnv)); const aiModelOptions = ref([]); const aiModelLoading = ref(false); @@ -1541,6 +1549,52 @@ const aiModelEmptyText = computed(() => { if (!aiModelListSupported.value) return t("ai.modelListUnsupported"); return t("ai.noModels"); }); +const aiCodexEnvError = computed(() => codexEnvValidationError()); +const aiCodexPathError = computed(() => { + const path = aiEditCodexCliPath.value.trim(); + const firstToken = path.split(/\s+/)[0] || ""; + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.codexCliPathEnvError") : ""; +}); +const aiCodexValidationError = computed(() => (aiIsCodexCli.value ? aiCodexPathError.value || aiCodexEnvError.value : "")); + +function aiEnvRowsFromConfig(env: unknown): AiEnvRow[] { + return Object.entries(normalizeAiEnv(env)).map(([key, value]) => ({ id: uuid(), key, value })); +} + +function codexEnvFromRows(): Record { + const result: Record = {}; + for (const row of aiEditCodexCliEnvRows.value) { + const key = row.key.trim(); + if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.toUpperCase().startsWith("DBX_MCP_")) continue; + result[key] = row.value; + } + return result; +} + +function codexEnvSignature(): string { + return JSON.stringify(Object.entries(codexEnvFromRows()).sort(([left], [right]) => left.localeCompare(right))); +} + +function savedCodexEnvSignature(): string { + return JSON.stringify(Object.entries(normalizeAiEnv(settingsStore.aiConfig.codexCliEnv)).sort(([left], [right]) => left.localeCompare(right))); +} + +function codexEnvValidationError(): string { + for (const row of aiEditCodexCliEnvRows.value) { + const key = row.key.trim(); + if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.codexCliEnvInvalidName", { name: key }); + if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.codexCliEnvReservedName", { name: key }); + } + return ""; +} + +function addCodexEnvRow() { + aiEditCodexCliEnvRows.value.push({ id: uuid(), key: "", value: "" }); +} + +function removeCodexEnvRow(id: string) { + aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); +} function clearAiModelOptions() { aiModelRequestToken += 1; @@ -1559,6 +1613,7 @@ function aiModelConfigSignature() { proxyEnabled: aiEditProxyEnabled.value, proxyUrl: aiEditProxyUrl.value.trim(), codexCliPath: aiEditCodexCliPath.value.trim(), + codexCliEnv: codexEnvSignature(), }); } @@ -1576,6 +1631,7 @@ function currentAiEditConfig() { reasoningLevel: aiEditReasoningLevel.value, contextWindow: aiEditContextWindow.value || undefined, codexCliPath: aiEditCodexCliPath.value.trim() || undefined, + codexCliEnv: aiIsCodexCli.value ? codexEnvFromRows() : {}, }; } @@ -1661,6 +1717,7 @@ function syncAiEditState() { aiEditReasoningLevel.value = settingsStore.aiConfig.reasoningLevel || "default"; aiEditContextWindow.value = settingsStore.aiConfig.contextWindow; aiEditCodexCliPath.value = settingsStore.aiConfig.codexCliPath || ""; + aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(settingsStore.aiConfig.codexCliEnv); aiTestResult.value = ""; aiTestError.value = ""; aiTestLatency.value = null; @@ -1678,6 +1735,7 @@ function aiSelectProvider(provider: AiProvider) { aiEditReasoningLevel.value = "default"; if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = ""; aiEditCodexCliPath.value = ""; + aiEditCodexCliEnvRows.value = []; aiTestResult.value = ""; aiTestError.value = ""; aiTestLatency.value = null; @@ -1699,16 +1757,27 @@ function aiHasChanges(): boolean { aiEditEnableThinking.value !== (settingsStore.aiConfig.enableThinking ?? true) || aiEditReasoningLevel.value !== (settingsStore.aiConfig.reasoningLevel || "default") || aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow || - aiEditCodexCliPath.value !== (settingsStore.aiConfig.codexCliPath || "") + aiEditCodexCliPath.value !== (settingsStore.aiConfig.codexCliPath || "") || + codexEnvSignature() !== savedCodexEnvSignature() ); } function aiApplySettings() { + if (aiCodexValidationError.value) { + aiTestResult.value = "error"; + aiTestError.value = aiCodexValidationError.value; + return; + } settingsStore.updateAiConfig(currentAiEditConfig()); } async function aiTestConn() { if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) || (!aiIsCodexCli.value && !aiEditModel.value.trim())) return; + if (aiCodexValidationError.value) { + aiTestResult.value = "error"; + aiTestError.value = aiCodexValidationError.value; + return; + } aiTesting.value = true; aiTestResult.value = ""; aiTestError.value = ""; @@ -2975,6 +3044,28 @@ watch(

{{ t("ai.codexCliPathHint") }}

+

{{ aiCodexPathError }}

+
+ + +
+ +
+
+
+ + + +
+
+ +

{{ aiCodexEnvError }}

+

{{ t("ai.codexCliEnvHint") }}

@@ -3344,7 +3435,7 @@ watch(
- @@ -3369,7 +3460,7 @@ watch(
- +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 901528c1e9..926c8fb5df 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1110,6 +1110,14 @@ export default { contextWindowHint: "Tokens. Leave empty to auto-detect. Set manually for local/custom models.", codexCliPath: "Codex CLI Path", codexCliPathHint: "Leave empty to use codex from PATH. Sign in separately with codex login.", + codexCliPathEnvError: "Codex CLI Path should contain only the executable path. Add environment variables below.", + codexCliEnv: "Environment variables", + codexCliEnvAdd: "Add variable", + codexCliEnvHint: "Optional variables passed to Codex CLI, such as HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE, or CODEX_CA_CERTIFICATE.", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "Invalid environment variable name: {name}", + codexCliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", codexMcpRequiredTitle: "DBX MCP Server required", codexMcpRequiredDescription: "Codex CLI uses the DBX MCP Server to access database schema and query tools. Install it before using Codex in the AI assistant.", reasoningLevel: "Reasoning level", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 940f6001c3..3b03b43fd4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -904,6 +904,14 @@ export default { modelListApiKeyRequired: "Ingresa una clave API antes de cargar modelos.", codexMcpRequiredTitle: "Se requiere DBX MCP Server", codexMcpRequiredDescription: "Codex CLI usa DBX MCP Server para acceder al esquema de la base de datos y a las herramientas de consulta. Instálalo antes de usar Codex en el asistente de IA.", + codexCliPathEnvError: "Codex CLI Path debe contener solo la ruta del ejecutable. Agrega las variables de entorno abajo.", + codexCliEnv: "Variables de entorno", + codexCliEnvAdd: "Agregar variable", + codexCliEnvHint: "Variables opcionales para Codex CLI, como HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE o CODEX_CA_CERTIFICATE.", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "Nombre de variable de entorno no válido: {name}", + codexCliEnvReservedName: "{name} lo administra DBX para MCP Server y no se puede configurar aquí.", reasoningLevel: "Nivel de razonamiento", reasoningLevelDefault: "Predeterminado", reasoningLevelMinimal: "Mínimo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 18d78a73d4..479a812cb4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -965,6 +965,14 @@ export default { modelListApiKeyRequired: "Inserisci una chiave API prima di caricare i modelli.", codexMcpRequiredTitle: "DBX MCP Server richiesto", codexMcpRequiredDescription: "Codex CLI usa il DBX MCP Server per accedere allo schema del database e agli strumenti di query. Installalo prima di usare Codex nell'assistente AI.", + codexCliPathEnvError: "Codex CLI Path deve contenere solo il percorso dell'eseguibile. Aggiungi le variabili d'ambiente sotto.", + codexCliEnv: "Variabili d'ambiente", + codexCliEnvAdd: "Aggiungi variabile", + codexCliEnvHint: "Variabili opzionali passate a Codex CLI, come HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE o CODEX_CA_CERTIFICATE.", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "Nome variabile d'ambiente non valido: {name}", + codexCliEnvReservedName: "{name} è gestita da DBX per MCP Server e non può essere impostata qui.", reasoningLevel: "Livello di ragionamento", reasoningLevelDefault: "Predefinito", reasoningLevelMinimal: "Minimo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f4f4d378f1..18e2d76aa2 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1017,6 +1017,14 @@ export default { modelListApiKeyRequired: "モデルを読み込む前にAPIキーを入力してください。", codexMcpRequiredTitle: "DBX MCP Serverが必要です", codexMcpRequiredDescription: "Codex CLIはDBX MCP Server経由でデータベーススキーマとクエリツールにアクセスします。AIアシスタントでCodexを使う前にインストールしてください。", + codexCliPathEnvError: "Codex CLI Pathには実行ファイルのパスのみを入力してください。環境変数は下に追加してください。", + codexCliEnv: "環境変数", + codexCliEnvAdd: "変数を追加", + codexCliEnvHint: "HTTPS_PROXY、NO_PROXY、SSL_CERT_FILE、CODEX_CA_CERTIFICATEなど、Codex CLIに渡す任意の変数。", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "無効な環境変数名: {name}", + codexCliEnvReservedName: "{name}はMCP Server用にDBXが管理しているため、ここでは設定できません。", reasoningLevel: "推論レベル", reasoningLevelDefault: "デフォルト", reasoningLevelMinimal: "最小", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index fa1a4ddbd5..46e9201627 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -965,6 +965,14 @@ export default { modelListApiKeyRequired: "Informe uma API key antes de carregar os modelos.", codexMcpRequiredTitle: "DBX MCP Server obrigatório", codexMcpRequiredDescription: "O Codex CLI usa o DBX MCP Server para acessar o schema do banco e as ferramentas de consulta. Instale-o antes de usar o Codex no assistente de AI.", + codexCliPathEnvError: "Codex CLI Path deve conter apenas o caminho do executável. Adicione variáveis de ambiente abaixo.", + codexCliEnv: "Variáveis de ambiente", + codexCliEnvAdd: "Adicionar variável", + codexCliEnvHint: "Variáveis opcionais passadas ao Codex CLI, como HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE ou CODEX_CA_CERTIFICATE.", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "Nome de variável de ambiente inválido: {name}", + codexCliEnvReservedName: "{name} é gerenciada pelo DBX para o MCP Server e não pode ser definida aqui.", reasoningLevel: "Nível de raciocínio", reasoningLevelDefault: "Padrão", reasoningLevelMinimal: "Mínimo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0b9a0a33e5..7b662cac26 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1109,6 +1109,14 @@ export default { contextWindowHint: "单位:token。留空自动推断,本地/自定义模型建议手动设置。", codexCliPath: "Codex CLI 路径", codexCliPathHint: "留空则使用 PATH 中的 codex。请先在终端执行 codex login 登录。", + codexCliPathEnvError: "Codex CLI 路径只能填写可执行文件路径。请在下方添加环境变量。", + codexCliEnv: "环境变量", + codexCliEnvAdd: "添加变量", + codexCliEnvHint: "传递给 Codex CLI 的可选变量,例如 HTTPS_PROXY、NO_PROXY、SSL_CERT_FILE 或 CODEX_CA_CERTIFICATE。", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "环境变量名称无效:{name}", + codexCliEnvReservedName: "{name} 由 DBX 用于 MCP Server,不能在这里设置。", codexMcpRequiredTitle: "需要 DBX MCP Server", codexMcpRequiredDescription: "Codex CLI 通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用 Codex。", reasoningLevel: "推理级别", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4e1c1013e4..cd83620852 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1021,6 +1021,14 @@ export default { enableThinkingHint: "此選項僅對 /chat/completions API 且部分支援的模型生效。設為停用後可大幅節省 token,但產生結果品質可能會略微下降。", codexMcpRequiredTitle: "需要 DBX MCP Server", codexMcpRequiredDescription: "Codex CLI 會透過 DBX MCP Server 存取資料庫結構與查詢工具。請先安裝後再於 AI 助理中使用 Codex。", + codexCliPathEnvError: "Codex CLI 路徑只能填寫可執行檔路徑。請在下方新增環境變數。", + codexCliEnv: "環境變數", + codexCliEnvAdd: "新增變數", + codexCliEnvHint: "傳遞給 Codex CLI 的選用變數,例如 HTTPS_PROXY、NO_PROXY、SSL_CERT_FILE 或 CODEX_CA_CERTIFICATE。", + codexCliEnvKeyPlaceholder: "HTTPS_PROXY", + codexCliEnvValuePlaceholder: "http://proxy:9800", + codexCliEnvInvalidName: "環境變數名稱無效:{name}", + codexCliEnvReservedName: "{name} 由 DBX 用於 MCP Server,不能在這裡設定。", reasoningLevel: "推理層級", reasoningLevelDefault: "預設", reasoningLevelMinimal: "最小", diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 78f02f98a0..cb581e1b79 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -30,6 +30,7 @@ export interface AiConfig { reasoningLevel?: AiReasoningLevel; contextWindow?: number; codexCliPath?: string | null; + codexCliEnv?: Record; } export interface AiTestConnectionResult { @@ -195,11 +196,23 @@ const defaultConfigs: Record> = Object.from ) as Record>; const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high"]; +const AI_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; function normalizeAiReasoningLevel(value: unknown): AiReasoningLevel { return typeof value === "string" && AI_REASONING_LEVELS.includes(value as AiReasoningLevel) ? (value as AiReasoningLevel) : "default"; } +export function normalizeAiEnv(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [rawKey, rawValue] of Object.entries(value as Record)) { + const key = rawKey.trim(); + if (!key || !AI_ENV_KEY_RE.test(key)) continue; + result[key] = rawValue == null ? "" : String(rawValue); + } + return result; +} + export function normalizeAiConfig(config: Partial | null | undefined): AiConfig { const provider = config?.provider && config.provider in AI_PROVIDER_PRESETS ? config.provider : inferAiProviderFromConfig(config); return { @@ -215,6 +228,7 @@ export function normalizeAiConfig(config: Partial | null | undefined): reasoningLevel: normalizeAiReasoningLevel(config?.reasoningLevel), contextWindow: config?.contextWindow ?? undefined, codexCliPath: config?.codexCliPath?.trim() || undefined, + codexCliEnv: normalizeAiEnv(config?.codexCliEnv), }; } diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 79fc1de045..80a0538419 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -120,6 +120,8 @@ pub struct AiConfig { pub context_window: Option, #[serde(default)] pub codex_cli_path: Option, + #[serde(default)] + pub codex_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -2118,6 +2120,7 @@ mod tests { assert_eq!(config.proxy_url, ""); assert!(config.enable_thinking); assert_eq!(config.auth_method, AiAuthMethod::ApiKey); + assert!(config.codex_cli_env.is_empty()); } #[test] @@ -2135,6 +2138,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -2157,6 +2161,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -2177,6 +2182,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -2197,6 +2203,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert_eq!( @@ -2217,6 +2224,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -2238,6 +2246,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -2254,6 +2263,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -2274,6 +2284,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions"); assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models"); @@ -2329,6 +2340,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -2400,6 +2412,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; assert!(!supports_temperature(&config)); @@ -2466,6 +2479,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index 1c2caa8765..f3010eb5d4 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -2,11 +2,15 @@ use crate::agent_events::AgentEvent; use crate::ai::{AiMessage, AiModelInfo}; use crate::token_usage::TokenUsage; use serde_json::Value; +use std::ffi::OsStr; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; use tokio::sync::Notify; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + #[derive(Debug, Clone)] pub struct CliAgentRunOptions { pub connection_id: String, @@ -96,12 +100,28 @@ pub fn model_infos(ids: &[&str]) -> Vec { ids.iter().map(|id| AiModelInfo { id: (*id).to_string(), display_name: None }).collect() } +pub fn cli_command(program: impl AsRef) -> Command { + let command = Command::new(program); + configure_cli_command(command) +} + +#[cfg(windows)] +fn configure_cli_command(mut command: Command) -> Command { + command.creation_flags(CREATE_NO_WINDOW); + command +} + +#[cfg(not(windows))] +fn configure_cli_command(command: Command) -> Command { + command +} + pub async fn list_json_models_or_default( program: String, args: impl IntoIterator, default_models: &[&str], ) -> Result, String> { - let output = Command::new(program).args(args).output().await; + let output = cli_command(program).args(args).output().await; let Ok(output) = output else { return Ok(model_infos(default_models)); @@ -285,6 +305,7 @@ fn cli_item_id(item: &Value) -> String { fn cli_tool_name(item: &Value) -> String { item.get("tool_name") .and_then(Value::as_str) + .or_else(|| item.get("tool").and_then(Value::as_str)) .or_else(|| item.get("name").and_then(Value::as_str)) .or_else(|| item.get("server_tool_name").and_then(Value::as_str)) .unwrap_or("cli_tool") @@ -300,7 +321,16 @@ fn cli_tool_args(item: &Value) -> Value { } fn cli_tool_result(item: &Value) -> Value { - item.get("result").or_else(|| item.get("output")).or_else(|| item.get("content")).cloned().unwrap_or(Value::Null) + non_null_field(item, "result") + .or_else(|| non_null_field(item, "output")) + .or_else(|| non_null_field(item, "content")) + .or_else(|| non_null_field(item, "error")) + .cloned() + .unwrap_or(Value::Null) +} + +fn non_null_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.get(key).filter(|field| !field.is_null()) } fn cli_text(item: &Value) -> Option { @@ -326,7 +356,8 @@ pub async fn run_cli_jsonl_agent( cancelled: &Notify, on_event: impl Fn(AgentEvent) + Send + Sync + 'static, ) -> Result { - let mut child = Command::new(&spec.command.program) + let mut command = cli_command(&spec.command.program); + let mut child = command .args(&spec.command.args) .envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) .stdout(Stdio::piped()) @@ -427,6 +458,27 @@ mod tests { .unwrap_or(false) } + #[tokio::test] + async fn jsonl_agent_applies_env_to_child() { + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + "printf '%s\n' \"{\\\"type\\\":\\\"item.completed\\\",\\\"item\\\":{\\\"type\\\":\\\"agent_message\\\",\\\"text\\\":\\\"$DBX_TEST_ENV\\\"}}\" \"{\\\"type\\\":\\\"turn.completed\\\"}\"".to_string(), + ], + }, + env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())], + dialect: CliAgentJsonlDialect::CodexExec, + classify_spawn_error, + classify_run_error, + }; + + let result = run_cli_jsonl_agent(spec, &Notify::new(), |_| {}).await.unwrap(); + + assert_eq!(result, "from-env"); + } + #[tokio::test] async fn jsonl_error_kills_and_waits_for_child() { let pid_file = std::env::temp_dir().join(format!( diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index dec694b8c3..16dec8fd76 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -1,12 +1,12 @@ use crate::agent_events::AgentEvent; use crate::ai::{AiConfig, AiModelInfo, AiTestConnectionResult}; use crate::ai_cli_agent::{ - append_config_overrides, build_cli_agent_prompt, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos, - parse_cli_jsonl_event, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec, + append_config_overrides, build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, + model_infos, parse_cli_jsonl_event, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions, }; use serde_json::Value; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -36,8 +36,13 @@ async fn resolve_codex_command(config: &AiConfig) -> CodexCommandSpec { } } -fn command_env(command: &CodexCommandSpec) -> Vec<(String, String)> { - command.parent_dir().map(|dir| ("PATH".to_string(), merged_path_with_dir(&dir))).into_iter().collect() +fn codex_process_env(config: &AiConfig, command: &CodexCommandSpec) -> Result, String> { + let mut env = BTreeMap::from_iter(codex_cli_env(config)?); + if let Some(dir) = command.parent_dir() { + let user_path = env.get("PATH").map(String::as_str); + env.insert("PATH".to_string(), merged_path_with_dir(&dir, user_path)); + } + Ok(env.into_iter().collect()) } trait CommandParentDir { @@ -167,9 +172,12 @@ fn common_executable_dirs() -> Vec { dirs } -fn merged_path_with_dir(dir: &str) -> String { +fn merged_path_with_dir(dir: &str, user_path: Option<&str>) -> String { let mut seen = BTreeSet::new(); let mut dirs = vec![PathBuf::from(dir)]; + if let Some(path) = user_path { + dirs.extend(env::split_paths(path)); + } dirs.extend(common_executable_dirs()); let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::>(); env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string() @@ -227,6 +235,58 @@ fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +fn validate_codex_program(config: &AiConfig) -> Result { + let program = codex_program(config); + if starts_with_env_assignment(&program) { + return Err("[codexCliPathInvalid] Codex CLI path should contain only the executable path. Add environment variables in the Codex CLI environment variables section.".to_string()); + } + Ok(program) +} + +pub fn codex_cli_env(config: &AiConfig) -> Result, String> { + let mut env = BTreeMap::new(); + for (key, value) in &config.codex_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[codexEnvInvalid] Invalid Codex CLI environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + if is_reserved_dbx_mcp_env_name(key) { + return Err(format!( + "[codexEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here." + )); + } + env.insert(key.to_string(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +fn starts_with_env_assignment(program: &str) -> bool { + let Some(first_token) = program.split_whitespace().next() else { + return false; + }; + let Some((key, _)) = first_token.split_once('=') else { + return false; + }; + is_env_var_name(key) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_reserved_dbx_mcp_env_name(name: &str) -> bool { + name.to_ascii_uppercase().starts_with("DBX_MCP_") +} + pub fn codex_enabled_tools(agent_mode: bool) -> Vec<&'static str> { dbx_mcp_enabled_tools(agent_mode) } @@ -239,7 +299,7 @@ fn codex_mcp_config_overrides(options: &CodexRunOptions) -> Vec { "mcp_servers.dbx.required=true".to_string(), "mcp_servers.dbx.startup_timeout_sec=20".to_string(), "mcp_servers.dbx.tool_timeout_sec=120".to_string(), - "mcp_servers.dbx.default_tools_approval_mode=\"auto\"".to_string(), + "mcp_servers.dbx.default_tools_approval_mode=\"approve\"".to_string(), format!("mcp_servers.dbx.enabled_tools={}", toml_string_array(&dbx_mcp_enabled_tools(options.agent_mode))), ]; if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) { @@ -284,10 +344,11 @@ pub fn build_codex_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage] } pub async fn list_codex_models(config: &AiConfig) -> Result, String> { + validate_codex_program(config)?; let command = resolve_codex_command(config).await; - let output = Command::new(&command.program) + let output = cli_command(&command.program) .args(["debug", "models"]) - .envs(command_env(&command).iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .envs(codex_process_env(config, &command)?.iter().map(|(key, value)| (key.as_str(), value.as_str()))) .output() .await; @@ -335,10 +396,11 @@ fn parse_codex_models(stdout: &str) -> Option> { pub async fn test_codex_connection(config: &AiConfig) -> Result { let start = Instant::now(); + validate_codex_program(config)?; let codex_command = resolve_codex_command(config).await; - let mut command = Command::new(&codex_command.program); + let mut command = cli_command(&codex_command.program); command.args(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]); - command.envs(command_env(&codex_command).iter().map(|(key, value)| (key.as_str(), value.as_str()))); + command.envs(codex_process_env(config, &codex_command)?.iter().map(|(key, value)| (key.as_str(), value.as_str()))); let model = config.model.trim(); if !model.is_empty() && !model.eq_ignore_ascii_case("default") { @@ -405,10 +467,11 @@ pub async fn run_codex_agent( cancelled: &Notify, on_event: impl Fn(AgentEvent) + Send + Sync + 'static, ) -> Result { + validate_codex_program(config)?; let mut command = build_codex_exec_command(config, prompt, &options); let resolved_command = resolve_codex_command(config).await; command.program = resolved_command.program; - let env = command_env(&command); + let env = codex_process_env(config, &command)?; run_cli_jsonl_agent( CliAgentProcessSpec { command, @@ -428,8 +491,9 @@ mod tests { #[cfg(not(windows))] use super::shell_quote; use super::{ - build_codex_exec_command, codex_enabled_tools, command_env, common_executable_dirs, is_path_like_program, - merged_path_with_dir, parse_codex_jsonl_event, parse_codex_models, CodexRunOptions, DEFAULT_CODEX_MODELS, + build_codex_exec_command, codex_cli_env, codex_enabled_tools, codex_process_env, common_executable_dirs, + is_path_like_program, merged_path_with_dir, parse_codex_jsonl_event, parse_codex_models, + validate_codex_program, CodexRunOptions, DEFAULT_CODEX_MODELS, }; use crate::agent_events::AgentEvent; use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel}; @@ -449,6 +513,7 @@ mod tests { reasoning_level: AiReasoningLevel::Default, context_window: None, codex_cli_path: None, + codex_cli_env: Default::default(), } } @@ -471,6 +536,7 @@ mod tests { assert!(!spec.args.contains(&"--model".to_string())); assert!(!spec.args.contains(&"--ask-for-approval".to_string())); assert!(spec.args.contains(&"mcp_servers.dbx.command=\"dbx-mcp-server\"".to_string())); + assert!(spec.args.contains(&"mcp_servers.dbx.default_tools_approval_mode=\"approve\"".to_string())); assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_ALLOW_WRITES=\"0\"".to_string())); assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_SCOPE_CONNECTION_ID=\"conn-1\"".to_string())); assert!(spec.args.iter().any(|arg| arg.contains("dbx_execute_query"))); @@ -533,8 +599,9 @@ mod tests { #[test] #[cfg(not(windows))] fn codex_command_env_prepends_resolved_program_dir_and_keeps_node_dirs() { + let config = codex_config("default"); let command = CliAgentCommandSpec { program: "/opt/homebrew/bin/codex".to_string(), args: Vec::new() }; - let env = command_env(&command); + let env = codex_process_env(&config, &command).unwrap(); let path = env.iter().find(|(key, _)| key == "PATH").map(|(_, value)| value).unwrap(); let dirs = std::env::split_paths(path).collect::>(); @@ -545,13 +612,28 @@ mod tests { #[test] #[cfg(not(windows))] fn merged_path_deduplicates_codex_dir() { - let path = merged_path_with_dir("/opt/homebrew/bin"); + let path = merged_path_with_dir("/opt/homebrew/bin", None); let dirs = std::env::split_paths(&path).collect::>(); let count = dirs.iter().filter(|dir| *dir == std::path::Path::new("/opt/homebrew/bin")).count(); assert_eq!(count, 1); } + #[test] + #[cfg(not(windows))] + fn codex_process_env_keeps_resolved_program_dir_before_user_path() { + let mut config = codex_config("default"); + config.codex_cli_env.insert("PATH".to_string(), "/custom/bin:/usr/bin".to_string()); + let command = CliAgentCommandSpec { program: "/opt/homebrew/bin/codex".to_string(), args: Vec::new() }; + + let env = codex_process_env(&config, &command).unwrap(); + let path = env.iter().find(|(key, _)| key == "PATH").map(|(_, value)| value).unwrap(); + let dirs = std::env::split_paths(path).collect::>(); + + assert_eq!(dirs.first().unwrap(), std::path::Path::new("/opt/homebrew/bin")); + assert!(dirs.iter().any(|dir| dir == std::path::Path::new("/custom/bin"))); + } + #[test] #[cfg(not(windows))] fn shell_quote_handles_codex_program_names() { @@ -564,6 +646,55 @@ mod tests { assert_eq!(model_infos(DEFAULT_CODEX_MODELS), model_infos(&["default", "gpt-5.5", "gpt-5.4-mini"])); } + #[test] + fn normalizes_codex_cli_env() { + let mut config = codex_config("default"); + config.codex_cli_env.insert(" HTTPS_PROXY ".to_string(), "http://proxy:9800".to_string()); + config.codex_cli_env.insert("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()); + config.codex_cli_env.insert("".to_string(), "ignored".to_string()); + + let env = codex_cli_env(&config).unwrap(); + + assert_eq!( + env, + vec![ + ("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string()), + ("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()) + ] + ); + } + + #[test] + fn rejects_invalid_codex_cli_env_name() { + let mut config = codex_config("default"); + config.codex_cli_env.insert("BAD-NAME".to_string(), "value".to_string()); + + let err = codex_cli_env(&config).unwrap_err(); + + assert!(err.contains("[codexEnvInvalid]")); + } + + #[test] + fn rejects_reserved_dbx_mcp_env_name() { + let mut config = codex_config("default"); + config.codex_cli_env.insert("DBX_MCP_ALLOW_DANGEROUS_SQL".to_string(), "1".to_string()); + + let err = codex_cli_env(&config).unwrap_err(); + + assert!(err.contains("[codexEnvReserved]")); + } + + #[test] + fn rejects_shell_style_env_prefix_in_codex_cli_path() { + let mut config = codex_config("default"); + config.codex_cli_path = Some("HTTPS_PROXY=http://proxy:9800 /opt/homebrew/bin/codex".to_string()); + + let err = validate_codex_program(&config).unwrap_err(); + + assert!(err.contains("[codexCliPathInvalid]")); + assert!(err.contains("environment variables section")); + } + #[test] fn parses_codex_model_catalog_without_service_tiers() { let models = parse_codex_models( @@ -603,6 +734,30 @@ mod tests { matches!(&tool_done[0], AgentEvent::ToolCallEnd { tool_name, is_error, .. } if tool_name == "dbx_list_tables" && !is_error) ); + let current_started = parse_codex_jsonl_event( + r#"{"type":"item.started","item":{"id":"item_0","type":"mcp_tool_call","server":"dbx","tool":"dbx_execute_query","arguments":{"sql":"SELECT 1"},"result":null,"error":null,"status":"in_progress"}}"#, + ) + .unwrap(); + assert!( + matches!(¤t_started[0], AgentEvent::ToolCallStart { tool_name, args, .. } if tool_name == "dbx_execute_query" && args["sql"] == "SELECT 1") + ); + + let current_tool_done = parse_codex_jsonl_event( + r#"{"type":"item.completed","item":{"id":"item_0","type":"mcp_tool_call","server":"dbx","tool":"dbx_execute_query","arguments":{"sql":"SELECT 1"},"result":{"content":[{"type":"text","text":"ok"}],"structured_content":null},"error":null,"status":"completed"}}"#, + ) + .unwrap(); + assert!( + matches!(¤t_tool_done[0], AgentEvent::ToolCallEnd { tool_name, result, is_error, .. } if tool_name == "dbx_execute_query" && result["content"][0]["text"] == "ok" && !is_error) + ); + + let current_tool_error = parse_codex_jsonl_event( + r#"{"type":"item.completed","item":{"id":"item_0","type":"mcp_tool_call","server":"dbx","tool":"dbx_execute_query","arguments":{"sql":"SELECT 1"},"result":null,"error":{"message":"user cancelled MCP tool call"},"status":"failed"}}"#, + ) + .unwrap(); + assert!( + matches!(¤t_tool_error[0], AgentEvent::ToolCallEnd { tool_name, result, is_error, .. } if tool_name == "dbx_execute_query" && result["message"] == "user cancelled MCP tool call" && *is_error) + ); + let turn_done = parse_codex_jsonl_event(r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3}}"#) .unwrap(); diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index 6bccf6840c..88e4d77fb3 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -192,16 +192,99 @@ async function connectionEndpoint(config: ConnectionConfig): Promise<{ host: str return { host: "127.0.0.1", port }; } -function buildConnectionUrl(config: ConnectionConfig, endpoint: { host: string; port: number }): string { +export function buildConnectionUrl(config: ConnectionConfig, endpoint: { host: string; port: number }): string { const db = config.database || ""; - const params = config.url_params || ""; - const suffix = params ? `?${params}` : ""; if (isMysqlType(config.db_type)) { + const params = config.url_params || ""; + const suffix = params ? `?${params}` : ""; return `mysql://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`; } + if (!isPostgresType(config.db_type)) { + throw new Error(`Unsupported pooled connection type: ${config.db_type}`); + } + const params = normalizePostgresUrlParams(config.url_params || "", config.ssl); + const suffix = params ? `?${params}` : ""; return `postgres://${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@${endpoint.host}:${endpoint.port}/${db}${suffix}`; } +function normalizePostgresUrlParams(value: string, forceTls: boolean): string { + const parts: string[] = []; + let timezone: string | undefined; + let searchPath: string | undefined; + for (const part of value.trim().replace(/^\?/, "").split("&")) { + if (!part) continue; + const [rawKey, rawValue] = splitUrlParam(part); + const key = decodeUrlParamPart(rawKey); + const lowerKey = key.toLowerCase(); + if (lowerKey === "timezone" || lowerKey === "time_zone") { + const decoded = decodeUrlParamPart(rawValue).trim(); + if (decoded) timezone = decoded; + continue; + } + if (lowerKey === "schema" || lowerKey === "currentschema") { + const decoded = decodeUrlParamPart(rawValue).trim(); + if (decoded) searchPath = decoded; + continue; + } + if (lowerKey === "ssl-mode") { + const value = decodeUrlParamPart(rawValue).toLowerCase().replaceAll("_", "-"); + if (value === "require" || value === "required") parts.push("sslmode=require"); + else if (value === "prefer" || value === "preferred") parts.push("sslmode=prefer"); + else if (value === "disable" || value === "disabled") parts.push("sslmode=disable"); + else if (value === "verify-ca") parts.push("sslmode=verify-ca"); + else if (value === "verify-full" || value === "verify-identity") parts.push("sslmode=verify-full"); + continue; + } + if (lowerKey === "charset" || lowerKey === "require_ssl" || lowerKey === "verify_ca" || lowerKey === "verify_identity") { + continue; + } + parts.push(part); + } + + const connectionOptions: Array<{ needle: string; value: string }> = []; + if (searchPath) connectionOptions.push({ needle: "search_path=", value: `-c search_path=${searchPath}` }); + if (timezone) connectionOptions.push({ needle: "timezone=", value: `-c TimeZone=${timezone}` }); + if (connectionOptions.length > 0) { + const optionsIndex = parts.findIndex((part) => urlParamKeyIs(part, "options")); + if (optionsIndex >= 0) { + const [rawKey, rawValue] = splitUrlParam(parts[optionsIndex]); + const optionsValue = decodeUrlParamPart(rawValue); + const lowerOptions = optionsValue.toLowerCase(); + const appended = connectionOptions.filter((option) => !lowerOptions.includes(option.needle)).map((option) => option.value).join(" "); + if (appended) { + const combined = `${optionsValue.trim()} ${appended}`.trim(); + parts[optionsIndex] = `${rawKey}=${encodeURIComponent(combined)}`; + } + } else { + parts.push(`options=${encodeURIComponent(connectionOptions.map((option) => option.value).join(" "))}`); + } + } + + if (forceTls && !parts.some((part) => urlParamKeyIs(part, "sslmode"))) { + parts.unshift("sslmode=require"); + } + return parts.join("&"); +} + +function urlParamKeyIs(part: string, expected: string): boolean { + const [rawKey] = splitUrlParam(part); + return decodeUrlParamPart(rawKey).toLowerCase() === expected.toLowerCase(); +} + +function splitUrlParam(part: string): [string, string] { + const index = part.indexOf("="); + if (index < 0) return [part, ""]; + return [part.slice(0, index), part.slice(index + 1)]; +} + +function decodeUrlParamPart(value: string): string { + try { + return decodeURIComponent(value.replace(/\+/g, " ")); + } catch { + return value; + } +} + function connectViaProxy(config: ConnectionConfig, proxy: ProxyLayer): Promise { return new Promise((resolve, reject) => { const socket = netConnect(proxy.port || 1080, proxy.host || "127.0.0.1"); @@ -295,6 +378,10 @@ function isMysqlType(dbType: string): boolean { return dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "manticoresearch"; } +function isPostgresType(dbType: string): boolean { + return dbType === "postgres" || dbType === "redshift" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "questdb"; +} + interface BridgeQueryResult { columns: string[]; rows: unknown[][]; diff --git a/packages/node-core/tests/postgres-url.test.ts b/packages/node-core/tests/postgres-url.test.ts new file mode 100644 index 0000000000..6cd4f48a9d --- /dev/null +++ b/packages/node-core/tests/postgres-url.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import type { ConnectionConfig } from "../src/connections.js"; +import { buildConnectionUrl } from "../src/database.js"; + +function postgresConfig(overrides: Partial = {}): ConnectionConfig { + return { + id: "pg-test", + name: "pg", + db_type: "postgres", + host: "pg.example.com", + port: 5432, + username: "postgres", + password: "secret", + database: "app", + ssl: false, + ...overrides, + }; +} + +test("postgres MCP URL adds sslmode=require when TLS is enabled", () => { + const url = buildConnectionUrl(postgresConfig({ ssl: true }), { host: "pg.example.com", port: 5432 }); + + assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require"); +}); + +test("postgres MCP URL keeps explicit sslmode", () => { + const url = buildConnectionUrl(postgresConfig({ ssl: true, url_params: "sslmode=verify-full&application_name=dbx" }), { + host: "pg.example.com", + port: 5432, + }); + + assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=verify-full&application_name=dbx"); +}); + +test("postgres MCP URL maps ssl-mode to sslmode", () => { + const url = buildConnectionUrl(postgresConfig({ url_params: "ssl-mode=required" }), { + host: "pg.example.com", + port: 5432, + }); + + assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require"); +}); + +test("postgres MCP URL drops MySQL-style TLS params", () => { + const url = buildConnectionUrl( + postgresConfig({ + url_params: "ssl-mode=required&verify_ca=false&verify_identity=false&require_ssl=true&charset=utf8mb4&application_name=dbx", + }), + { host: "pg.example.com", port: 5432 }, + ); + + assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require&application_name=dbx"); +}); + +test("postgres MCP URL maps schema and timezone to options", () => { + const url = buildConnectionUrl(postgresConfig({ url_params: "schema=app&timezone=UTC" }), { + host: "pg.example.com", + port: 5432, + }); + + assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?options=-c%20search_path%3Dapp%20-c%20TimeZone%3DUTC"); +}); + +test("pooled URL builder rejects non-pooled direct types", () => { + assert.throws( + () => buildConnectionUrl(postgresConfig({ db_type: "sqlite", host: "/tmp/app.db", port: 0 }), { host: "/tmp/app.db", port: 0 }), + /Unsupported pooled connection type: sqlite/, + ); +}); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 38981b8c24..774a6977c6 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; @@ -6,11 +7,13 @@ pub use dbx_core::ai::*; #[tauri::command] pub async fn ai_test_connection(config: AiConfig) -> Result { + let config = resolve_codex_cli_config(config); dbx_core::ai::test_connection_core(&config).await } #[tauri::command] pub async fn ai_list_models(config: AiConfig) -> Result, String> { + let config = resolve_codex_cli_config(config); dbx_core::ai::list_models_core(&config).await } @@ -64,6 +67,7 @@ pub async fn ai_agent_stream( db_type: String, mode: Option, ) -> Result { + let request = resolve_codex_cli_request(request); let cancelled = dbx_core::ai::register_stream(&session_id).await; let parsed_db_type: DatabaseType = @@ -105,6 +109,32 @@ pub async fn ai_agent_stream( result } +fn resolve_codex_cli_request(mut request: AiCompletionRequest) -> AiCompletionRequest { + request.config = resolve_codex_cli_config(request.config); + request +} + +fn resolve_codex_cli_config(mut config: AiConfig) -> AiConfig { + if !matches!(config.provider, AiProvider::CodexCli) { + return config; + } + + let command = config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex"); + if is_explicit_cli_path(command) { + return config; + } + + if let Some(path) = super::mcp::locate_command(command) { + config.codex_cli_path = Some(path); + } + config +} + +fn is_explicit_cli_path(command: &str) -> bool { + let path = Path::new(command); + path.is_absolute() || command.contains('/') || command.contains('\\') +} + #[tauri::command] pub async fn save_ai_conversation(state: State<'_, Arc>, conversation: AiConversation) -> Result<(), String> { state.storage.save_ai_conversation(&conversation).await diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 465db4e598..7862ba0fa9 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -120,17 +120,21 @@ pub(crate) fn resolve_mcp_server_command() -> Option<(String, Vec)> { .or_else(|| installed_mcp_bin_script().map(|script| ("node".to_string(), vec![script]))) } -fn locate_mcp_bin() -> Option { +pub(crate) fn locate_command(command: &str) -> Option { #[cfg(windows)] { - return locate_windows_command("dbx-mcp-server"); + return locate_windows_command(command); } #[cfg(not(windows))] { - command_stdout("which", &["dbx-mcp-server"]).ok().and_then(first_non_empty_line) + command_stdout("which", &[command]).ok().and_then(first_non_empty_line) } } +fn locate_mcp_bin() -> Option { + locate_command("dbx-mcp-server") +} + fn installed_mcp_bin_script() -> Option { let root = command_stdout("npm", &["root", "-g"]).ok()?; let script = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("dist").join("index.js");