diff --git a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts index f7ed22d16c..732c1f11d4 100644 --- a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts +++ b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts @@ -284,6 +284,31 @@ describe("backend error translation", () => { expect(sanitizeBackendErrorMessage(message)).toBe(message); }); + test("hides internal Agent error data that precedes appended fallback context", () => { + const t = translatorFor("zh-CN"); + const message = + 'Agent RPC error (-1): ORA-12514: TNS:listener does not currently know of service requested in connect descriptor\nDBX_AGENT_ERROR_DATA:{"category":null,"retryable":null,"sessionDisposition":null,"stage":null,"operationOutcome":null,"agentSessionId":null}\n\nFallback with alternate Oracle descriptor failed: Agent RPC error (-1): ORA-12505'; + const expected = "Agent RPC error (-1): ORA-12514: TNS:listener does not currently know of service requested in connect descriptor\n\nFallback with alternate Oracle descriptor failed: Agent RPC error (-1): ORA-12505"; + + expect(sanitizeBackendErrorMessage(message)).toBe(expected); + expect(formatError(new Error(message))).toBe(expected); + expect(translateBackendError(t, message)).toBe(expected); + }); + + test("hides nested Agent error data inside fallback context", () => { + const message = 'native wire version error\nDBX_AGENT_ERROR_DATA:{"category":"connection"}\n\nFallback with MongoDB (Legacy) driver failed: Agent RPC error (-1): handshake rejected\nDBX_AGENT_ERROR_DATA:{"agentSessionId":"session-1"}'; + const expected = "native wire version error\n\nFallback with MongoDB (Legacy) driver failed: Agent RPC error (-1): handshake rejected"; + + expect(sanitizeBackendErrorMessage(message)).toBe(expected); + }); + + test("keeps invalid internal-looking data while hiding valid payloads after it", () => { + const message = 'database returned\nDBX_AGENT_ERROR_DATA:not-json\n\nFallback failed: Agent RPC error (-1): closed\nDBX_AGENT_ERROR_DATA:{"agentSessionId":"session-1"}'; + const expected = "database returned\nDBX_AGENT_ERROR_DATA:not-json\n\nFallback failed: Agent RPC error (-1): closed"; + + expect(sanitizeBackendErrorMessage(message)).toBe(expected); + }); + test("strips the SQL error-position transport suffix from raw messages", () => { const message = 'ERROR: relation "missing" does not exist\nDBX_SQL_ERROR_POSITION:15'; const expected = 'ERROR: relation "missing" does not exist'; diff --git a/apps/desktop/src/lib/backend/errorUtils.ts b/apps/desktop/src/lib/backend/errorUtils.ts index 4e614a8ded..850e1beeaa 100644 --- a/apps/desktop/src/lib/backend/errorUtils.ts +++ b/apps/desktop/src/lib/backend/errorUtils.ts @@ -40,26 +40,89 @@ export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001"; const MAX_FALLBACK_CHARS = 64 * 1024; const MAX_ERROR_PARSE_DEPTH = 16; +// The Agent transport appends its structured payload behind this marker. It is +// not always the last thing in the message: callers append human-readable +// fallback context after it, so the payload has to be stripped wherever it sits. const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:"; // Rust-side transport suffix carrying a driver cursor position. It is stripped // before a structured envelope is built, but metadata/catalog errors can surface // as raw strings, so strip it here so it never reaches the UI. const SQL_ERROR_POSITION_MARKER_PATTERN = /\nDBX_SQL_ERROR_POSITION:\d+/g; -export function sanitizeBackendErrorMessage(message: string): string { - const withoutPositionMarker = message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, ""); - const markerIndex = withoutPositionMarker.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER); - if (markerIndex < 0) return withoutPositionMarker; - - const rawData = withoutPositionMarker.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim(); +function isInternalAgentErrorData(source: string): boolean { try { - const data: unknown = JSON.parse(rawData); - if (!data || typeof data !== "object" || Array.isArray(data)) return withoutPositionMarker; + const data: unknown = JSON.parse(source); + return Boolean(data) && typeof data === "object" && !Array.isArray(data); } catch { - return withoutPositionMarker; + return false; + } +} + +/** + * Exclusive end offset of the JSON object literal starting at `start`, or + * `null` when no complete object follows. + */ +function jsonObjectEnd(source: string, start: number): number | null { + if (source[start] !== "{") return null; + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return index + 1; + } + } + return null; +} + +/** + * Removes every internal Agent payload, wherever the marker appears. Trailing + * text that follows a payload is kept: callers use it to append fallback + * context such as the alternate Oracle descriptor failure. + */ +function stripAgentErrorMessageData(message: string): string { + let stripped = ""; + let cursor = 0; + let droppedToEnd = false; + + while (cursor < message.length) { + const markerIndex = message.indexOf(AGENT_RPC_ERROR_DATA_MARKER, cursor); + if (markerIndex < 0) break; + + const dataStart = markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length; + const leadingWhitespace = message.slice(dataStart).search(/\S/); + const objectStart = leadingWhitespace < 0 ? -1 : dataStart + leadingWhitespace; + const objectEnd = objectStart < 0 ? null : jsonObjectEnd(message, objectStart); + if (objectEnd === null || !isInternalAgentErrorData(message.slice(objectStart, objectEnd))) { + // Not internal data (for example a database message that mentions the + // marker): keep the text as-is and continue looking behind it. + stripped += message.slice(cursor, dataStart); + cursor = dataStart; + continue; + } + + stripped += message.slice(cursor, markerIndex); + cursor = objectEnd; + droppedToEnd = message.slice(objectEnd).trim().length === 0; } - return withoutPositionMarker.slice(0, markerIndex).trimEnd(); + const result = stripped + message.slice(cursor); + return droppedToEnd ? result.trimEnd() : result; +} + +export function sanitizeBackendErrorMessage(message: string): string { + return stripAgentErrorMessageData(message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, "")); } function isBackendError(value: unknown): value is BackendError {