Skip to content

Commit 9edd6ee

Browse files
fix(core): atomic session writes, MCP error classification, bg-log sweep
P1/P2 hardening on top of the private-settings fix: 1. Atomic session writes (session.ts) - saveSessionsIndex / saveSessionMessages now write via writeFileAtomic (tmp + rename) instead of writeFileSync, so a crash mid-write never leaves a truncated sessions-index.json / .jsonl. 2. MCP error classification (mcp-manager.ts) - classifyMcpError() tags tool failures as timeout/connection/auth/ protocol/busy so the agent can distinguish a wedged server (retry) from a config error (don't retry). Surface as mcpErrorKind on the tool result. 3. Background log sweep (bash-handler.ts) - sweepOldBackgroundLogs() removes deepcode-background/*.log older than 7 days on each background start (was never cleaned). Evaluated and left as-is: sessionWorkingDirs is already cleared on session delete; deps already use package-lock.json + npm ci in CI. Tests: hardening.test.ts (atomic write, classifyMcpError, sweep) — 9 new cases. Full suite: 293 tests, 286 pass, 0 fail. tsc --noEmit clean.
1 parent bae5ffd commit 9edd6ee

5 files changed

Lines changed: 198 additions & 3 deletions

File tree

packages/core/src/common/private-storage.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,30 @@ export function ensurePrivateDirectory(dirPath: string): void {
108108
export function deepcodeHome(): string {
109109
return path.join(os.homedir(), ".deepcode");
110110
}
111+
112+
/**
113+
* Atomically write ``contents`` to ``targetPath`` with user-only permissions.
114+
*
115+
* Writes to a sibling ``.tmp`` file first, then ``fs.rename`` over the target.
116+
* A crash mid-write leaves the previous content intact instead of a truncated
117+
* file. On Windows, the private ACL is applied to the temp file *before* the
118+
* rename so the final path never exists with a permissive ACL.
119+
*/
120+
export function writeFileAtomic(targetPath: string, contents: string): void {
121+
const tmpPath = `${targetPath}.tmp`;
122+
writePrivateFile(tmpPath, contents);
123+
try {
124+
fs.renameSync(tmpPath, targetPath);
125+
} catch (err) {
126+
try {
127+
fs.unlinkSync(tmpPath);
128+
} catch {
129+
/* ignore cleanup failure */
130+
}
131+
throw err;
132+
}
133+
if (process.platform === "win32") {
134+
// After rename the final path may carry a new (inherited) ACL; re-restrict.
135+
restrictWindowsAcl(targetPath);
136+
}
137+
}

packages/core/src/mcp/mcp-manager.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,37 @@ const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT
66
? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10)
77
: 30_000;
88
const MCP_CALL_TOOL_TIMEOUT_MS = 60_000;
9+
/** Connection-establishment budget for MCP servers (startup + handshake). */
10+
const MCP_CONNECT_TIMEOUT_MS = 15_000;
911
const API_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
1012
const API_TOOL_NAME_MAX_LENGTH = 64;
1113

14+
/**
15+
* Classify an MCP error message so the agent can respond appropriately:
16+
* a timed-out server should be retried / restarted, a config error should
17+
* not be retried. Mirrors the failure taxonomy used by the MCP reference
18+
* implementations (connection / protocol / timeout / auth / busy).
19+
*/
20+
export function classifyMcpError(message: string): "timeout" | "connection" | "protocol" | "auth" | "busy" | "unknown" {
21+
const m = message.toLowerCase();
22+
if (/timed out|timeout|deadline/.test(m)) {
23+
return "timeout";
24+
}
25+
if (/connection refused|connect (call )?failed|failed to start|no such file|spawn .* enoent|not found/.test(m)) {
26+
return "connection";
27+
}
28+
if (/unauthorized|forbidden|401|403|authentication|invalid api|api ?key/.test(m)) {
29+
return "auth";
30+
}
31+
if (/parse error|invalid json|protocol|schema|unsupported/.test(m)) {
32+
return "protocol";
33+
}
34+
if (/busy|locked|in progress/.test(m)) {
35+
return "busy";
36+
}
37+
return "unknown";
38+
}
39+
1240
type McpToolEntry = {
1341
serverName: string;
1442
originalName: string;
@@ -359,10 +387,16 @@ export class McpManager {
359387
output: text || JSON.stringify(result.content),
360388
};
361389
} catch (err) {
390+
const message = err instanceof Error ? err.message : String(err);
391+
const kind = classifyMcpError(message);
362392
return {
363393
ok: false,
364394
name,
365-
error: err instanceof Error ? err.message : String(err),
395+
error: message,
396+
// Structured classification lets the agent distinguish a wedged
397+
// server (timeout) from a config error (auth/protocol) instead of
398+
// retrying blindly.
399+
...(kind !== "unknown" ? { mcpErrorKind: kind } : {}),
366400
};
367401
}
368402
}

