Roo-Code/src/services/checkpoints/ShadowCheckpointService.ts
Roo Code 40a52aecee fix: add file size validation to prevent memory exhaustion
- Add MAX_FILE_SIZE_BYTES constants for different file types
- Add size validation to readFileIfExists() in roo-config service
- Add size validation to .gitignore reading in list-files service
- Add size validation to checkpoint file reading in ShadowCheckpointService
- Add size validation to MCP config reading in McpHub
- Add comprehensive tests for file size validation
- Add FileSizeLimitError class for better error handling

This prevents memory exhaustion and performance issues when reading
large files across various Roo-Code services.

Fixes #6155
2025-07-24 08:54:41 +00:00

431 lines
13 KiB
TypeScript

import fs from "fs/promises"
import os from "os"
import * as path from "path"
import crypto from "crypto"
import EventEmitter from "events"
import simpleGit, { SimpleGit } from "simple-git"
import pWaitFor from "p-wait-for"
import { fileExistsAtPath } from "../../utils/fs"
import { executeRipgrep } from "../../services/search/file-search"
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
import { getExcludePatterns } from "./excludes"
import { MAX_CHECKPOINT_FILE_SIZE_BYTES } from "../constants/file-limits"
export abstract class ShadowCheckpointService extends EventEmitter {
public readonly taskId: string
public readonly checkpointsDir: string
public readonly workspaceDir: string
protected _checkpoints: string[] = []
protected _baseHash?: string
protected readonly dotGitDir: string
protected git?: SimpleGit
protected readonly log: (message: string) => void
protected shadowGitConfigWorktree?: string
public get baseHash() {
return this._baseHash
}
protected set baseHash(value: string | undefined) {
this._baseHash = value
}
public get isInitialized() {
return !!this.git
}
constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) {
super()
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath]
if (protectedPaths.includes(workspaceDir)) {
throw new Error(`Cannot use checkpoints in ${workspaceDir}`)
}
this.taskId = taskId
this.checkpointsDir = checkpointsDir
this.workspaceDir = workspaceDir
this.dotGitDir = path.join(this.checkpointsDir, ".git")
this.log = log
}
public async initShadowGit(onInit?: () => Promise<void>) {
if (this.git) {
throw new Error("Shadow git repo already initialized")
}
const hasNestedGitRepos = await this.hasNestedGitRepositories()
if (hasNestedGitRepos) {
throw new Error(
"Checkpoints are disabled because nested git repositories were detected in the workspace. " +
"Please remove or relocate nested git repositories to use the checkpoints feature.",
)
}
await fs.mkdir(this.checkpointsDir, { recursive: true })
const git = simpleGit(this.checkpointsDir)
const gitVersion = await git.version()
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
let created = false
const startTime = Date.now()
if (await fileExistsAtPath(this.dotGitDir)) {
this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`)
const worktree = await this.getShadowGitConfigWorktree(git)
if (worktree !== this.workspaceDir) {
throw new Error(
`Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`,
)
}
await this.writeExcludeFile()
this.baseHash = await git.revparse(["HEAD"])
} else {
this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
await git.init()
await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo.
await git.addConfig("user.name", "Roo Code")
await git.addConfig("user.email", "noreply@example.com")
await this.writeExcludeFile()
await this.stageAll(git)
const { commit } = await git.commit("initial commit", { "--allow-empty": null })
this.baseHash = commit
created = true
}
const duration = Date.now() - startTime
this.log(
`[${this.constructor.name}#initShadowGit] initialized shadow repo with base commit ${this.baseHash} in ${duration}ms`,
)
this.git = git
await onInit?.()
this.emit("initialize", {
type: "initialize",
workspaceDir: this.workspaceDir,
baseHash: this.baseHash,
created,
duration,
})
return { created, duration }
}
// Add basic excludes directly in git config, while respecting any
// .gitignore in the workspace.
// .git/info/exclude is local to the shadow git repo, so it's not
// shared with the main repo - and won't conflict with user's
// .gitignore.
protected async writeExcludeFile() {
await fs.mkdir(path.join(this.dotGitDir, "info"), { recursive: true })
const patterns = await getExcludePatterns(this.workspaceDir)
await fs.writeFile(path.join(this.dotGitDir, "info", "exclude"), patterns.join("\n"))
}
private async stageAll(git: SimpleGit) {
try {
await git.add(".")
} catch (error) {
this.log(
`[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
private async hasNestedGitRepositories(): Promise<boolean> {
try {
// Find all .git directories that are not at the root level.
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
// Filter to only include nested git directories (not the root .git).
const nestedGitPaths = gitPaths.filter(
({ type, path }) =>
type === "folder" && path.includes(".git") && !path.startsWith(".git") && path !== ".git",
)
if (nestedGitPaths.length > 0) {
this.log(
`[${this.constructor.name}#hasNestedGitRepositories] found ${nestedGitPaths.length} nested git repositories: ${nestedGitPaths.map((p) => p.path).join(", ")}`,
)
return true
}
return false
} catch (error) {
this.log(
`[${this.constructor.name}#hasNestedGitRepositories] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
)
// If we can't check, assume there are no nested repos to avoid blocking the feature.
return false
}
}
private async getShadowGitConfigWorktree(git: SimpleGit) {
if (!this.shadowGitConfigWorktree) {
try {
this.shadowGitConfigWorktree = (await git.getConfig("core.worktree")).value || undefined
} catch (error) {
this.log(
`[${this.constructor.name}#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
return this.shadowGitConfigWorktree
}
public async saveCheckpoint(
message: string,
options?: { allowEmpty?: boolean },
): Promise<CheckpointResult | undefined> {
try {
this.log(
`[${this.constructor.name}#saveCheckpoint] starting checkpoint save (allowEmpty: ${options?.allowEmpty ?? false})`,
)
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const startTime = Date.now()
await this.stageAll(this.git)
const commitArgs = options?.allowEmpty ? { "--allow-empty": null } : undefined
const result = await this.git.commit(message, commitArgs)
const isFirst = this._checkpoints.length === 0
const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
const toHash = result.commit || fromHash
this._checkpoints.push(toHash)
const duration = Date.now() - startTime
if (isFirst || result.commit) {
this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
}
if (result.commit) {
this.log(
`[${this.constructor.name}#saveCheckpoint] checkpoint saved in ${duration}ms -> ${result.commit}`,
)
return result
} else {
this.log(`[${this.constructor.name}#saveCheckpoint] found no changes to commit in ${duration}ms`)
return undefined
}
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[${this.constructor.name}#saveCheckpoint] failed to create checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async restoreCheckpoint(commitHash: string) {
try {
this.log(`[${this.constructor.name}#restoreCheckpoint] starting checkpoint restore`)
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
await this.git.reset(["--hard", commitHash])
// Remove all checkpoints after the specified commitHash.
const checkpointIndex = this._checkpoints.indexOf(commitHash)
if (checkpointIndex !== -1) {
this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
}
const duration = Date.now() - start
this.emit("restore", { type: "restore", commitHash, duration })
this.log(`[${this.constructor.name}#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[${this.constructor.name}#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}
public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const result = []
if (!from) {
from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim()
}
// Stage all changes so that untracked files appear in diff summary.
await this.stageAll(this.git)
this.log(`[${this.constructor.name}#getDiff] diffing ${to ? `${from}..${to}` : `${from}..HEAD`}`)
const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from])
const cwdPath = (await this.getShadowGitConfigWorktree(this.git)) || this.workspaceDir || ""
for (const file of files) {
const relPath = file.file
const absPath = path.join(cwdPath, relPath)
const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
let after = ""
if (to) {
after = await this.git.show([`${to}:${relPath}`]).catch(() => "")
} else {
// Check file size before reading from filesystem
try {
const stats = await fs.stat(absPath)
if (stats.size > MAX_CHECKPOINT_FILE_SIZE_BYTES) {
console.warn(
`Checkpoint file ${absPath} exceeds size limit (${stats.size} bytes > ${MAX_CHECKPOINT_FILE_SIZE_BYTES} bytes)`,
)
after = `[File too large to display: ${stats.size} bytes]`
} else {
after = await fs.readFile(absPath, "utf8")
}
} catch (err) {
after = ""
}
}
result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
}
return result
}
/**
* EventEmitter
*/
override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]) {
return super.emit(event, data)
}
override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.on(event, listener)
}
override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.off(event, listener)
}
override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void) {
return super.once(event, listener)
}
/**
* Storage
*/
public static hashWorkspaceDir(workspaceDir: string) {
return crypto.createHash("sha256").update(workspaceDir).digest("hex").toString().slice(0, 8)
}
protected static taskRepoDir({ taskId, globalStorageDir }: { taskId: string; globalStorageDir: string }) {
return path.join(globalStorageDir, "tasks", taskId, "checkpoints")
}
protected static workspaceRepoDir({
globalStorageDir,
workspaceDir,
}: {
globalStorageDir: string
workspaceDir: string
}) {
return path.join(globalStorageDir, "checkpoints", this.hashWorkspaceDir(workspaceDir))
}
public static async deleteTask({
taskId,
globalStorageDir,
workspaceDir,
}: {
taskId: string
globalStorageDir: string
workspaceDir: string
}) {
const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir })
const branchName = `roo-${taskId}`
const git = simpleGit(workspaceRepoDir)
const success = await this.deleteBranch(git, branchName)
if (success) {
console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`)
} else {
console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`)
}
}
public static async deleteBranch(git: SimpleGit, branchName: string) {
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`)
return false
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
if (currentBranch === branchName) {
const worktree = await git.getConfig("core.worktree")
try {
await git.raw(["config", "--unset", "core.worktree"])
await git.reset(["--hard"])
await git.clean("f", ["-d"])
const defaultBranch = branches.all.includes("main") ? "main" : "master"
await git.checkout([defaultBranch, "--force"])
await pWaitFor(
async () => {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
return newBranch === defaultBranch
},
{ interval: 500, timeout: 2_000 },
)
await git.branch(["-D", branchName])
return true
} catch (error) {
console.error(
`[${this.constructor.name}#deleteBranch] failed to delete branch ${branchName}: ${error instanceof Error ? error.message : String(error)}`,
)
return false
} finally {
if (worktree.value) {
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
await git.branch(["-D", branchName])
return true
}
}
}