Skip to content

Commit 3ad0e0c

Browse files
committed
Harden worktree handling and queue routing
1 parent a869a45 commit 3ad0e0c

13 files changed

Lines changed: 502 additions & 65 deletions

File tree

TASKS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Task List (Review Findings)
2+
3+
Ordered by severity (High → Medium → Low). Source: recent changes review.
4+
5+
## High
6+
7+
- [x] Fix pending worktree intercepting all text (including commands) so users can still run /status, /stop, etc. (`src/handlers/text.ts:73`)
8+
- [x] Tie pending worktree to both userId + chatId to avoid cross-chat confusion. (`src/session.ts:276`)
9+
- [x] Add pending worktree expiry/timeout and clear it on /provider switch and session kill. (`src/session.ts:264`, `src/session.ts:295`, `src/session.ts:837`)
10+
- [x] Enforce allowlist validation when switching workingDir via worktree/branch/merge. (`src/handlers/text.ts:94`, `src/handlers/callback.ts:708`, `src/handlers/callback.ts:819`, `src/session.ts:320`)
11+
- [x] Add allowlist checks to sendfile callback to prevent arbitrary file exfiltration. (`src/handlers/callback.ts:732`)
12+
- [x] Add hard safety for Codex file operations to respect ALLOWED_PATHS (not just prompt). (`src/session.ts:638`, `src/providers/codex-worker.js:37`)
13+
14+
## Medium
15+
16+
- [x] Add timeout to git exec to avoid hangs. (`src/worktree.ts:43`)
17+
- [x] Guard `mkdirSync` in worktree creation with try/catch and user-friendly error. (`src/worktree.ts:444`)
18+
- [x] Reconcile worktree base directory with ALLOWED_PATHS (or auto-extend allowlist). (`src/worktree.ts:444`, `src/config.ts:102`, `src/security.ts:129`)
19+
- [x] Clear pending messages on session kill / worktree switch to avoid cross-context execution. (`src/session.ts:157`, `src/session.ts:837`)
20+
- [x] Support multiple pending worktree requests (at least scoped per chat) or reject with clearer feedback. (`src/session.ts:264`)
21+
- [x] Clear pending worktree state when switching via branch callback. (`src/handlers/callback.ts:708`, `src/session.ts:287`)
22+
- [x] Handle detached worktrees in worktree map to avoid wrong main path during merge. (`src/worktree.ts:89`, `src/worktree.ts:210`)
23+
- [x] Warn/block on dirty working tree before /merge. (`src/handlers/commands.ts:582`)
24+
- [x] Warn/block on dirty working tree before /worktree and /branch switches. (`src/handlers/commands.ts:499`, `src/handlers/commands.ts:538`)
25+
- [x] Provide user feedback when branches are omitted due to callback length. (`src/handlers/commands.ts:562`)
26+
- [x] Add paging/limit for long branch lists to avoid Telegram keyboard limits. (`src/handlers/commands.ts:562`)
27+
- [x] Add callback length check for /merge (long branch names). (`src/handlers/commands.ts:602`)
28+
29+
## Low
30+
31+
- [x] Add callback length check for /diff view (long file paths). (`src/handlers/commands.ts:1140`)
32+
- [x] Add file size limit + try/catch around diff file sending. (`src/handlers/callback.ts:916`)
33+
- [x] Handle spawn errors for Codex worker to avoid uncaught failures. (`src/providers/codex.ts:150`)
34+
- [x] Escape HTML for branch names in /branch and /merge messages. (`src/handlers/commands.ts:572`, `src/handlers/commands.ts:608`, `src/handlers/callback.ts:825`)
35+
- [x] Either wire `queryQueue` into handlers or remove dead code + tests. (`src/query-queue.ts:57`, `src/handlers/streaming.ts:281`)

src/__tests__/logger.test.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ describe("Logger", () => {
1919
console.error = consoleErrorMock;
2020
});
2121

