import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { mirrorDirectory, prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, } from "./sandbox-managed-runtime.js"; const execFile = promisify(execFileCallback); describe("sandbox managed runtime", () => { const cleanupDirs: string[] = []; afterEach(async () => { while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (!dir) continue; await rm(dir, { recursive: true, force: true }).catch(() => undefined); } }); it("preserves excluded local workspace artifacts during restore mirroring", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-restore-")); cleanupDirs.push(rootDir); const sourceDir = path.join(rootDir, "source"); const targetDir = path.join(rootDir, "target"); await mkdir(path.join(sourceDir, "src"), { recursive: true }); await mkdir(path.join(targetDir, ".claude"), { recursive: true }); await mkdir(path.join(targetDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(sourceDir, "src", "app.ts"), "export const value = 2;\n", "utf8"); await writeFile(path.join(targetDir, "stale.txt"), "remove me\n", "utf8"); await writeFile(path.join(targetDir, ".claude", "settings.json"), "{\"keep\":true}\n", "utf8"); await writeFile(path.join(targetDir, ".claude.json"), "{\"keep\":true}\n", "utf8"); await writeFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8"); await mirrorDirectory(sourceDir, targetDir, { preserveAbsent: [".paperclip-runtime", ".claude", ".claude.json"], }); await expect(readFile(path.join(targetDir, "src", "app.ts"), "utf8")).resolves.toBe("export const value = 2;\n"); await expect(readFile(path.join(targetDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"keep\":true}\n"); await expect(readFile(path.join(targetDir, ".claude.json"), "utf8")).resolves.toBe("{\"keep\":true}\n"); await expect(readFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n"); await expect(readFile(path.join(targetDir, "stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); it("syncs workspace and assets through a provider-neutral sandbox client", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-managed-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const localAssetsDir = path.join(rootDir, "local-assets"); const linkedAssetPath = path.join(rootDir, "linked-skill.md"); await mkdir(path.join(localWorkspaceDir, ".claude"), { recursive: true }); await mkdir(localAssetsDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8"); await writeFile(path.join(localWorkspaceDir, "._README.md"), "appledouble\n", "utf8"); await writeFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "{\"local\":true}\n", "utf8"); await writeFile(linkedAssetPath, "skill body\n", "utf8"); await symlink(linkedAssetPath, path.join(localAssetsDir, "skill.md")); 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), remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, run: async (command) => { await execFile("sh", ["-lc", command], { maxBuffer: 32 * 1024 * 1024, }); }, }; const prepared = await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, workspaceExclude: [".claude"], preserveAbsentOnRestore: [".claude"], assets: [{ key: "skills", localDir: localAssetsDir, followSymlinks: true, }], }); await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n"); await expect(readFile(path.join(remoteWorkspaceDir, "._README.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(remoteWorkspaceDir, ".claude", "settings.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(prepared.assetDirs.skills, "skill.md"), "utf8")).resolves.toBe("skill body\n"); expect((await lstat(path.join(prepared.assetDirs.skills, "skill.md"))).isFile()).toBe(true); await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8"); await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "sync back\n", "utf8"); await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8"); await writeFile(path.join(localWorkspaceDir, "local-stale.txt"), "remove\n", "utf8"); await prepared.restoreWorkspace(); await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n"); await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n"); await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n"); await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n"); }); });