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
5 changes: 4 additions & 1 deletion packages/core/src/common/openai-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
Expand Down Expand Up @@ -51,7 +52,9 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): {
};
}

const cacheKey = `${settings.apiKey}::${settings.baseURL}`;
// Cache key hashes the apiKey so the raw secret never lingers in module
// state (a heap dump / crash report would otherwise contain it verbatim).
const cacheKey = `${createHash("sha256").update(settings.apiKey).digest("hex").slice(0, 16)}::${settings.baseURL}`;
if (cachedOpenAI && cachedOpenAIKey === cacheKey) {
return {
client: cachedOpenAI,
Expand Down
137 changes: 137 additions & 0 deletions packages/core/src/common/private-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* User-private filesystem helpers for DeepCode runtime state.
*
* DeepCode stores API keys and session data under the user's home directory.
* POSIX callers should rely on explicit mode bits (0600/0700) rather than the
* process umask, which is commonly permissive on desktop systems. Windows
* ignores POSIX mode bits, so we additionally restrict the NTFS ACL to the
* current user — matching the 0600 intent.
*/

import { execFileSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

/** POSIX mode for private files: owner read/write only. */
export const PRIVATE_FILE_MODE = 0o600;
/** POSIX mode for private directories: owner rwx only. */
export const PRIVATE_DIRECTORY_MODE = 0o700;

/**
* Resolve the current Windows user as a fully-qualified principal
* (``DOMAIN\user``) so ACL grants are unambiguous across machines/domains.
* Returns null when the identity cannot be resolved (callers no-op).
*/
export function windowsIdentity(): string | null {
if (process.platform !== "win32") {
return null;
}
try {
const stdout = execFileSync("whoami", {
encoding: "utf8",
timeout: 5000,
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
});
const principal = stdout.trim();
return principal || null;
} catch {
return null;
}
}

/**
* Restrict an NTFS path to the current user (Windows only; no-op elsewhere).
*
* Two idempotent steps:
* 1. ``icacls /inheritance:r`` removes inherited ACEs so a permissive parent
* (e.g. the profile root granting ``Authenticated Users``) no longer
* applies.
* 2. ``icacls /grant:r <user>:F`` grants the current user exclusive full
* control (``:r`` replaces, does not append).
*
* Failures are swallowed (best-effort, like POSIX chmod) — the file is still
* created; only its ACL may be more permissive than intended.
*/
export function restrictWindowsAcl(targetPath: string): void {
if (process.platform !== "win32") {
return;
}
const identity = windowsIdentity();
if (!identity) {
return;
}
for (const args of [
["icacls", targetPath, "/inheritance:r"],
["icacls", targetPath, "/grant:r", `${identity}:F`],
]) {
try {
execFileSync("icacls", args, {
encoding: "utf8",
timeout: 15000,
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return; // best-effort; keep the caller moving
}
}
}

/**
* Write a private file with user-only permissions on every platform.
*
* - POSIX: mode 0600 (subject to umask, which typically keeps it at 0600).
* - Windows: mode bits are ignored by the OS, so we remove inherited ACEs
* and grant the current user exclusive full control.
*/
export function writePrivateFile(targetPath: string, contents: string): void {
fs.writeFileSync(targetPath, contents, { encoding: "utf8", mode: PRIVATE_FILE_MODE });
if (process.platform === "win32") {
restrictWindowsAcl(targetPath);
}
}

/**
* Ensure a directory exists with user-only permissions (0700 on POSIX;
* current-user-only ACL on Windows).
*/
export function ensurePrivateDirectory(dirPath: string): void {
fs.mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
if (process.platform === "win32") {
restrictWindowsAcl(dirPath);
}
}

/** Home directory used for DeepCode user state. */
export function deepcodeHome(): string {
return path.join(os.homedir(), ".deepcode");
}

/**
* Atomically write ``contents`` to ``targetPath`` with user-only permissions.
*
* Writes to a sibling ``.tmp`` file first, then ``fs.rename`` over the target.
* A crash mid-write leaves the previous content intact instead of a truncated
* file. On Windows, the private ACL is applied to the temp file *before* the
* rename so the final path never exists with a permissive ACL.
*/
export function writeFileAtomic(targetPath: string, contents: string): void {
const tmpPath = `${targetPath}.tmp`;
writePrivateFile(tmpPath, contents);
try {
fs.renameSync(tmpPath, targetPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
/* ignore cleanup failure */
}
throw err;
}
if (process.platform === "win32") {
// After rename the final path may carry a new (inherited) ACL; re-restrict.
restrictWindowsAcl(targetPath);
}
}
36 changes: 35 additions & 1 deletion packages/core/src/mcp/mcp-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,37 @@ const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT
? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10)
: 30_000;
const MCP_CALL_TOOL_TIMEOUT_MS = 60_000;
/** Connection-establishment budget for MCP servers (startup + handshake). */
const MCP_CONNECT_TIMEOUT_MS = 15_000;
const API_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const API_TOOL_NAME_MAX_LENGTH = 64;

/**
* Classify an MCP error message so the agent can respond appropriately:
* a timed-out server should be retried / restarted, a config error should
* not be retried. Mirrors the failure taxonomy used by the MCP reference
* implementations (connection / protocol / timeout / auth / busy).
*/
export function classifyMcpError(message: string): "timeout" | "connection" | "protocol" | "auth" | "busy" | "unknown" {
const m = message.toLowerCase();
if (/timed out|timeout|deadline/.test(m)) {
return "timeout";
}
if (/connection refused|connect (call )?failed|failed to start|no such file|spawn .* enoent|not found/.test(m)) {
return "connection";
}
if (/unauthorized|forbidden|401|403|authentication|invalid api|api ?key/.test(m)) {
return "auth";
}
if (/parse error|invalid json|protocol|schema|unsupported/.test(m)) {
return "protocol";
}
if (/busy|locked|in progress/.test(m)) {
return "busy";
}
return "unknown";
}

type McpToolEntry = {
serverName: string;
originalName: string;
Expand Down Expand Up @@ -359,10 +387,16 @@ export class McpManager {
output: text || JSON.stringify(result.content),
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const kind = classifyMcpError(message);
return {
ok: false,
name,
error: err instanceof Error ? err.message : String(err),
error: message,
// Structured classification lets the agent distinguish a wedged
// server (timeout) from a config error (auth/protocol) instead of
// retrying blindly.
...(kind !== "unknown" ? { mcpErrorKind: kind } : {}),
};
}
}
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type PermissionSettings,
} from "./settings";
import { logApiError } from "./common/error-logger";
import { writeFileAtomic } from "./common/private-storage";
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger";
import { describeLlmError, getLlmErrorDetails } from "./common/llm-error";
import { killProcessTree } from "./common/process-tree";
Expand Down Expand Up @@ -1479,7 +1480,7 @@ ${agentInstructions}
toolCalls,
usage: accumulateUsage(entry.usage, responseUsage),
usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage),
activeTokens: getTotalTokens(responseUsage),
activeTokens: (entry.activeTokens ?? 0) + getTotalTokens(responseUsage),
status: "ask_permission",
failReason: null,
askPermissions: permissionPlan.askPermissions,
Expand All @@ -1505,7 +1506,7 @@ ${agentInstructions}
toolCalls,
usage: accumulateUsage(entry.usage, responseUsage),
usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage),
activeTokens: getTotalTokens(responseUsage),
activeTokens: (entry.activeTokens ?? 0) + getTotalTokens(responseUsage),
status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed",
failReason: refusal ? refusal : entry.failReason,
askPermissions: undefined,
Expand Down Expand Up @@ -1618,7 +1619,8 @@ ${agentInstructions}
...entry,
usage: accumulateUsage(entry.usage, responseUsage),
usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage),
activeTokens: getTotalTokens(responseUsage),
// 压缩后上下文已精简, 重置 activeTokens 避免立即再次触发压缩
activeTokens: 0,
updateTime: now,
}));

Expand Down Expand Up @@ -2126,7 +2128,7 @@ ${agentInstructions}
})),
originalPath: this.projectRoot,
};
fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8");
writeFileAtomic(sessionsIndexPath, JSON.stringify(normalized, null, 2));
}

private getSessionMessagesPath(sessionId: string): string {
Expand Down Expand Up @@ -2197,7 +2199,7 @@ ${agentInstructions}
this.ensureProjectDir();
const messagePath = this.getSessionMessagesPath(sessionId);
const payload = messages.map((message) => JSON.stringify(message)).join("\n");
fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8");
writeFileAtomic(messagePath, payload ? `${payload}\n` : "");
}

private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DEEPSEEK_V4_MODELS, defaultsToThinkingMode } from "./common/model-capab
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { ensurePrivateDirectory, writePrivateFile } from "./common/private-storage";

export type DeepcodingEnv = Record<string, string | undefined> & {
MODEL?: string;
Expand Down Expand Up @@ -684,8 +685,8 @@ export function readProjectSettings(projectRoot: string = process.cwd()): Deepco
}

function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
ensurePrivateDirectory(path.dirname(settingsPath));
writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
}

export function writeSettings(settings: DeepcodingSettings): void {
Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/tests/auto-compact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { getCompactPromptTokenThreshold } from "../session";

/**
* Regression test for auto-compaction bug:
* activeTokens was set to the *single response* total_tokens instead of a
* running counter. Because each individual call stays below the threshold,
* auto-compaction never fired, so long sessions kept resending their full
* history on every turn and blew up token usage.
*
* The fix (session.ts):
* activeTokens = (entry.activeTokens ?? 0) + getTotalTokens(responseUsage)
* → running context counter that grows with each response
* activeTokens = 0 after compaction
* → reset so it does not re-trigger immediately on the next turn
*/
describe("auto-compact activeTokens accumulation", () => {
it("accumulated activeTokens eventually crosses the threshold (buggy single-response never does)", () => {
const threshold = getCompactPromptTokenThreshold("deepseek-v4-flash");
// Each single response is far below the threshold
const singleTotal = 30_000;
assert.ok(singleTotal < threshold, "single response must stay under threshold");

// Buggy behavior: activeTokens = getTotalTokens(responseUsage) — single response, never crosses
const buggyActive = singleTotal;
// Fixed behavior: activeTokens = previous + responseTotal (running counter)
let activeTokens = 0;
let fixedCrossed = false;

// Simulate many turns in one long session
for (let i = 0; i < 50; i += 1) {
activeTokens += singleTotal;
if (activeTokens > threshold) {
fixedCrossed = true;
activeTokens = 0; // reset after compaction
}
}

assert.equal(fixedCrossed, true, "fixed logic must trigger compaction at some point");
assert.ok(buggyActive < threshold, "buggy single-response activeTokens stays under threshold forever");
});

it("resetting activeTokens after compaction prevents immediate re-trigger", () => {
const threshold = getCompactPromptTokenThreshold("deepseek-v4-flash");
const bigTotal = 120_000;
let activeTokens = 0;
let compactions = 0;

for (let i = 0; i < 100; i += 1) {
activeTokens += bigTotal;
if (activeTokens > threshold) {
compactions += 1;
activeTokens = 0; // reset after compaction
}
}

assert.ok(compactions >= 1, "should have compacted at least once");
// Reset prevents runaway: with 120k/call and ~524k threshold, at least 4 calls
// must pass before the next compaction. 100 calls → at most ~25 compactions,
// and never one on every call.
const maxBounded = Math.ceil(100 / 4);
assert.ok(compactions <= maxBounded, `compactions=${compactions} should be bounded by ${maxBounded}`);
});
});
Loading