packages/core/src/session.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type PermissionSettings,
3636
} from "./settings";
3737
import { logApiError } from "./common/error-logger";
38+
import { writeFileAtomic } from "./common/private-storage";
3839
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger";
3940
import { describeLlmError, getLlmErrorDetails } from "./common/llm-error";
4041
import { killProcessTree } from "./common/process-tree";
@@ -2126,7 +2127,7 @@ ${agentInstructions}
21262127
})),
21272128
originalPath: this.projectRoot,
21282129
};
2129-
fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8");
2130+
writeFileAtomic(sessionsIndexPath, JSON.stringify(normalized, null, 2));
21302131
}
21312132

21322133
private getSessionMessagesPath(sessionId: string): string {
@@ -2197,7 +2198,7 @@ ${agentInstructions}
21972198
this.ensureProjectDir();
21982199
const messagePath = this.getSessionMessagesPath(sessionId);
21992200
const payload = messages.map((message) => JSON.stringify(message)).join("\n");
2200-
fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8");
2201+
writeFileAtomic(messagePath, payload ? `${payload}\n` : "");
22012202
}
22022203

22032204
private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import * as fs from "fs";
4+
import * as os from "os";
5+
import * as path from "path";
6+
import { writeFileAtomic } from "../common/private-storage";
7+
import { classifyMcpError } from "../mcp/mcp-manager";
8+
import { sweepOldBackgroundLogs } from "../tools/bash-handler";
9+
10+
const tempDirs: string[] = [];
11+
12+
function tempDir(prefix: string): string {
13+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
14+
tempDirs.push(dir);
15+
return dir;
16+
}
17+
18+
afterEach(() => {
19+
for (const dir of tempDirs.splice(0)) {
20+
fs.rmSync(dir, { recursive: true, force: true });
21+
}
22+
});
23+
24+
describe("writeFileAtomic", () => {
25+
it("replaces the target and leaves no .tmp behind", () => {
26+
const dir = tempDir("dc-atomic-");
27+
const target = path.join(dir, "index.json");
28+
fs.writeFileSync(target, "old", "utf8");
29+
writeFileAtomic(target, "new");
30+
assert.equal(fs.readFileSync(target, "utf8"), "new");
31+
assert.ok(!fs.existsSync(`${target}.tmp`), "tmp file must be cleaned up");
32+
});
33+
34+
it("writes 0600 on POSIX", () => {
35+
if (process.platform === "win32") {
36+
return;
37+
}
38+
const dir = tempDir("dc-atomic-mode-");
39+
const target = path.join(dir, "s.json");
40+
writeFileAtomic(target, "{}");
41+
const mode = fs.statSync(target).mode & 0o777;
42+
assert.equal(mode, 0o600);
43+
});
44+
});
45+
46+
describe("classifyMcpError", () => {
47+
it("classifies timeouts", () => {
48+
assert.equal(classifyMcpError("Timed out after 60000ms"), "timeout");
49+
assert.equal(classifyMcpError("deadline exceeded"), "timeout");
50+
});
51+
52+
it("classifies connection failures", () => {
53+
assert.equal(classifyMcpError("Failed to start MCP server: ENOENT"), "connection");
54+
assert.equal(classifyMcpError("connection refused"), "connection");
55+
});
56+
57+
it("classifies auth failures", () => {
58+
assert.equal(classifyMcpError("HTTP 401 unauthorized"), "auth");
59+
assert.equal(classifyMcpError("invalid api key"), "auth");
60+
});
61+
62+
it("classifies protocol and busy", () => {
63+
assert.equal(classifyMcpError("invalid json payload"), "protocol");
64+
assert.equal(classifyMcpError("server busy, retry later"), "busy");
65+
});
66+
67+
it("returns unknown otherwise", () => {
68+
assert.equal(classifyMcpError("something unexpected"), "unknown");
69+
});
70+
});
71+
72+
describe("sweepOldBackgroundLogs", () => {
73+
it("deletes only expired .log files", () => {
74+
const dir = tempDir("dc-sweep-");
75+
76+
const oldLog = path.join(dir, "old.log");
77+
const freshLog = path.join(dir, "fresh.log");
78+
const otherFile = path.join(dir, "keep.txt");
79+
fs.writeFileSync(oldLog, "old");
80+
fs.writeFileSync(freshLog, "fresh");
81+
fs.writeFileSync(otherFile, "keep");
82+
83+
// Simulate old file: 8 days ago; fresh: now.
84+
const now = Date.now();
85+
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
86+
fs.utimesSync(oldLog, new Date(eightDaysAgo), new Date(eightDaysAgo));
87+
88+
sweepOldBackgroundLogs(now, dir);
89+
90+
assert.ok(!fs.existsSync(oldLog), "expired .log must be deleted");
91+
assert.ok(fs.existsSync(freshLog), "fresh .log must be kept");
92+
assert.ok(fs.existsSync(otherFile), "non-log file must be kept");
93+
});
94+
95+
it("is a no-op when the directory does not exist", () => {
96+
const missing = path.join(os.tmpdir(), "dc-no-such-sweep-dir");
97+
assert.doesNotThrow(() => sweepOldBackgroundLogs(Date.now(), missing));
98+
});
99+
});

