From 2a56f4134e72920746ab8fe8ce8bdb8a7c59369d Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 10:29:44 -0500 Subject: [PATCH] Harden workspace cleanup and clone env handling --- server/src/services/heartbeat.ts | 5 ++++- server/src/services/workspace-runtime.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 43c5c05f..31905a5f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -36,6 +36,7 @@ import { persistAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; @@ -61,6 +62,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; +const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; const execFile = promisify(execFileCallback); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", @@ -125,7 +127,8 @@ async function ensureManagedProjectWorkspace(input: { try { await execFile("git", ["clone", input.repoUrl, cwd], { - env: process.env, + env: sanitizeRuntimeServiceBaseEnv(process.env), + timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, }); return { cwd, warning: null }; } catch (error) { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 132999dc..7cb780ce 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -94,7 +94,7 @@ function stableStringify(value: unknown): string { return JSON.stringify(value); } -function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { if (key.startsWith("PAPERCLIP_")) { @@ -504,7 +504,7 @@ function buildExecutionWorkspaceCleanupEnv(input: { }; projectWorkspaceCwd?: string | null; }) { - const env: NodeJS.ProcessEnv = { ...process.env }; + const env: NodeJS.ProcessEnv = sanitizeRuntimeServiceBaseEnv(process.env); env.PAPERCLIP_WORKSPACE_CWD = input.workspace.cwd ?? ""; env.PAPERCLIP_WORKSPACE_PATH = input.workspace.cwd ?? ""; env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = @@ -796,8 +796,14 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { } else if (input.workspace.providerType === "local_fs" && createdByRuntime && workspacePath) { const projectWorkspaceCwd = input.projectWorkspace?.cwd ? path.resolve(input.projectWorkspace.cwd) : null; const resolvedWorkspacePath = path.resolve(workspacePath); - if (projectWorkspaceCwd && resolvedWorkspacePath === projectWorkspaceCwd) { - warnings.push(`Refusing to remove shared project workspace "${workspacePath}".`); + const containsProjectWorkspace = projectWorkspaceCwd + ? ( + resolvedWorkspacePath === projectWorkspaceCwd || + projectWorkspaceCwd.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) + : false; + if (containsProjectWorkspace) { + warnings.push(`Refusing to remove path "${workspacePath}" because it contains the project workspace.`); } else { await fs.rm(resolvedWorkspacePath, { recursive: true, force: true }); if (input.recorder) {