fix: preserve task workspace continuity and referenced sources

Keep repository-free directories scoped to the task across reassignment. Resolve authorized referenced projects through the normal gates and exercise the real workspace lifecycle plus sandbox file transfer across turns.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 16:31:00 -05:00
1 parent e4495f93b6
commit b4ccd9802b
5 files changed
+124 -37

No files matched your search

+2 -2
View File
@@ -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.
@@ -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([
+17 -16
View File
@@ -12557,9 +12557,10 @@ export function heartbeatService(
opts?: {
useProjectWorkspace?: boolean | null;
executionEnvironmentDriver?: string | null;
anchorWorkspace?: ResolvedAnchorWorkspaceForRun;
},
): Promise<ResolvedWorkspaceForRun> {
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(
@@ -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" }))
@@ -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<string> {
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");
}