mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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>
This commit is contained in:
1 parent
1d23cb6962
commit
f7e36ba3e2
6 files changed
+320
-2
No files matched your search
@@ -65,6 +65,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.
|
||||
@@ -38,6 +38,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 },
|
||||
|
||||
@@ -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 {
|
||||
@@ -12574,9 +12575,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,
|
||||
@@ -21451,6 +21453,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,
|
||||
@@ -21462,6 +21477,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,
|
||||
@@ -21743,6 +21761,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;
|
||||
}
|
||||
Reference in new issue
Block a user