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:
DottaandPaperclip authored and GitHub committed 2026-09-30 18:06:07 -05:00
1 parent 1d23cb6962
commit f7e36ba3e2
6 files changed
+320 -2

No files matched your search

+7
View File
@@ -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.
+4
View File
@@ -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 },
+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 {
@@ -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;
}