mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
fix(workspaces): omit an untracked Python virtual environment from workspace snapshots (#19274)
`python -m venv` links its interpreter by absolute path. The portable workspace archive rejects absolute symlinks, so a workspace with an untracked .venv could never be saved and the task blocked with native_workspace_sync_out_retry_exhausted. Treat an untracked directory with a regular PEP 405 pyvenv.cfg at its root like an untracked node_modules: record it as excluded and do not archive it. Tracked environments are still archived; other absolute links are still rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10a7d039c8
commit
99c68455e6
2 files changed
+77
-3
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureRemoteWorkspaceDirectory, remoteWorkspaceArchiveScript, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_AUTHOR_NAME: "Snapshot", GIT_COMMITTER_NAME: "Snapshot", GIT_AUTHOR_EMAIL: "snapshot@example.test", GIT_COMMITTER_EMAIL: "snapshot@example.test" };
|
||||
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env })).stdout.trim();
|
||||
|
||||
describe("reinstallable environments in portable workspace archives", () => {
|
||||
let root: string;
|
||||
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-environment-test-")); });
|
||||
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
|
||||
async function source() { const cwd = path.join(root, "source"); await fs.mkdir(cwd); return cwd; }
|
||||
async function capture(cwd: string, mode: "local" | "serialized" = "local") {
|
||||
const archivePath = path.join(root, "snapshot.pcws");
|
||||
if (mode === "local") return { archivePath, ...await captureRemoteWorkspaceDirectory({ cwd, archivePath }) };
|
||||
// The sandbox runs this generated helper in its own Node process.
|
||||
const helper = path.join(root, "helper.cjs");
|
||||
await fs.writeFile(helper, remoteWorkspaceArchiveScript());
|
||||
return { archivePath, ...JSON.parse((await execute(process.execPath, [helper, "capture", cwd, archivePath])).stdout) };
|
||||
}
|
||||
async function restore(snapshot: { archivePath: string; manifestDigest: string }) {
|
||||
const targetDir = path.join(root, "restored");
|
||||
await restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir, expectedManifestDigest: snapshot.manifestDigest });
|
||||
return targetDir;
|
||||
}
|
||||
async function excluded(archivePath: string) {
|
||||
const archive = await fs.readFile(archivePath), start = Buffer.from("PCWS1\n").length, length = archive.readUInt32BE(start);
|
||||
return JSON.parse(archive.subarray(start + 4, start + 4 + length).toString()).excluded;
|
||||
}
|
||||
|
||||
it.each(["local", "serialized"] as const)("omits an untracked Python virtual environment and its interpreter links in %s capture", async mode => {
|
||||
const cwd = await source(), venv = path.join(cwd, ".venv");
|
||||
await fs.writeFile(path.join(cwd, "semver.py"), "import re\n");
|
||||
await fs.mkdir(path.join(venv, "bin"), { recursive: true });
|
||||
await fs.writeFile(path.join(venv, "pyvenv.cfg"), "home = /usr/bin\n");
|
||||
// `python -m venv` links the interpreter by absolute path.
|
||||
await fs.symlink(process.execPath, path.join(venv, "bin", "python"));
|
||||
const snapshot = await capture(cwd, mode);
|
||||
expect(await fs.readdir(await restore(snapshot))).toEqual(["semver.py"]);
|
||||
expect(await excluded(snapshot.archivePath)).toEqual([".venv"]);
|
||||
expect(await fs.readlink(path.join(venv, "bin", "python"))).toBe(process.execPath);
|
||||
});
|
||||
|
||||
it("keeps a deliberately tracked virtual environment and still rejects other absolute links", async () => {
|
||||
const cwd = await source(), venv = path.join(cwd, "env");
|
||||
await fs.mkdir(venv); await fs.writeFile(path.join(venv, "pyvenv.cfg"), "home = /usr/bin\n");
|
||||
await git(cwd, "init", "-q", "-b", "main"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "tracked environment");
|
||||
const restored = await restore(await capture(cwd));
|
||||
expect(await fs.readFile(path.join(restored, "env", "pyvenv.cfg"), "utf8")).toBe("home = /usr/bin\n");
|
||||
await fs.rm(path.join(root, "snapshot.pcws")); await fs.rm(restored, { recursive: true });
|
||||
// A directory without the PEP 405 marker is ordinary work, not an environment.
|
||||
await fs.mkdir(path.join(cwd, "tools")); await fs.symlink(process.execPath, path.join(cwd, "tools", "node"));
|
||||
await expect(capture(cwd)).rejects.toThrow("unsafe_symlink");
|
||||
});
|
||||
|
||||
it("does not treat a linked pyvenv.cfg as an environment marker", async () => {
|
||||
const cwd = await source(), venv = path.join(cwd, ".venv");
|
||||
await fs.mkdir(path.join(venv, "bin"), { recursive: true });
|
||||
await fs.writeFile(path.join(cwd, "pyvenv.cfg"), "home = /usr/bin\n");
|
||||
await fs.symlink("../pyvenv.cfg", path.join(venv, "pyvenv.cfg"));
|
||||
await fs.symlink(process.execPath, path.join(venv, "bin", "python"));
|
||||
await expect(capture(cwd)).rejects.toThrow("unsafe_symlink");
|
||||
});
|
||||
});
|
||||
@@ -219,9 +219,13 @@ async function workspaceArchive(input: {
|
||||
const source = path.join(root, next);
|
||||
const stat = await fs.lstat(source);
|
||||
if (skipGit && name === ".git") { if (stat.isSymbolicLink()) fail("unsafe_git_link"); directories.push(relative); continue; }
|
||||
if (stat.isDirectory() && name === "node_modules") {
|
||||
const packageManifest = await fs.lstat(path.join(directory, "package.json")).catch(() => null);
|
||||
if (packageManifest?.isFile() && !packageManifest.isSymbolicLink()) {
|
||||
if (stat.isDirectory()) {
|
||||
// Reinstallable dependency trees: npm packages beside package.json, and
|
||||
// a Python virtual environment, which links its interpreter by absolute
|
||||
// path and has a PEP 405 pyvenv.cfg at its root.
|
||||
const marker = await fs.lstat(name === "node_modules" ? path.join(directory, "package.json")
|
||||
: path.join(source, "pyvenv.cfg")).catch(() => null);
|
||||
if (marker?.isFile() && !marker.isSymbolicLink()) {
|
||||
// A failing Git read cannot establish that data is untracked.
|
||||
const tracked = inRepository ? await git(directory, ["ls-files", "-z", "--", name]) : "";
|
||||
if (!tracked) { excludedPaths.add(next); continue; }
|
||||
|
||||
Reference in new issue
Block a user