22+
const getCall = (mockFn: ReturnType<typeof mock>, index = 0): string => {
23+
const call = mockFn.mock.calls[index]?.[0];
24+
if (call === undefined) {
25+
throw new Error("Expected console call");
26+
}
27+
return String(call);
28+
};
29+
2230
afterEach(() => {
2331
consoleLogMock.mockRestore?.();
2432
consoleWarnMock.mockRestore?.();
@@ -98,7 +106,7 @@ describe("Logger", () => {
98106
const log = new Logger("info");
99107
log.info("test message");
100108

101-
const call = consoleLogMock.mock.calls[0][0] as string;
109+
const call = getCall(consoleLogMock);
102110
// Check for ISO timestamp pattern: [YYYY-MM-DDTHH:MM:SS.sssZ]
103111
expect(call).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\]/);
104112
});
@@ -107,23 +115,23 @@ describe("Logger", () => {
107115
const log = new Logger("debug");
108116

109117
log.debug("test");
110-
expect(consoleLogMock.mock.calls[0][0]).toContain("DEBUG");
118+
expect(getCall(consoleLogMock, 0)).toContain("DEBUG");
111119

112120
log.info("test");
113-
expect(consoleLogMock.mock.calls[1][0]).toContain("INFO");
121+
expect(getCall(consoleLogMock, 1)).toContain("INFO");
114122

115123
log.warn("test");
116-
expect(consoleWarnMock.mock.calls[0][0]).toContain("WARN");
124+
expect(getCall(consoleWarnMock, 0)).toContain("WARN");
117125

118126
log.error("test");
119-
expect(consoleErrorMock.mock.calls[0][0]).toContain("ERROR");
127+
expect(getCall(consoleErrorMock, 0)).toContain("ERROR");
120128
});
121129

122130
test("includes the message", () => {
123131
const log = new Logger("info");
124132
log.info("Hello, world!");
125133

126-
const call = consoleLogMock.mock.calls[0][0] as string;
134+
const call = getCall(consoleLogMock);
127135
expect(call).toContain("Hello, world!");
128136
});
129137
});
@@ -133,23 +141,23 @@ describe("Logger", () => {
133141
const log = new Logger("info");
134142
log.info("Request received", { method: "GET", path: "/api/users" });
135143

136-
const call = consoleLogMock.mock.calls[0][0] as string;
144+
const call = getCall(consoleLogMock);
137145
expect(call).toContain('{"method":"GET","path":"/api/users"}');
138146
});
139147

140148
test("handles empty context object", () => {
141149
const log = new Logger("info");
142150
log.info("Simple message", {});
143151

144-
const call = consoleLogMock.mock.calls[0][0] as string;
152+
const call = getCall(consoleLogMock);
145153
expect(call).not.toContain("{}");
146154
});
147155