packages/core/src/tools/bash-handler.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
const MAX_OUTPUT_CHARS = 30000;
1919
const MAX_CAPTURE_CHARS = 10 * 1024 * 1024;
2020
const BACKGROUND_OUTPUT_DIR = path.join(os.tmpdir(), "deepcode-background");
21+
/** Background-task logs older than this are deleted on the next background start. */
22+
const BACKGROUND_LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
2123
const TRAILING_BACKGROUND_OPERATOR_PATTERN = /(^|[^\\&])\s*&\s*$/;
2224
const sessionWorkingDirs = new Map<string, string>();
2325

@@ -28,6 +30,37 @@ export function clearSessionWorkingDir(sessionId: string): void {
2830
sessionWorkingDirs.delete(sessionId);
2931
}
3032

33+
/**
34+
* Delete background-task logs older than {@link BACKGROUND_LOG_MAX_AGE_MS}.
35+
* Best-effort: the temp dir is shared and may contain files from other
36+
* processes, so we only remove ``*.log`` files past the age cutoff and never
37+
* touch directories. Called once per background command start — cheap enough
38+
* to avoid a dedicated sweeper task.
39+
*/
40+
export function sweepOldBackgroundLogs(nowMs: number = Date.now(), dir: string = BACKGROUND_OUTPUT_DIR): void {
41+
let entries: fs.Dirent[];
42+
try {
43+
entries = fs.readdirSync(dir, { withFileTypes: true });
44+
} catch {
45+
return; // dir does not exist yet
46+
}
47+
const cutoff = nowMs - BACKGROUND_LOG_MAX_AGE_MS;
48+
for (const entry of entries) {
49+
if (!entry.isFile() || !entry.name.endsWith(".log")) {
50+
continue;
51+
}
52+
const full = path.join(dir, entry.name);
53+
try {
54+
const stat = fs.statSync(full);
55+
if (stat.mtimeMs < cutoff) {
56+
fs.unlinkSync(full);
57+
}
58+
} catch {
59+
// File may have been removed concurrently; skip.
60+
}
61+
}
62+
}
63+
3164
type ToolCommandResult = {
3265
ok: boolean;
3366
output: string;
@@ -264,6 +297,7 @@ function startBackgroundShellCommand(
264297
context: ToolExecutionContext
265298
): ToolExecutionResult {
266299
fs.mkdirSync(BACKGROUND_OUTPUT_DIR, { recursive: true });
300+
sweepOldBackgroundLogs();
267301
const taskId = `bash-${randomUUID()}`;
268302
const outputPath = path.join(BACKGROUND_OUTPUT_DIR, `${taskId}.log`);
269303
const startedAtMs = Date.now();

0 commit comments

Comments
 (0)