diff --git a/packages/cli/src/tests/run-tests.mjs b/packages/cli/src/tests/run-tests.mjs index 87748b2d..f03600a1 100644 --- a/packages/cli/src/tests/run-tests.mjs +++ b/packages/cli/src/tests/run-tests.mjs @@ -2,14 +2,22 @@ import { globSync } from "glob"; import { spawnSync } from "child_process"; import { fileURLToPath } from "url"; +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testFiles = globSync("*.test.ts", { cwd: __dirname }); - -const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { - stdio: "inherit", - cwd: __dirname, -}); - -process.exit(result.status ?? 1); +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-cli-tests-home-")); +let status = 1; +try { + const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { + stdio: "inherit", + cwd: __dirname, + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + status = result.status ?? 1; +} finally { + fs.rmSync(tempHome, { recursive: true, force: true }); +} +process.exit(status); diff --git a/packages/core/src/common/error-logger.ts b/packages/core/src/common/error-logger.ts index 694a331a..6b7adb5e 100644 --- a/packages/core/src/common/error-logger.ts +++ b/packages/core/src/common/error-logger.ts @@ -17,59 +17,44 @@ function ensureLogDir(): void { * or response bodies. */ function maskSensitive(text: string): string { - return ( - text - // Mask Bearer tokens in Authorization headers - .replace(/(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, "$1***MASKED***") - // Mask "apiKey" or "api_key" values in JSON-like strings - .replace(/((?:api[Kk]ey|api_key|secret)\s*[:=]\s*"?)[^",}\s]+/gi, "$1***MASKED***") - ); + return text + .replace(/(Authorization:\s*(?:Bearer\s+)?)[^\s\r\n]+/gi, "$1***MASKED***") + .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "sk-***MASKED***") + .replace(/((?:api[Kk]ey|api_key|secret|token|authorization)\s*[:=]\s*"?)[^",}\s]+/gi, "$1***MASKED***"); } -const CONTENT_TRUNCATE_PREVIEW = 100; +const MAX_STRING_LENGTH = 500; +const MAX_LOG_LINE_CHARS = 32_000; +const MAX_ENTRIES = 50; -/** - * Truncate a content string for logging: keep a short prefix and append the - * total length so the payload structure is preserved while content bloat is - * avoided. - */ -function truncateContent(value: string): string { - if (value.length <= CONTENT_TRUNCATE_PREVIEW) { - return value; +function truncateString(value: string): string { + const masked = maskSensitive(value); + if (masked.length <= MAX_STRING_LENGTH) { + return masked; } - return `${value.slice(0, CONTENT_TRUNCATE_PREVIEW)}...(total ${value.length} chars)`; + return `${masked.slice(0, MAX_STRING_LENGTH)}...(total ${masked.length} chars)`; } /** - * Deep-clone a request payload, only truncating `content` fields whose value - * is a string. Every other field is kept exactly as-is so the logged request - * mirrors the original API payload (no fields added or removed). + * Deep-clone a payload, masking and truncating every string value. This keeps + * the structure for debugging without logging full prompts, tool arguments, or + * response bodies. */ -function sanitizeRequestPayload(request: Record): Record { - function walk(value: unknown): unknown { - if (!value || typeof value !== "object") { - return value; - } - - if (Array.isArray(value)) { - return value.map(walk); - } - - const record = value as Record; - const result: Record = {}; - - for (const [key, val] of Object.entries(record)) { - if (key === "content" && typeof val === "string") { - result[key] = truncateContent(val); - } else { - result[key] = walk(val); - } - } - - return result; +function sanitizePayload(value: unknown): unknown { + if (typeof value === "string") { + return truncateString(value); } - - return walk(request) as Record; + if (!value || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(sanitizePayload); + } + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = sanitizePayload(val); + } + return result; } export type ApiErrorLogEntry = { @@ -99,18 +84,30 @@ export function logApiError(entry: ApiErrorLogEntry): void { model: entry.model, baseURL: entry.baseURL, error: sanitizeError(entry.error), - request: sanitizeRequestPayload(entry.request), + request: sanitizePayload(entry.request), }; if (entry.response !== undefined) { - logLine.response = typeof entry.response === "string" ? maskSensitive(entry.response) : entry.response; + logLine.response = sanitizePayload(entry.response); } - const newLine = JSON.stringify(logLine) + "\n"; - fs.appendFileSync(ERROR_LOG_PATH, newLine, "utf8"); + let newLine = JSON.stringify(logLine); + if (newLine.length > MAX_LOG_LINE_CHARS) { + newLine = JSON.stringify({ + timestamp: entry.timestamp, + location: entry.location, + requestId: entry.requestId, + sessionId: entry.sessionId, + model: entry.model, + baseURL: entry.baseURL, + error: sanitizeError(entry.error), + truncated: true, + note: `entry exceeded ${MAX_LOG_LINE_CHARS} chars`, + }); + } + fs.appendFileSync(ERROR_LOG_PATH, `${newLine}\n`, "utf8"); // Keep only the last N entries - const MAX_ENTRIES = 20; const raw = fs.readFileSync(ERROR_LOG_PATH, "utf8"); const lines = raw.split("\n").filter((line) => line.trim().length > 0); if (lines.length > MAX_ENTRIES) { @@ -124,8 +121,8 @@ export function logApiError(entry: ApiErrorLogEntry): void { function sanitizeError(error: LlmErrorDetails): LlmErrorDetails { return { ...error, - message: maskSensitive(error.message), - stack: error.stack ? maskSensitive(error.stack) : undefined, + message: truncateString(error.message), + stack: error.stack ? truncateString(error.stack) : undefined, causes: error.causes?.map(sanitizeError), }; } diff --git a/packages/core/src/tests/error-logger.test.ts b/packages/core/src/tests/error-logger.test.ts new file mode 100644 index 00000000..4597d750 --- /dev/null +++ b/packages/core/src/tests/error-logger.test.ts @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +test("logApiError masks secrets, truncates payloads, and enforces retention", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-error-logger-")); + const previousHome = process.env.HOME; + process.env.HOME = tempHome; + try { + const { logApiError } = await import("../common/error-logger.js"); + const logPath = path.join(tempHome, ".deepcode", "logs", "error.log"); + + for (let index = 0; index < 55; index += 1) { + logApiError({ + timestamp: new Date().toISOString(), + location: "test", + requestId: `req-${index}`, + sessionId: "session-1", + error: { + name: "Error", + message: "Bearer sk-1234567890abcdef api_key=secret-value", + } as never, + request: { + model: "test", + messages: [{ role: "user", content: `hello ${"x".repeat(10_000)}` }], + tools: [{ type: "function", function: { name: "x", description: "y".repeat(10_000) } }], + }, + response: { body: "z".repeat(50_000) }, + }); + } + + const retained = fs.readFileSync(logPath, "utf8"); + const retainedLines = retained.split("\n").filter((line) => line.trim().length > 0); + assert.equal(retainedLines.length, 50); + assert.ok(retained.length < 2_000_000, `log grew to ${retained.length} bytes`); + assert.ok(!retained.includes("sk-1234567890abcdef"), "raw secret key leaked into the log"); + assert.ok(!retained.includes("secret-value"), "raw api_key leaked into the log"); + assert.ok(retained.includes("***MASKED***"), "sensitive values were not masked"); + assert.ok(retained.includes("...(total "), "long strings were not truncated"); + for (const line of retainedLines) { + assert.ok(line.length <= 32_000, `log entry is too long: ${line.length}`); + } + + const oversizedRequest: Record = {}; + for (let index = 0; index < 100; index += 1) { + oversizedRequest[`field${index}`] = "x".repeat(500); + } + logApiError({ + timestamp: new Date().toISOString(), + location: "test", + requestId: "req-oversized", + error: { name: "Error", message: "oversized" } as never, + request: oversizedRequest, + }); + + const finalLines = fs + .readFileSync(logPath, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0); + const last = JSON.parse(finalLines.at(-1) ?? "{}") as { truncated?: boolean }; + assert.equal(last.truncated, true); + assert.ok((finalLines.at(-1) ?? "").length <= 32_000); + } finally { + process.env.HOME = previousHome; + fs.rmSync(tempHome, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/tests/run-tests.mjs b/packages/core/src/tests/run-tests.mjs index 8ce54952..0208422e 100644 --- a/packages/core/src/tests/run-tests.mjs +++ b/packages/core/src/tests/run-tests.mjs @@ -2,14 +2,22 @@ import { globSync } from "glob"; import { spawnSync } from "child_process"; import { fileURLToPath } from "url"; +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testFiles = globSync("*.test.ts", { cwd: __dirname }); - -const result = spawnSync(process.execPath, ["--import", "tsx", "--test", "--test-concurrency=1", ...testFiles], { - stdio: "inherit", - cwd: __dirname, -}); - -process.exit(result.status ?? 1); +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-core-tests-home-")); +let status = 1; +try { + const result = spawnSync(process.execPath, ["--import", "tsx", "--test", "--test-concurrency=1", ...testFiles], { + stdio: "inherit", + cwd: __dirname, + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + status = result.status ?? 1; +} finally { + fs.rmSync(tempHome, { recursive: true, force: true }); +} +process.exit(status); diff --git a/packages/vscode-ide-companion/src/tests/run-tests.mjs b/packages/vscode-ide-companion/src/tests/run-tests.mjs index 73034d8a..34df920f 100644 --- a/packages/vscode-ide-companion/src/tests/run-tests.mjs +++ b/packages/vscode-ide-companion/src/tests/run-tests.mjs @@ -2,14 +2,22 @@ import { globSync } from "glob"; import { spawnSync } from "child_process"; import { fileURLToPath } from "url"; +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const testFiles = globSync("*.test.ts", { cwd: __dirname }); - -const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { - stdio: "inherit", - cwd: __dirname, -}); - -process.exit(result.status ?? 1); +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-vscode-tests-home-")); +let status = 1; +try { + const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { + stdio: "inherit", + cwd: __dirname, + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + status = result.status ?? 1; +} finally { + fs.rmSync(tempHome, { recursive: true, force: true }); +} +process.exit(status);