148156
test("handles undefined context", () => {
149157
const log = new Logger("info");
150158
log.info("Simple message");
151159

152-
const call = consoleLogMock.mock.calls[0][0] as string;
160+
const call = getCall(consoleLogMock);
153161
expect(call).toContain("Simple message");
154162
// Should not have trailing JSON
155163
expect(call).toMatch(/Simple message$/);
@@ -162,7 +170,7 @@ describe("Logger", () => {
162170
tags: ["admin", "active"],
163171
});
164172

165-
const call = consoleLogMock.mock.calls[0][0] as string;
173+
const call = getCall(consoleLogMock);
166174
expect(call).toContain('"user":{"id":123,"name":"Alice"}');
167175
expect(call).toContain('"tags":["admin","active"]');
168176
});
@@ -171,7 +179,7 @@ describe("Logger", () => {
171179
const log = new Logger("info");
172180
log.info("Status", { count: 42, enabled: true, ratio: 3.14 });
173181

174-
const call = consoleLogMock.mock.calls[0][0] as string;
182+
const call = getCall(consoleLogMock);
175183
expect(call).toContain('"count":42');
176184
expect(call).toContain('"enabled":true');
177185
expect(call).toContain('"ratio":3.14');

src/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { homedir } from "node:os";
8-
import { dirname, resolve } from "node:path";
8+
import { basename, dirname, resolve } from "node:path";
99
import type { McpServerConfig } from "./types";
1010

1111
// ============== Environment Setup ==============
@@ -100,8 +100,10 @@ export { MCP_SERVERS };
100100
// ============== Security Configuration ==============
101101

102102
// Allowed directories for file operations
103+
const WORKTREE_BASE_DIR = resolve(WORKING_DIR, "..", "worktree");
103104
const defaultAllowedPaths = [
104105
WORKING_DIR,
106+
...(basename(WORKTREE_BASE_DIR) === "worktree" ? [WORKTREE_BASE_DIR] : []),
105107
`${HOME}/Documents`,
106108
`${HOME}/Downloads`,
107109
`${HOME}/Desktop`,

src/handlers/callback.ts

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66

77
import { unlinkSync } from "node:fs";
88
import { type Context, InlineKeyboard } from "grammy";
9-
import { addBookmark, removeBookmark } from "../bookmarks";
9+
import { addBookmark, removeBookmark, resolvePath } from "../bookmarks";
1010
import {
1111
AGENT_PROVIDERS,
1212
ALLOWED_USERS,
1313
type AgentProviderId,
1414
MESSAGE_EFFECTS,
1515
} from "../config";
16-
import { isAuthorized } from "../security";
16+
import { escapeHtml } from "../formatting";
17+
import { queryQueue } from "../query-queue";
18+
import { isAuthorized, isPathAllowed } from "../security";
1719
import { session } from "../session";
1820
import { auditLog, startTypingIndicator } from "../utils";
1921
import { logNonCriticalError } from "../utils/error-logging";
@@ -97,7 +99,7 @@ export async function handleCallback(ctx: Context): Promise<void> {
9799

98100
// 2h. Handle branch callbacks
99101
if (callbackData.startsWith("branch:")) {
100-
await handleBranchCallback(ctx, callbackData);
102+
await handleBranchCallback(ctx, userId, chatId, callbackData);
101103
return;
102104
}
103105

@@ -197,7 +199,7 @@ export async function handleCallback(ctx: Context): Promise<void> {
197199
const statusCallback = createStatusCallback(ctx, state);
198200

199201
try {
200-
const response = await session.sendMessageStreaming(
202+
const response = await queryQueue.sendMessage(
201203
message,
202204
username,
203205
userId,
@@ -372,7 +374,7 @@ async function handlePendingCallback(
372374
const statusCallback = createStatusCallback(ctx, state);
373375

374376
try {
375-
const response = await session.sendMessageStreaming(
377+
const response = await queryQueue.sendMessage(
376378
message,
377379
username,
378380
userId,
@@ -433,7 +435,7 @@ async function handleActionCallback(
433435
const statusCallback = createStatusCallback(ctx, state);
434436

435437
try {
436-
const response = await session.sendMessageStreaming(
438+
const response = await queryQueue.sendMessage(
437439
command,
438440
username,
439441
userId,
@@ -672,6 +674,8 @@ async function handleProviderCallback(
672674
*/
673675
async function handleBranchCallback(
674676
ctx: Context,
677+
userId: number,
678+
chatId: number,
675679
callbackData: string,
676680
): Promise<void> {
677681
const prefix = "branch:switch:";
@@ -705,19 +709,35 @@ async function handleBranchCallback(
705709
return;
706710
}
707711

712+
if (!isPathAllowed(result.path)) {
713+
await ctx.answerCallbackQuery({
714+
text: "Worktree path is not in allowed directories.",
715+
});
716+
try {
717+
await ctx.reply(
718+
`❌ Worktree path is not in allowed directories:\n<code>${escapeHtml(result.path)}</code>\n\nUpdate ALLOWED_PATHS and try again.`,
719+
{ parse_mode: "HTML" },
720+
);
721+
} catch (error) {
722+
logNonCriticalError("branch allowlist reply", error);
723+
}
724+
return;
725+
}
726+
708727
// Save current session before switching
709728
session.flushSession();
710729
session.setWorkingDir(result.path);
711730
await session.kill();
731+
session.clearWorktreeRequest(userId, chatId);
712732

713733
try {
714734
await ctx.editMessageText(
715-
`✅ Switched to worktree:\n<code>${result.path}</code>\n\nBranch: <code>${result.branch}</code>`,
735+
`✅ Switched to worktree:\n<code>${escapeHtml(result.path)}</code>\n\nBranch: <code>${escapeHtml(result.branch)}</code>`,
716736
{ parse_mode: "HTML" },
717737
);
718738
} catch {
719739
await ctx.reply(
720-
`✅ Switched to worktree:\n<code>${result.path}</code>\n\nBranch: <code>${result.branch}</code>`,
740+
`✅ Switched to worktree:\n<code>${escapeHtml(result.path)}</code>\n\nBranch: <code>${escapeHtml(result.branch)}</code>`,
721741
{ parse_mode: "HTML" },
722742
);
723743
}
@@ -747,17 +767,28 @@ async function handleSendFileCallback(
747767
return;
748768
}
749769

770+
const resolvedPath = resolvePath(filePath, session.workingDir);
771+
750772
// Check file exists
751-
if (!existsSync(filePath)) {
773+
if (!existsSync(resolvedPath)) {
752774
await ctx.answerCallbackQuery({ text: "File not found" });
753775
return;
754776
}
755777

778+
if (!isPathAllowed(resolvedPath)) {
779+
await ctx.answerCallbackQuery({ text: "Access denied" });
780+
await ctx.reply(
781+
`❌ Access denied: <code>${escapeHtml(resolvedPath)}</code>`,
782+
{ parse_mode: "HTML" },
783+
);
784+
return;
785+
}
786+
756787
// Send the file
757788
try {
758789
await ctx.answerCallbackQuery({ text: "Sending file..." });
759-
const fileName = basename(filePath);
760-
await ctx.replyWithDocument(new InputFile(filePath, fileName));
790+
const fileName = basename(resolvedPath);
791+
await ctx.replyWithDocument(new InputFile(resolvedPath, fileName));
761792
} catch (error) {
762793
console.error("Failed to send file:", error);
763794
await ctx.reply(`❌ Failed to send file: ${String(error).slice(0, 100)}`);
@@ -816,14 +847,25 @@ async function handleMergeCallback(
816847
return;
817848
}
818849

850+
if (!isPathAllowed(info.mainWorktreePath)) {
851+
await ctx.answerCallbackQuery({
852+
text: "Main worktree path is not in allowed directories.",
853+
});
854+
await ctx.reply(
855+
`❌ Main worktree path is not in allowed directories:\n<code>${escapeHtml(info.mainWorktreePath)}</code>\n\nUpdate ALLOWED_PATHS and try again.`,
856+
{ parse_mode: "HTML" },
857+
);
858+
return;
859+
}
860+
819861
// Switch to main worktree
820862
session.flushSession();
821863
session.setWorkingDir(info.mainWorktreePath);
822864
await session.kill();
823865

824866
try {
825867
await ctx.editMessageText(
826-
`🔀 Switched to <code>${info.mainBranch}</code> worktree.\n\nMerging <code>${branchToMerge}</code>...`,
868+
`🔀 Switched to <code>${escapeHtml(info.mainBranch)}</code> worktree.\n\nMerging <code>${escapeHtml(branchToMerge)}</code>...`,
827869
{ parse_mode: "HTML" },
828870
);
829871
} catch (error) {
@@ -849,7 +891,7 @@ If the merge is clean, just complete it. If there are conflicts, explain what yo
849891
const chatId = ctx.chat?.id;
850892

851893
try {
852-
const response = await session.sendMessageStreaming(
894+
const response = await queryQueue.sendMessage(
853895
mergePrompt,
854896
username,
855897
userId,
@@ -919,10 +961,23 @@ async function handleDiffCallback(
919961

920962
const { InputFile } = await import("grammy");
921963
const diffBuffer = Buffer.from(result.fullDiff, "utf-8");
964+
const MAX_DIFF_SIZE = 50 * 1024 * 1024;
965+
if (diffBuffer.length > MAX_DIFF_SIZE) {
966+
await ctx.answerCallbackQuery({ text: "Diff file too large" });
967+
await ctx.reply("❌ Diff is too large to send as a file.");
968+
return;
969+
}
922970
const filename = file
923971
? `${file.replace(/\//g, "_")}.diff`
924972
: "changes.diff";
925-
await ctx.replyWithDocument(new InputFile(diffBuffer, filename));
973+
try {
974+
await ctx.replyWithDocument(new InputFile(diffBuffer, filename));
975+
} catch (error) {
976+
console.error("Failed to send diff file:", error);
977+
await ctx.reply(
978+
`❌ Failed to send diff file: ${String(error).slice(0, 100)}`,
979+
);
980+
}
926981
} else {
927982
// Send as HTML pre block
928983
await ctx.answerCallbackQuery({ text: "Showing diff..." });
@@ -961,7 +1016,7 @@ async function handleDiffCallback(
9611016
const chatId = ctx.chat?.id;
9621017

9631018
try {
964-
const response = await session.sendMessageStreaming(
1019+
const response = await queryQueue.sendMessage(
9651020
"/commit",
9661021
username,
9671022
userId,

0 commit comments

Comments
 (0)