feat(sandbox): declare and collect the advisory read-write intent (#10521)

## Thinking Path

> - Paperclip is the control plane for AI agent companies
> - Sandbox providers move files between the host and the sandbox
> - The runtime needs advisory data for which sync paths are writable
> - The host already knows that intent when it prepares the sync map
> - Daytona can record that intent for later use
> - This pull request adds the advisory access field and collects
writable paths
> - The benefit is that later runtime work can use the data without
changing current flow

## Linked Issues or Issue Description

No public GitHub issue exists for this change. The description below
follows the feature-request template fields.

### Problem or motivation
An agent runs inside an ephemeral sandbox. Some sandbox paths keep agent
changes; other paths do not. Today the runtime has no declared signal
for which sync destinations the agent may change and keep. A later
feedback wrapper needs this signal to give the agent real-time feedback
when a write lands on a non-persistent path.

### Proposed solution
Add an optional advisory `access: "rw" | "ro"` field to the sync
file-mapping types. The host sets `rw` for the workspace, git-history,
and asset destinations, and `ro` for referenced-project trees. An absent
value defaults to `ro`. The Daytona provider collects the `rw` target
directories into a per-lease writable set for later use. This change
adds the metadata and the collection only. No execution path reads the
writable set yet, so runtime behavior does not change.

### Alternatives considered
Derive a static writable set in provider code. This alternative is
weaker: the layer that authors each sync destination already knows the
intent, so a per-operation declaration is more accurate and does not
hard-code a path list.

### Roadmap alignment
This is the first step toward an advisory sandbox feedback wrapper. The
wrapper is best-effort and adds no security. The ephemeral sandbox stays
the only boundary.

### Additional context
The field is advisory metadata. It does not change the file transfer and
adds no security.

## What Changed

- Added an optional `access` field to the sync file mapping types.
- Set the workspace, git history, and asset destinations to writable.
- Set referenced project destinations to read only.
- Added a writable set store in the Daytona provider.
- Recorded the parent directory of each writable sync mapping during
sync in.

## Verification

- The handoff reports these checks before push.
- `pnpm --filter @paperclipai/adapter-utils exec vitest run
sandbox-managed-runtime.test.ts`
- `pnpm --filter @paperclipai/plugin-sdk exec tsc --noEmit`
- `vitest run src/plugin.test.ts` in the Daytona package
- `tsc --noEmit` in the Daytona package

## Risks

- Low risk.
- The new field is advisory.
- The writable set store is best effort and in memory.
- A cold store falls back to the workspace baseline.

## Model Used

OpenAI Codex, GPT-5, tool use, code execution.

## 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 linked existing issues or described the issue in the
PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-30 21:20:13 -07:00 committed by GitHub
parent 5ec7ce76e5
commit 5cffd5c72e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 423 additions and 3 deletions

View File

@ -93,6 +93,41 @@ function attachNativeRecordingSyncIn(
};
}
// A capturing `syncIn` that records every operation for assertion and
// materializes file AND directory mappings. A directory mapping (a referenced
// project) uses `mirrorDirectory`, so a test can assert the advisory `access`
// intent on directory mappings as well as file mappings.
function attachCapturingSyncIn(
client: SandboxManagedRuntimeClient,
captured: SandboxSyncOperation[],
): void {
client.syncIn = async (operations: SandboxSyncOperation[]): Promise<SandboxSyncResult> => {
const resultOperations: SandboxSyncResult["operations"] = [];
for (const operation of operations) {
captured.push(operation);
let filesTransferred = 0;
let bytesTransferred = 0;
for (const mapping of operation.files) {
await mkdir(path.posix.dirname(mapping.targetPath), { recursive: true });
if (mapping.kind === "directory") {
await mirrorDirectory(mapping.sourcePath, mapping.targetPath);
} else {
const bytes = await readFile(mapping.sourcePath);
await writeFile(mapping.targetPath, bytes);
if (mapping.mode != null) await fsPromises.chmod(mapping.targetPath, mapping.mode);
bytesTransferred += bytes.byteLength;
}
filesTransferred += 1;
}
for (const command of operation.postUploadCommands ?? []) {
await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 });
}
resultOperations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations: resultOperations };
};
}
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
@ -1282,6 +1317,18 @@ describe("sandbox managed runtime", () => {
`rm -rf ${q(path.posix.join(prepared.runtimeRootDir, "widget"))} && mkdir -p ${q(path.posix.join(prepared.runtimeRootDir, "widget"))}`,
);
// The asset tar carries the asset directory as its read-write destination,
// because the extract command fills that directory, not the staging archive.
const assetTarMapping = assetOp!.files.find((mapping) => mapping.targetPath.endsWith("widget-upload.tar"));
expect(assetTarMapping?.access).toBe("rw");
expect(assetTarMapping?.writablePath).toBe(prepared.assetDirs.widget);
// The staged helper file is a read-only input that the command consumes, so
// it is `access: "ro"` and never joins the writable set.
const stageMapping = assetOp!.files.find((mapping) => mapping.targetPath.endsWith("widget-helper.sh"));
expect(stageMapping?.access).toBe("ro");
expect(stageMapping?.writablePath).toBeUndefined();
// The asset actually materialized through the native seam.
await expect(readFile(path.join(prepared.assetDirs.widget, "seed.txt"), "utf8")).resolves.toBe("seed\n");
});
@ -1643,6 +1690,137 @@ describe("sandbox managed runtime", () => {
.resolves.toBe("{\"OPENAI_API_KEY\":\"sk-test\"}\n");
});
it("authors the advisory access intent rw on workspace, git, and asset inbound mappings", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-access-rw-"));
cleanupDirs.push(rootDir);
const sourceRepoDir = path.join(rootDir, "source-repo");
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const assetDir = path.join(rootDir, "asset-home");
// A git-backed workspace produces both a git-history tar and an overlay tar.
await mkdir(sourceRepoDir, { recursive: true });
await git(sourceRepoDir, ["init"]);
await git(sourceRepoDir, ["checkout", "-b", "main"]);
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(sourceRepoDir, "tracked.txt"), "tracked\n", "utf8");
await git(sourceRepoDir, ["add", "tracked.txt"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
await mkdir(assetDir, { recursive: true });
await writeFile(path.join(assetDir, "config.toml"), "model = \"gpt\"\n", "utf8");
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); },
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); },
run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); },
};
const captured: SandboxSyncOperation[] = [];
attachCapturingSyncIn(client, captured);
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "home", localDir: assetDir }],
});
const findMapping = (base: string) =>
captured
.flatMap((op) => op.files)
.find((mapping) => path.posix.basename(mapping.targetPath) === base);
// The workspace, git-history, and asset destinations receive read-write bytes,
// so the author marks each mapping `access: "rw"`.
expect(findMapping("workspace-upload.tar")?.access).toBe("rw");
expect(findMapping("git-workspace-upload.tar")?.access).toBe("rw");
expect(findMapping("home-upload.tar")?.access).toBe("rw");
// Each tar mapping uploads a staging archive under the runtime root, so its
// `targetPath` is not the read-write destination. `writablePath` names the
// directory that the post-upload extract command fills: the workspace
// directory for the workspace and git tars, and the asset directory for the
// asset tar.
const remoteAssetDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter", "home");
expect(findMapping("workspace-upload.tar")?.writablePath).toBe(remoteWorkspaceDir);
expect(findMapping("git-workspace-upload.tar")?.writablePath).toBe(remoteWorkspaceDir);
expect(findMapping("home-upload.tar")?.writablePath).toBe(remoteAssetDir);
});
it("authors the advisory access intent ro on referenced-project inbound mappings", async () => {
const flagKey = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC";
const priorFlag = process.env[flagKey];
process.env[flagKey] = "1";
try {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-access-ro-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const referencedDir = path.join(rootDir, "referenced-project");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(referencedDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
await writeFile(path.join(referencedDir, "notes.md"), "reference\n", "utf8");
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); },
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); },
run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); },
};
const captured: SandboxSyncOperation[] = [];
attachCapturingSyncIn(client, captured);
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
additionalSources: [{ localPath: referencedDir, projectId: "proj-first" }],
});
const referencedMapping = captured
.flatMap((op) => op.files)
.find((mapping) => path.posix.basename(mapping.targetPath) === "project-proj-first");
// A referenced project is a read-only tree, so the author marks it `access: "ro"`.
expect(referencedMapping).toBeDefined();
expect(referencedMapping?.kind).toBe("directory");
expect(referencedMapping?.access).toBe("ro");
} finally {
if (priorFlag === undefined) delete process.env[flagKey];
else process.env[flagKey] = priorFlag;
}
});
it("keeps the sandbox runtime core free of Codex-specific string literals", async () => {
const coreSource = await readFile(new URL("./sandbox-managed-runtime.ts", import.meta.url), "utf8");
// The seam must be generic: no adapter (Codex) knowledge may live in the core.

View File

@ -161,6 +161,24 @@ export interface SandboxSyncFileMapping {
mode?: number;
exclude?: string[];
followSymlinks?: boolean;
/**
* Advisory read-write intent for the sandbox target. `"rw"` marks a target the
* agent may change and keep; `"ro"` marks a read-only tree. An absent value
* defaults to `"ro"` (read-only is the safe default for an advisory signal).
* The field is advisory metadata for an optional sandbox feedback wrapper. It
* does not change the transfer and adds no security.
*/
access?: "rw" | "ro";
/**
* The sandbox directory that becomes read-write when `access` is `"rw"` and a
* post-upload command extracts `targetPath` into a different directory. A tar
* mapping uploads an archive under the runtime root, so its `targetPath` is the
* staging archive, not the directory the extract command fills. This field
* names that final destination directory. When absent, the read-write
* destination is the parent directory of `targetPath`. Advisory; ignored when
* `access` is not `"rw"`.
*/
writablePath?: string;
}
/**
@ -816,7 +834,7 @@ export async function prepareSandboxManagedRuntime(input: {
exclude: [".paperclip-runtime"],
});
});
workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file" });
workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
@ -847,7 +865,7 @@ export async function prepareSandboxManagedRuntime(input: {
exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
});
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file" });
workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
@ -898,7 +916,7 @@ export async function prepareSandboxManagedRuntime(input: {
exclude: asset.exclude,
});
const files: SandboxSyncFileMapping[] = [
{ sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file" },
{ sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir },
];
// Stage provision helper files (e.g. the merge scripts) into the temp dir
// and map them alongside the asset tar so they ride the same native upload.
@ -912,10 +930,14 @@ export async function prepareSandboxManagedRuntime(input: {
: stageFile.contents;
const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`);
await fs.writeFile(stageHostPath, stageBytes);
// A stage helper file (for example a merge script) is a read-only input
// that the provision command reads; the agent does not change it and does
// not keep it. So it is `access: "ro"` and never joins the writable set.
files.push({
sourcePath: stageHostPath,
targetPath: path.posix.join(runtimeRootDir, safeName),
kind: "file",
access: "ro",
});
}
const postUploadCommand = asset.provision?.postUploadCommand?.({
@ -967,6 +989,7 @@ export async function prepareSandboxManagedRuntime(input: {
targetPath: remoteProjectDir,
kind: "directory",
exclude: additionalSourceExclude,
access: "ro",
}],
sourceRoots: [localPath],
targetRoots: [remoteProjectDir],

View File

@ -32,6 +32,7 @@ import plugin, {
setDaytonaTimingClockForTest,
setDaytonaHandleFreshnessClockForTest,
__resetDaytonaSandboxHandleCacheForTest,
__getDaytonaWritableDirsForTest,
} from "./plugin.js";
import manifest from "./manifest.js";
@ -2200,6 +2201,124 @@ describe("daytona native file-sync hooks", () => {
expect(plugin.definition.onEnvironmentSyncOut).toBeTypeOf("function");
});
it("records the writablePath destination of a staging-tar rw mapping, not the staging parent", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "workspace.tar");
await fs.writeFile(source, "bytes");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const params = {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-rw",
files: [
{
// The mapping uploads a staging tar under the runtime root, and a
// post-upload command extracts it into the workspace directory. So
// `writablePath` names the real read-write destination.
sourcePath: source,
targetPath: `${REMOTE_DIR}/.paperclip-runtime/workspace-upload.tar`,
kind: "file" as const,
access: "rw" as const,
writablePath: REMOTE_DIR,
},
],
},
],
};
await plugin.definition.onEnvironmentSyncIn?.(params);
// The set holds the extract destination, not the staging archive parent.
const recorded = __getDaytonaWritableDirsForTest(params);
expect(recorded).toContain(REMOTE_DIR);
expect(recorded).not.toContain(`${REMOTE_DIR}/.paperclip-runtime`);
});
it("falls back to the parent directory of an rw mapping with no writablePath", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "in-place.txt");
await fs.writeFile(source, "bytes");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const params = {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-rw-inplace",
files: [
{
// No post-upload extract, so the mapping writes `targetPath` in
// place and the parent directory is the read-write destination.
sourcePath: source,
targetPath: `${REMOTE_DIR}/data/in-place.txt`,
kind: "file" as const,
access: "rw" as const,
},
],
},
],
};
await plugin.definition.onEnvironmentSyncIn?.(params);
expect(__getDaytonaWritableDirsForTest(params)).toContain(`${REMOTE_DIR}/data`);
});
it("skips ro and access-absent sync targets in the advisory writable set", async () => {
const hostDir = await makeHostDir();
const roSource = path.join(hostDir, "referenced");
const defaultSource = path.join(hostDir, "default.tar");
await fs.mkdir(roSource, { recursive: true });
await fs.writeFile(path.join(roSource, "notes.md"), "reference");
await fs.writeFile(defaultSource, "bytes");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const params = {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-ro",
files: [
{
sourcePath: roSource,
targetPath: `${REMOTE_DIR}/.paperclip-runtime/project-proj-first`,
kind: "directory" as const,
access: "ro" as const,
},
{
// An absent `access` defaults to read-only, so it is not recorded.
sourcePath: defaultSource,
targetPath: `${REMOTE_DIR}/.paperclip-runtime/default-upload.tar`,
kind: "file" as const,
},
],
},
],
};
await plugin.definition.onEnvironmentSyncIn?.(params);
// Neither the ro directory nor the access-absent file directory is recorded.
expect(__getDaytonaWritableDirsForTest(params)).toEqual([]);
});
it("syncIn coalesces file mappings into one uploadFiles batch to reserved temp destinations, then one batched mv, applying secret mode via setFilePermissions before the rename", async () => {
const hostDir = await makeHostDir();
const secretSource = path.join(hostDir, "auth.json");

View File

@ -36,6 +36,7 @@ import type {
PluginEnvironmentSyncResult,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
PluginSyncOperation,
} from "@paperclipai/plugin-sdk";
import { performSyncIn, performSyncOut } from "./file-sync.js";
@ -1096,6 +1097,55 @@ const sandboxHandleCache = (() => {
return { get, seed, clear, reset, markFresh };
})();
// Advisory writable-set store. It holds, per lease scope, the sandbox
// directories that a sync operation declared read-write (`access: "rw"`). An
// optional sandbox feedback wrapper reads this set later to bind those
// directories read-write, so an agent gets real-time feedback when a write to a
// non-persistent path fails. The store is advisory and best-effort in-memory
// state: it adds no security (the ephemeral sandbox stays the only boundary),
// and a cold store (for example after a worker restart) degrades to the
// workspace baseline, never to a crash. The store is keyed the same way as
// `sandboxHandleCache`, by `sandboxHandleCacheKey(scope)`.
const sandboxHandleWritableDirs = (() => {
const dirsByKey = new Map<string, Set<string>>();
// Record the read-write destination directory of every `access: "rw"`
// mapping. Skip read-only mappings (`access` absent or `"ro"`). Read-only is
// the safe default for an advisory signal.
//
// A workspace, git-history, or asset mapping uploads a tar archive, so its
// `targetPath` is the staging archive under the runtime root, not the directory
// that the post-upload extract command fills. For those mappings the author
// sets `writablePath` to the final destination directory, so this records the
// real read-write destination, not the staging parent. When `writablePath` is
// absent the mapping writes `targetPath` in place, so the parent directory of
// `targetPath` is the destination.
function recordWritableTargets(scope: SandboxScope, operations: PluginSyncOperation[]): void {
const key = sandboxHandleCacheKey(scope);
for (const operation of operations) {
for (const mapping of operation.files) {
if (mapping.access !== "rw") continue;
let dirs = dirsByKey.get(key);
if (!dirs) {
dirs = new Set<string>();
dirsByKey.set(key, dirs);
}
dirs.add(mapping.writablePath ?? path.posix.dirname(mapping.targetPath));
}
}
}
function get(scope: SandboxScope): ReadonlySet<string> {
return dirsByKey.get(sandboxHandleCacheKey(scope)) ?? new Set<string>();
}
function reset(): void {
dirsByKey.clear();
}
return { recordWritableTargets, get, reset };
})();
/**
* Test seam: clear the process-scoped handle cache between tests so a handle
* memoized under a reused composite key in one test never leaks into the next.
@ -1106,6 +1156,29 @@ export function __resetDaytonaSandboxHandleCacheForTest(): void {
sandboxHandleTeardownGates.reset();
sandboxHandleActivityGates.reset();
sandboxHandleLeaseAdmissionStates.reset();
sandboxHandleWritableDirs.reset();
}
/**
* Test seam: read the advisory writable directories recorded for a sync scope.
* The caller passes the same `onEnvironmentSyncIn` inputs, so this rebuilds the
* exact scope key the hook used. Not used in production.
*/
export function __getDaytonaWritableDirsForTest(input: {
driverKey: string;
companyId: string;
environmentId: string;
lease: { providerLeaseId?: string | null };
config: Record<string, unknown>;
}): string[] {
const scope: SandboxScope = {
driverKey: input.driverKey,
companyId: input.companyId,
environmentId: input.environmentId,
providerLeaseId: input.lease.providerLeaseId ?? "",
config: parseDriverConfig(input.config),
};
return [...sandboxHandleWritableDirs.get(scope)];
}
async function getSandbox(scope: SandboxScope, options: SandboxLookupOptions = {}): Promise<Sandbox> {
@ -1874,6 +1947,9 @@ const plugin = definePlugin({
providerLeaseId: params.lease.providerLeaseId,
config,
};
// Collect the advisory read-write destinations for this scope. This records
// intent only; it does not change the transfer below.
sandboxHandleWritableDirs.recordWritableTargets(scope, params.operations);
return await withSandboxActivityGate(scope, async () => {
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
await ensureSandboxStarted(sandbox, timeoutSeconds);

View File

@ -691,6 +691,30 @@ export interface PluginSyncFileMapping {
* as links; `true` dereferences them to their target bytes. Mirrors tar's `-h`.
*/
followSymlinks?: boolean;
/**
* Advisory read-write intent for the sandbox target. `"rw"` means the author
* expects the agent to change the bytes at the target and keep the change.
* `"ro"` means the target is a read-only tree. An absent value defaults to
* `"ro"` (read-only is the safe default for an advisory signal).
*
* This field is advisory metadata for an optional sandbox feedback wrapper. It
* does not change the transfer and adds no security. A provider may read it to
* bind the read-write targets read-write under the wrapper, but the ephemeral
* sandbox stays the only security boundary.
*/
access?: "rw" | "ro";
/**
* The sandbox directory that becomes read-write when `access` is `"rw"` and a
* post-upload command extracts `targetPath` into a different directory. A
* workspace, git-history, or asset mapping uploads a tar archive, so its
* `targetPath` is the staging archive under the runtime root, not the directory
* that the extract command fills. This field names that final destination
* directory, so a consumer records the real read-write destination, not the
* staging parent. When absent, the read-write destination is the parent
* directory of `targetPath`. This field is advisory and ignored when `access`
* is not `"rw"`.
*/
writablePath?: string;
}
/**