mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
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:
1 parent
e4495f93b6
commit
b4ccd9802b
5 files changed
+124
-37
No files matched your search
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user