diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 25636c44c3..9581e7cfa3 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -66,8 +66,8 @@ runtime boundary: grants the `runtime.manage` tool class When the task's project has no configured workspace and no layer specifies a -workspace strategy, sandbox execution uses a private directory for that company, -agent, and task. The directory persists across turns and never imports the shared +workspace strategy, sandbox execution uses a private directory for that company +and task. The directory persists across turns and reassignment and never imports the shared project directory or agent home. No Git repository is required for this case. Configured workspaces and explicit Git strategies keep their existing validation; a missing or broken checkout does not fall back to an empty directory. diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index 22c1d0e327..a619a67cef 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -8,10 +8,12 @@ import { pathToFileURL } from "node:url"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, environments, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync"; +import { buildProjectMentionHref } from "@paperclipai/shared"; import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; -import { heartbeatService, type HeartbeatEnvironmentRuntime } from "../services/heartbeat.ts"; +import { heartbeatService } from "../services/heartbeat.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; +import { environmentRuntimeService } from "../services/environment-runtime.js"; import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; const execute = vi.hoisted(() => vi.fn(async (_input: any) => ({ exitCode: 0, signal: null, timedOut: false }))); @@ -50,56 +52,87 @@ suite("task project repository provisioning", () => { afterEach(async () => { await drainHeartbeatRunsToQuiescence(db, heartbeat); setExpensiveWorkspaceGitExecutor(null); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "false"); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false, enableIsolatedWorkspacesByDefault: false, }); }); - it("gives repository-free low-trust email tasks separate directories before sandbox acquisition", async () => { + it("isolates repository-free low-trust tasks while preserving reassignment and authorized referenced projects", async () => { const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID(); + const referencedProjectId = randomUUID(), deniedProjectId = randomUUID(); + vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "true"); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(companies).values({ id: companyId, name: "Email company", issuePrefix: `E${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); await db.insert(projects).values({ id: projectId, companyId, name: "Onboarding" }); + for (const id of [referencedProjectId, deniedProjectId]) { + const source = path.join(root, companyId, id); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "reference.txt"), id); + await db.insert(projects).values({ id, companyId, name: id }); + await db.insert(projectWorkspaces).values({ id: randomUUID(), companyId, projectId: id, name: "Reference", sourceType: "local_path", cwd: source, isPrimary: true }); + } await db.insert(environments).values({ id: environmentId, name: "Email sandbox", driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } }); await db.insert(agents).values({ id: agentId, companyId, name: "Email agent", role: "engineer", status: "idle", adapterType: "codex_local", defaultEnvironmentId: environmentId, adapterConfig: {}, runtimeConfig: {}, permissions: { trustPreset: "low_trust_review", authorizationPolicy: { - trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, projectIds: [projectId] }, + trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, projectIds: [projectId, referencedProjectId] }, } }, }); - // Stop at the external provider boundary. Everything before it, including - // trust preflight, workspace validation and persistence, uses production code. - const acquireRunLease = vi.fn(async () => { throw new Error("test sandbox acquisition reached"); }); + // Exercise the real lease and workspace lifecycle with the built-in fake + // provider, executing its setup commands in a disposable filesystem root. const sandboxHeartbeat = heartbeatService(db, { environmentRuntime: { - acquireRunLease, releaseRunLeases: async () => [], - } as unknown as HeartbeatEnvironmentRuntime }); + ...environmentRuntimeService(db), + execute: async (input) => ({ + exitCode: 0, + stdout: execFileSync(input.command, input.args ?? [], { cwd: root, input: input.stdin, encoding: "utf8", env: { ...process.env, ...input.env } }), + stderr: "", signal: null, timedOut: false, + }), + } }); const directories: string[] = []; + const issueIds: string[] = []; for (let index = 0; index < 2; index += 1) { const issueId = randomUUID(); + issueIds.push(issueId); await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Incoming email", originKind: "chat_channel", status: "todo", assigneeAgentId: agentId, + description: [referencedProjectId, deniedProjectId].map((id) => `[@Reference](${buildProjectMentionHref(id)})`).join(" "), executionWorkspaceSettings: { mode: "isolated_workspace" }, }); const run = await sandboxHeartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId, projectId } }); expect(run).not.toBeNull(); await vi.waitFor(async () => { const latest = await sandboxHeartbeat.getRun(run!.id); - expect(latest?.status).toBe("failed"); - expect(latest?.error).toContain("test sandbox acquisition reached"); + expect({ status: latest?.status, error: latest?.error }).toEqual({ status: "succeeded", error: null }); }, { timeout: 15_000 }); await drainHeartbeatRunsToQuiescence(db, sandboxHeartbeat); const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.sourceIssueId, issueId)); expect(workspace).toMatchObject({ companyId, projectId, mode: "isolated_workspace", strategyType: "project_primary" }); - expect(workspace.cwd).toContain(`/isolated-workspaces/${companyId}/${agentId}/${issueId}`); - expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(0); + expect(workspace.cwd).toContain(`/isolated-workspaces/${companyId}/${issueId}`); + expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(1); + const call = execute.mock.calls.find(([input]) => input.runId === run!.id)![0]; + expect(call.context.paperclipEnvironment.driver).toBe("sandbox"); + expect(call.context.paperclipWorkspaces.map((workspace: { projectId: string }) => workspace.projectId)).toEqual([referencedProjectId]); directories.push(workspace.cwd!); await writeFile(path.join(workspace.cwd!, "email.txt"), `private email ${index}`); } - expect(acquireRunLease).toHaveBeenCalledTimes(2); expect(directories[0]).not.toBe(directories[1]); expect(await readFile(path.join(directories[0], "email.txt"), "utf8")).toBe("private email 0"); + const [originalAgent] = await db.select().from(agents).where(eq(agents.id, agentId)); + const reassignedAgentId = randomUUID(); + await db.insert(agents).values({ ...originalAgent, id: reassignedAgentId, name: "Next agent", status: "idle" }); + await db.update(issues).set({ status: "todo", assigneeAgentId: reassignedAgentId }).where(eq(issues.id, issueIds[0])); + const reassignedRun = await sandboxHeartbeat.wakeup(reassignedAgentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId: issueIds[0], projectId } }); + await vi.waitFor(async () => { + const latest = await sandboxHeartbeat.getRun(reassignedRun!.id); + expect({ status: latest?.status, error: latest?.error }).toEqual({ status: "succeeded", error: null }); + }, { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, sandboxHeartbeat); + const reassignedCall = execute.mock.calls.find(([input]) => input.runId === reassignedRun!.id)![0]; + expect(reassignedCall.context.paperclipWorkspace.cwd).toBe(directories[0]); + expect(await readFile(path.join(directories[0], "email.txt"), "utf8")).toBe("private email 0"); }, 40_000); it.each([ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index fcf79ecaf8..5c307d550a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -12557,9 +12557,10 @@ export function heartbeatService( opts?: { useProjectWorkspace?: boolean | null; executionEnvironmentDriver?: string | null; + anchorWorkspace?: ResolvedAnchorWorkspaceForRun; }, ): Promise { - const anchor = await resolveAnchorWorkspaceForRun( + const anchor = opts?.anchorWorkspace ?? await resolveAnchorWorkspaceForRun( agent, context, previousSessionParams, @@ -21744,7 +21745,6 @@ export function heartbeatService( if (useIsolatedTaskDirectory && issueRef) { const cwd = await materializeIsolatedTaskDirectory({ companyId: agent.companyId, - agentId: agent.id, issueId: issueRef.id, }); if (reusableExistingExecutionWorkspace && ( @@ -21759,20 +21759,21 @@ export function heartbeatService( workspaceValidation: { reason: "isolated_task_directory_binding_mismatch", issueId: issueRef.id }, }); } - return { - cwd, - source: "task_session" as const, - projectId: issueRef.projectId, - workspaceId: null, - repoUrl: null, - repoRef: null, - workspaceHints: [], - warnings: [], - baseCwdFallback: false, - materializationFailures: [], - additionalWorkspaces: [], - referencedProjectFailures: [], - }; + return resolveWorkspaceForRun(agent, context, previousSessionParams, { + executionEnvironmentDriver: selectedEnvironmentForConfig?.driver ?? null, + anchorWorkspace: { + cwd, + source: "task_session", + projectId: issueRef.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + workspaceHints: [], + warnings: [], + baseCwdFallback: false, + materializationFailures: [], + }, + }); } if (nativeChatWorkspaceScope && !nativeChatWorkspaceScope.projectId) { const cwd = await materializeNativeChatTaskRoot( diff --git a/server/src/services/isolated-task-directory.test.ts b/server/src/services/isolated-task-directory.test.ts index 5deaf3c86a..bd3d350ff9 100644 --- a/server/src/services/isolated-task-directory.test.ts +++ b/server/src/services/isolated-task-directory.test.ts @@ -1,8 +1,13 @@ -import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { materializeIsolatedTaskDirectory, shouldUseIsolatedTaskDirectory } from "./isolated-task-directory.js"; +import { prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, type SandboxSyncOperation } from "@paperclipai/adapter-utils/sandbox-managed-runtime"; + +const execFile = promisify(execFileCallback); const policy = { trustPreset: "low_trust_review", @@ -43,16 +48,16 @@ describe("isolated task directories", () => { async function setup() { root = await mkdtemp(path.join(os.tmpdir(), "paperclip-isolated-task-")); vi.stubEnv("PAPERCLIP_HOME", root); - return { companyId: "company-1", agentId: "agent-1", issueId: "issue-1" }; + return { companyId: "company-1", issueId: "issue-1" }; } - it("retains a task's files across turns without sharing them with another task, agent, or company", async () => { + it("retains a task's files across turns without sharing them with another task or company", async () => { const identity = await setup(); const cwd = await materializeIsolatedTaskDirectory(identity); await writeFile(path.join(cwd, "notes.txt"), "private task output"); expect(await materializeIsolatedTaskDirectory(identity)).toBe(cwd); expect(await readFile(path.join(cwd, "notes.txt"), "utf8")).toBe("private task output"); - for (const other of [{ issueId: "issue-2" }, { agentId: "agent-2" }, { companyId: "company-2" }]) { + for (const other of [{ issueId: "issue-2" }, { companyId: "company-2" }]) { const otherCwd = await materializeIsolatedTaskDirectory({ ...identity, ...other }); expect(otherCwd).not.toBe(cwd); expect(otherCwd.startsWith(`${cwd}${path.sep}`)).toBe(false); @@ -68,6 +73,54 @@ describe("isolated task directories", () => { .rejects.toThrow("not a private directory"); }); + it("round-trips sandbox output into the task directory and stages it on the next turn", async () => { + const identity = await setup(); + const cwd = await materializeIsolatedTaskDirectory(identity); + // Only provider I/O is emulated. Production archive, ignore, sync-back and + // merge logic runs against distinct host and remote filesystem roots. + const transfer = async (operations: SandboxSyncOperation[]) => ({ + operations: await Promise.all(operations.map(async (operation) => { + for (const file of operation.files) { + await mkdir(path.dirname(file.targetPath), { recursive: true }); + await cp(file.sourcePath, file.targetPath, { recursive: true, dereference: file.followSymlinks ?? false }); + } + for (const command of operation.postUploadCommands ?? []) { + await execFile("sh", ["-c", command.command]); + } + return { operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred: 0 }; + })), + }); + const client: SandboxManagedRuntimeClient = { + makeDir: async (target) => { await mkdir(target, { recursive: true }); }, + writeFile: async (target, bytes) => { await writeFile(target, Buffer.from(bytes)); }, + readFile: async (target) => readFile(target), + listFiles: async (target) => readdir(target), + remove: async (target) => { await rm(target, { recursive: true, force: true }); }, + run: async (command) => { await execFile("sh", ["-c", command]); }, + syncIn: transfer, + syncOut: transfer, + }; + for (let turn = 0; turn < 2; turn += 1) { + const remoteCwd = path.join(root!, `sandbox-${turn}`); + const runtime = await prepareSandboxManagedRuntime({ + spec: { transport: "sandbox", provider: "test", sandboxId: `turn-${turn}`, remoteCwd, timeoutMs: 30_000, apiKey: null }, + adapterKey: "test", + client, + workspaceLocalDir: cwd, + }); + if (turn === 0) { + await writeFile(path.join(remoteCwd, "result.txt"), "created in sandbox"); + } else { + expect(await readFile(path.join(remoteCwd, "result.txt"), "utf8")).toBe("created in sandbox"); + await writeFile(path.join(remoteCwd, "result.txt"), "continued in a new sandbox"); + } + await runtime.restoreWorkspace(); + } + expect(await readFile(path.join(cwd, "result.txt"), "utf8")).toBe("continued in a new sandbox"); + const other = await materializeIsolatedTaskDirectory({ ...identity, issueId: "issue-2" }); + expect(await readdir(other)).toEqual([]); + }); + it("rejects path traversal identities", async () => { const identity = await setup(); await expect(materializeIsolatedTaskDirectory({ ...identity, issueId: "../outside" })) diff --git a/server/src/services/isolated-task-directory.ts b/server/src/services/isolated-task-directory.ts index 26496b29e1..be34c930c8 100644 --- a/server/src/services/isolated-task-directory.ts +++ b/server/src/services/isolated-task-directory.ts @@ -24,10 +24,10 @@ export function shouldUseIsolatedTaskDirectory(input: { /** Stable across turns, separate from project roots and shared agent homes. */ export async function materializeIsolatedTaskDirectory(input: { companyId: string; - agentId: string; issueId: string; }): Promise { - const identities = [input.companyId, input.agentId, input.issueId]; + // Files belong to the task, so reassignment preserves the same workspace. + const identities = [input.companyId, input.issueId]; if (identities.some((value) => !/^[a-zA-Z0-9_-]+$/.test(value))) { throw new Error("Invalid isolated task workspace identity"); }