Files
PaperClipAI/server/src/services/isolated-task-directory.test.ts
T
DottaandPaperclip f7e36ba3e2 fix: isolate repository-free low-trust tasks in private directories (#14766)
## Thinking Path

> - Paperclip manages work by agents within company boundaries.
> - Email tasks can run under the low-trust review preset.
> - These tasks must use an isolated workspace and a sandbox.
> - The default workspace strategy assumed that the project had a Git
repository.
> - A project without a configured workspace failed before the agent
could start.
> - This change gives each such task a private directory and keeps the
sandbox requirement.

## Linked Issues or Issue Description

**What happened?**
An inbound email assigned to a low-trust agent failed with
`git_worktree_base_not_git_checkout` when its boundary project had no
configured workspace. Setup had accepted the project and sandbox.

**Expected behavior**
The agent can process email without a repository. Its workspace stays
isolated from other tasks and the shared agent home.

**Steps to reproduce**
1. Select a low-trust agent with an active sandbox and a project
boundary.
2. Leave the project without a configured workspace.
3. Receive an email through AgentMail.
4. Observe that startup fails before provider work starts.

**Paperclip version or commit**
Reproduced against `5edf55d73`.

**Deployment mode**
Hosted staging with sandbox execution.

Related: #13256 added email tasks. #13636 fixed default isolation for
projects without workspaces; the explicit isolation used by low-trust
tasks still needed this path.

## What Changed

- Select private task directories for low-trust sandbox tasks with no
configured workspace or explicit workspace strategy.
- Keep each directory scoped to its company and task. Retain files
across turns and reassignment and reject symlink paths and mismatched
workspace reuse.
- Preserve Git validation for configured workspaces and explicit
strategies, plus the existing authorization and remote gates for
referenced projects.
- Add a startup regression and directory isolation tests. Document the
supported repository-free path.

## Verification

- The startup regression failed before the fix with the same Git
validation error.
- 260 targeted email, workspace policy, heartbeat, referenced-project
and directory tests pass.
- Full `pnpm -r typecheck` and `pnpm build` pass on the latest commit.
- All CI checks, including the complete sharded test suite and canary
dry run, pass on `b4ccd9802b09b2e95499df72d48b4a3906b8c328`.
- The final commit also passes the same server shard locally: 60 files,
1,024 passed / 6 skipped tests. The earlier all-groups local run was
interrupted during follow-up edits; complete-suite verification comes
from CI on the final commit.
- Deployed the reviewed commit to staging and independently verified the
full serving SHA. Two real Codex runs in Daytona succeeded and finalized
the same private company/task workspace. The first wrote a 35-byte
marker; the second read the existing file without modifying it and
returned the independently verified SHA-256
`ce3bbeb44d07ca6822826d3a5945752a38d30b356d10829f3159a191e5aa92a6`.
- Live runtime caveat: Codex reported a nested `bwrap` loopback
permission error and used its configured escalated execution inside
Daytona. The outer Daytona sandbox remained active for both runs.
- The startup regression uses a real database, production trust checks,
workspace persistence, sandbox lease acquisition and realization, and a
fake provider. It checks reassignment and allows only an authorized
referenced project.
- The transfer regression runs production archive/sync-back/merge code
against distinct filesystem roots: create output in one sandbox, restore
it, then read and update it in a fresh sandbox. The provider I/O is
emulated; live staging verification is separate.

## Risks

- The new default applies only to low-trust sandbox tasks without
workspace configuration. Standard agents and explicit Git strategies
keep their existing behavior.
- Task directories retain work across turns and consume instance
storage. The change does not migrate or copy existing shared files.
- No database migration or credential changes are required.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
execution, and browser tools. The exact runtime model revision and
context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-30 18:06:07 -05:00

130 lines
5.9 KiB
TypeScript

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");
});
});