diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 63cabb0932..db796761f2 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -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 => { + 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(); 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. diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index ae6d433c75..0cff256464 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -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], diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index df19124bfc..868261d5d3 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -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"); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index e92366d28a..4efc06bf09 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -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>(); + + // 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(); + dirsByKey.set(key, dirs); + } + dirs.add(mapping.writablePath ?? path.posix.dirname(mapping.targetPath)); + } + } + } + + function get(scope: SandboxScope): ReadonlySet { + return dirsByKey.get(sandboxHandleCacheKey(scope)) ?? new Set(); + } + + 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[] { + 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 { @@ -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); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index d9b78d416b..f85446a83c 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -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; } /**