Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions packages/cli/src/tests/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
99 changes: 48 additions & 51 deletions packages/core/src/common/error-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
const result: Record<string, unknown> = {};

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<string, unknown>;
if (!value || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map(sanitizePayload);
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
result[key] = sanitizePayload(val);
}
return result;
}

export type ApiErrorLogEntry = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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),
};
}
69 changes: 69 additions & 0 deletions packages/core/src/tests/error-logger.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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 });
}
});
22 changes: 15 additions & 7 deletions packages/core/src/tests/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
22 changes: 15 additions & 7 deletions packages/vscode-ide-companion/src/tests/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);