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:
parent
5ec7ce76e5
commit
5cffd5c72e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue