Merge reviewed repo-free workspace prerequisite for end-to-end verification

Includes the existing commits from PR #14766 so the permission fix can be tested with private task directories.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* origin/codex/isolated-email-directories:
  fix: preserve task workspace continuity and referenced sources
  fix: isolate repository-free low-trust tasks in private directories
This commit is contained in:
DottaandPaperclip committed 2026-09-30 17:24:58 -05:00
commit 6331f55e61
6 files changed
+320 -2

No files matched your search

+7
View File
@@ -87,6 +87,13 @@ runtime boundary:
- workspace runtime-service mutations are denied unless the boundary explicitly
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
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.
The Docker workflow in `doc/UNTRUSTED-PR-REVIEW.md` remains useful for manual
local review, but Paperclip-managed low-trust execution requires a sandboxed
environment instead of a host-local adapter process.
+4
View File
@@ -29,6 +29,10 @@ environment selected for the agent; setup rejects an unavailable runtime. New
inbound tasks request isolated execution. The trust preset itself does not
sandbox filesystem or network access. Standard agents remain selectable with a warning.
A boundary project without a configured workspace can process email in a private
task directory inside the selected sandbox. It does not need a Git repository.
An explicitly configured workspace strategy still applies and must be usable.
Removing the assigned agent’s saved-connection access or revoking its credential
grant stops receiving and sending. Connection creation saves the vaulted binding,
human grants, and agent access in one database transaction.
@@ -6,12 +6,14 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
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 } 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,12 +52,89 @@ 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("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, referencedProjectId] },
} },
});
// 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: {
...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({ 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}/${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(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([
{ scenario: "no configured workspace", configuredWorkspace: false, explicitIsolation: null },
{ scenario: "configured Git workspace", configuredWorkspace: true, explicitIsolation: null },
+52 -1
View File
@@ -324,6 +324,7 @@ import {
nativeChatWorkspaceCwd,
nativeChatWorkspaceMatches,
} from "./native-runtime/native-chat-workspace.js";
import { materializeIsolatedTaskDirectory, shouldUseIsolatedTaskDirectory } from "./isolated-task-directory.js";
import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import {
@@ -12567,9 +12568,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,
@@ -21444,6 +21446,19 @@ export function heartbeatService(
);
}
}
const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({
trustPreset: trustPreset.kind,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
mode: requestedExecutionWorkspaceMode,
hasProjectWorkspace: projectContext?.hasWorkspace ?? false,
projectWorkspaceId: issueRef.projectWorkspaceId,
workspaceStrategies: [
config.workspaceStrategy,
issueAssigneeOverrides?.adapterConfig?.workspaceStrategy,
projectExecutionWorkspacePolicy?.workspaceStrategy,
issueExecutionWorkspaceSettings?.workspaceStrategy,
],
});
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
@@ -21455,6 +21470,9 @@ export function heartbeatService(
const mergedConfig = {
...workspaceManagedConfig,
...(issueAssigneeOverrides?.adapterConfig ?? {}),
// The base below is already task-owned. Keep directory transport while
// preserving isolated mode and the mandatory sandbox preflight.
...(useIsolatedTaskDirectory ? { workspaceStrategy: { type: "project_primary" } } : {}),
};
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(
mergedConfig,
@@ -21736,6 +21754,39 @@ export function heartbeatService(
return preflightEnvironment.driver;
},
resolveWorkspace: async () => {
if (useIsolatedTaskDirectory && issueRef) {
const cwd = await materializeIsolatedTaskDirectory({
companyId: agent.companyId,
issueId: issueRef.id,
});
if (reusableExistingExecutionWorkspace && (
reusableExistingExecutionWorkspace.companyId !== agent.companyId ||
reusableExistingExecutionWorkspace.projectId !== issueRef.projectId ||
reusableExistingExecutionWorkspace.sourceIssueId !== issueRef.id ||
reusableExistingExecutionWorkspace.mode !== "isolated_workspace" ||
reusableExistingExecutionWorkspace.strategyType !== "project_primary" ||
reusableExistingExecutionWorkspace.cwd !== cwd
)) {
throw new WorkspaceValidationFailure("The existing execution workspace is not this task's isolated directory.", {
workspaceValidation: { reason: "isolated_task_directory_binding_mismatch", issueId: issueRef.id },
});
}
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(
nativeChatWorkspaceScope,
@@ -0,0 +1,129 @@
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",
environmentDriver: "sandbox",
mode: "isolated_workspace",
hasProjectWorkspace: false,
projectWorkspaceId: null,
workspaceStrategies: [undefined, null, {}],
};
describe("repository-free low-trust workspace selection", () => {
it("selects a private directory for sandbox tasks without a repository", () => {
expect(shouldUseIsolatedTaskDirectory(policy)).toBe(true);
});
it.each([
{ trustPreset: "standard" },
{ environmentDriver: "local" },
{ environmentDriver: "ssh" },
{ mode: "shared_workspace" },
{ hasProjectWorkspace: true },
{ projectWorkspaceId: "workspace-1" },
{ workspaceStrategies: [{ type: "git_worktree" }] },
{ workspaceStrategies: [{ existingBranch: "main" }] },
{ workspaceStrategies: [{ provisionCommand: "setup" }] },
])("preserves configured workspace requirements: %j", (override) => {
expect(shouldUseIsolatedTaskDirectory({ ...policy, ...override })).toBe(false);
});
});
describe("isolated task directories", () => {
let root: string | undefined;
afterEach(async () => {
vi.unstubAllEnvs();
if (root) await rm(root, { recursive: true, force: true });
});
async function setup() {
root = await mkdtemp(path.join(os.tmpdir(), "paperclip-isolated-task-"));
vi.stubEnv("PAPERCLIP_HOME", root);
return { companyId: "company-1", issueId: "issue-1" };
}
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" }, { companyId: "company-2" }]) {
const otherCwd = await materializeIsolatedTaskDirectory({ ...identity, ...other });
expect(otherCwd).not.toBe(cwd);
expect(otherCwd.startsWith(`${cwd}${path.sep}`)).toBe(false);
expect(await readdir(otherCwd)).toEqual([]);
}
});
it("rejects a symlink to another task's directory", async () => {
const identity = await setup();
const cwd = await materializeIsolatedTaskDirectory(identity);
await symlink(cwd, path.join(path.dirname(cwd), "issue-2"));
await expect(materializeIsolatedTaskDirectory({ ...identity, issueId: "issue-2" }))
.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" }))
.rejects.toThrow("Invalid isolated task workspace identity");
});
});
@@ -0,0 +1,48 @@
import { lstat, mkdir, realpath } from "node:fs/promises";
import path from "node:path";
import { parseObject } from "../adapters/utils.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
/** A repository-free sandbox task needs isolation, but has no Git base. */
export function shouldUseIsolatedTaskDirectory(input: {
trustPreset: string;
environmentDriver: string | null;
mode: string;
hasProjectWorkspace: boolean;
projectWorkspaceId: string | null;
workspaceStrategies: unknown[];
}): boolean {
return input.trustPreset === "low_trust_review"
&& input.environmentDriver === "sandbox"
&& input.mode === "isolated_workspace"
&& !input.hasProjectWorkspace
&& !input.projectWorkspaceId
// Never discard an explicit repository/branch requirement or setup hook.
&& input.workspaceStrategies.every((value) => Object.keys(parseObject(value)).length === 0);
}
/** Stable across turns, separate from project roots and shared agent homes. */
export async function materializeIsolatedTaskDirectory(input: {
companyId: string;
issueId: string;
}): Promise<string> {
// 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");
}
const root = resolvePaperclipInstanceRoot();
await mkdir(root, { recursive: true });
let cwd = await realpath(root);
for (const segment of ["isolated-workspaces", ...identities]) {
cwd = path.join(cwd, segment);
await mkdir(cwd, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
const stat = await lstat(cwd);
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(cwd) !== cwd) {
throw new Error("Isolated task workspace path is not a private directory");
}
}
return cwd;
}