mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
Require verified worktrees before relaxing source write authority
Keep copy intent deny-only, verify registered linked worktrees after Git and before provisioning, and preserve write restrictions on occupied original source folders. Retained privacy remains effective through failed preparation and revoked access. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
18a133a1a3
commit
20efaf904f
7 files changed
+162
-18
No files matched your search
@@ -45,7 +45,7 @@ Independent acquisition reserves a company-scoped canonical physical root as eit
|
||||
|
||||
Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories, whose publication callback rechecks the intersection after staging. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement.
|
||||
|
||||
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy without granting writes to the original source; actual shared target owners still require write authority. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
|
||||
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy before any copying. They are deny-only intent, not proof of isolation. The separate isolated_copy proof is recorded only after Git validates a registered linked worktree; the ancestor-source write exemption also requires the target to remain a real worktree. Preparation may temporarily allow a validated missing/empty target, without persisting that exemption. Existing ordinary folders and exact/descendant target owners keep their write requirements. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
|
||||
|
||||
Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_r
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
rootKey: text("root_key").notNull(),
|
||||
canonicalCwd: text("canonical_cwd").notNull(),
|
||||
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "isolated_source" | "isolated_project_source" | "repository">().notNull(),
|
||||
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "isolated_source" | "isolated_project_source" | "isolated_copy" | "repository">().notNull(),
|
||||
scopeId: uuid("scope_id").notNull(),
|
||||
issueId: uuid("issue_id"),
|
||||
agentId: uuid("agent_id"),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { findNativeChatWorkspaceScope } from "../services/native-runtime/native-chat-workspace.js";
|
||||
@@ -468,7 +469,7 @@ suite("task project repository provisioning", () => {
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
}, 30_000);
|
||||
|
||||
it.each([false, true])("checks nested private source access before worktree copy and retains it after read-grant revocation (provision fails: %s)", async (provisionFails) => {
|
||||
it.each(["success", "provision_failure", "occupied_target"] as const)("checks nested private source access before worktree copy and retains it after read-grant revocation (%s)", async (scenario) => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
@@ -476,10 +477,15 @@ suite("task project repository provisioning", () => {
|
||||
const sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "public-source");
|
||||
const privateCwd = path.join(cwd, "private");
|
||||
const occupiedTarget = path.join(cwd, ".paperclip", "worktrees", "occupied-private");
|
||||
await mkdir(privateCwd, { recursive: true });
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
git("init", "-b", "main");
|
||||
await writeFile(path.join(privateCwd, "secret.txt"), "NESTED_PRIVATE_TRACKED_BYTES");
|
||||
if (scenario === "occupied_target") {
|
||||
await mkdir(path.join(occupiedTarget, "private"), { recursive: true });
|
||||
await writeFile(path.join(occupiedTarget, "private/secret.txt"), "NESTED_PRIVATE_TRACKED_BYTES");
|
||||
}
|
||||
git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed");
|
||||
const initialWorktrees = git("worktree", "list", "--porcelain");
|
||||
@@ -493,9 +499,13 @@ suite("task project repository provisioning", () => {
|
||||
{ id: sourceId, companyId, projectId, name: "Git source", sourceType: "local_path", cwd, isPrimary: true },
|
||||
{ id: randomUUID(), companyId, projectId: privateProjectId, name: "Nested private source", sourceType: "local_path", cwd: privateCwd, isPrimary: true },
|
||||
]);
|
||||
if (scenario === "occupied_target") await db.insert(projectWorkspaces).values({
|
||||
id: randomUUID(), companyId, projectId: privateProjectId, name: "Private parent of occupied target",
|
||||
sourceType: "local_path", cwd: path.dirname(occupiedTarget), isPrimary: false,
|
||||
});
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Isolated reader", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Read isolated source", status: "todo", assigneeAgentId: agentId,
|
||||
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", ...(provisionFails ? { provisionCommand: "exit 42" } : {}) } },
|
||||
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", ...(scenario === "provision_failure" ? { provisionCommand: "exit 42" } : {}), ...(scenario === "occupied_target" ? { branchTemplate: "occupied-private", provisionCommand: "touch provision-ran" } : {}) } },
|
||||
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } });
|
||||
const admit = async () => {
|
||||
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
|
||||
@@ -519,13 +529,21 @@ suite("task project repository provisioning", () => {
|
||||
const allowed = await admit();
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === allowed.id);
|
||||
let copiedCwd: string;
|
||||
if (provisionFails) {
|
||||
if (scenario !== "success") {
|
||||
expect(allowed.status).toBe("failed");
|
||||
expect(allowed.error).toContain("provision");
|
||||
expect(calls).toHaveLength(0);
|
||||
const paths = git("worktree", "list", "--porcelain").split("\n").filter(line => line.startsWith("worktree ")).map(line => line.slice(9));
|
||||
expect(paths).toHaveLength(2);
|
||||
copiedCwd = paths.find(entry => entry !== paths[0])!;
|
||||
if (scenario === "provision_failure") {
|
||||
expect(allowed.error).toContain("provision");
|
||||
const paths = git("worktree", "list", "--porcelain").split("\n").filter(line => line.startsWith("worktree ")).map(line => line.slice(9));
|
||||
expect(paths).toHaveLength(2);
|
||||
copiedCwd = paths.find(entry => entry !== paths[0])!;
|
||||
} else {
|
||||
expect(allowed.error).toMatch(/protected|assignment grant/);
|
||||
expect(git("worktree", "list", "--porcelain")).toBe(initialWorktrees);
|
||||
copiedCwd = occupiedTarget;
|
||||
await expect(readFile(path.join(copiedCwd, ".git"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(path.join(copiedCwd, "provision-ran"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull();
|
||||
// A later public alias must not expose bytes from failed provisioning.
|
||||
const aliasId = randomUUID();
|
||||
@@ -533,6 +551,11 @@ suite("task project repository provisioning", () => {
|
||||
name: "Public alias after failed provisioning", mode: "shared_workspace", strategyType: "project_primary", cwd: copiedCwd,
|
||||
providerType: "local_fs", status: "active" });
|
||||
await db.update(issues).set({ executionWorkspaceId: aliasId }).where(eq(issues.id, issueId));
|
||||
if (scenario === "occupied_target") {
|
||||
// A failed copy intent must never relabel an ordinary private directory as isolated.
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId, cwd: copiedCwd, sourceWorkspaceId: aliasId, issueId,
|
||||
actor: { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: "responsible-user" } })).rejects.toThrow(/protected|assignment grant/);
|
||||
}
|
||||
} else {
|
||||
expect(allowed).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
|
||||
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
@@ -270,17 +270,68 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const targetCwd = sourceKind === "nested_target" ? path.join(f.admission.cwd, ".paperclip", "worktrees", "copy") : path.join(root, `${f.issueId}-copy`);
|
||||
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured });
|
||||
await expect(fs.lstat(targetCwd)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
if (sourceKind === "nested_target") {
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd,
|
||||
actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/);
|
||||
}
|
||||
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "copy", targetCwd]);
|
||||
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured });
|
||||
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured, phase: "verified" });
|
||||
const [target] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Isolated copy", cwd: targetCwd,
|
||||
mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
|
||||
await db.update(issues).set({ executionWorkspaceId: target.id }).where(eq(issues.id, f.issueId));
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, workspaceId: target.id, cwd: targetCwd })).resolves.toEqual([]);
|
||||
expect(await readFile(path.join(targetCwd, "private", "secret.txt"), "utf8")).toBe("Copied private Git bytes\n");
|
||||
if (sourceKind === "nested_target") {
|
||||
await fs.rename(path.join(targetCwd, ".git"), path.join(targetCwd, ".saved-git"));
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd,
|
||||
actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/);
|
||||
await fs.rename(path.join(targetCwd, ".saved-git"), path.join(targetCwd, ".git"));
|
||||
}
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, sourceWorkspaceId: target.id })).rejects.toThrow(/source project/);
|
||||
});
|
||||
|
||||
it("does not let an unmaterialized copy intent authorize writes to an existing source folder", async () => {
|
||||
const f = await fixture(), exec = promisify(execFileCallback);
|
||||
const targetCwd = path.join(f.admission.cwd, "tracked-private-folder");
|
||||
await mkdir(targetCwd);
|
||||
await writeFile(path.join(targetCwd, "existing.txt"), "Protected original bytes\n");
|
||||
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
|
||||
await exec("git", ["-C", f.admission.cwd, "add", "."]);
|
||||
await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected original source", visibility: "private",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original files", cwd: f.admission.cwd });
|
||||
await db.insert(projectAccessMembers).values([
|
||||
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
|
||||
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
|
||||
]);
|
||||
const writeInput = { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId };
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/);
|
||||
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
|
||||
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd,
|
||||
actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/);
|
||||
await expect(fs.lstat(path.join(targetCwd, ".git"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(path.join(targetCwd, "existing.txt"), "utf8")).toBe("Protected original bytes\n");
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, writeInput)).rejects.toThrow(/protected|assignment grant/);
|
||||
});
|
||||
|
||||
it("does not verify a missing or ordinary empty directory as an isolated copy", async () => {
|
||||
const f = await fixture(), exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
|
||||
await writeFile(path.join(f.admission.cwd, "source.txt"), "Source\n");
|
||||
await exec("git", ["-C", f.admission.cwd, "add", "."]);
|
||||
await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
|
||||
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
|
||||
const targetCwd = path.join(root, `${f.issueId}-never-materialized`);
|
||||
const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured, phase: "verified" as const };
|
||||
await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/);
|
||||
await mkdir(targetCwd);
|
||||
await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/not a verified Git worktree/);
|
||||
const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["exact", "descendant"] as const)("requires write authority for a protected %s target before copying", async relation => {
|
||||
const f = await fixture(), exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
|
||||
|
||||
@@ -107,7 +107,7 @@ export function assertRepositoryRetryMatches(row: Pick<RepositoryRow, "repositor
|
||||
|
||||
/** Resolve persisted and physical file provenance without registering readers or policies. */
|
||||
async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; inspectCurrentAliases?: boolean;
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; inspectCurrentAliases?: boolean; preparingCopyProjectIds?: Set<string>;
|
||||
}) {
|
||||
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
@@ -122,7 +122,11 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
// Only an exact target copy record proves that ancestral source owners were
|
||||
// copied here. A copied child must never relax its parent or a new owner
|
||||
// configured directly at/inside the target.
|
||||
const copiedProjectIds = new Set(entries.filter(entry => entry.kind === "isolated_project_source").map(entry => entry.scopeId));
|
||||
const proofs = entries.filter(entry => entry.kind === "isolated_copy");
|
||||
const copiedProjectIds = new Set(input.preparingCopyProjectIds ?? []);
|
||||
if (proofs.length && await verifiedIsolatedWorktree(canonicalCwd)) {
|
||||
for (const proof of proofs) copiedProjectIds.add(proof.scopeId);
|
||||
}
|
||||
const isCopiedAncestor = (projectId: string, sourceRoot: string) => copiedProjectIds.has(projectId)
|
||||
&& sourceRoot !== canonicalCwd && repositoryRootsOverlap(sourceRoot, canonicalCwd)
|
||||
&& !path.relative(sourceRoot, canonicalCwd).startsWith(`..${path.sep}`)
|
||||
@@ -324,6 +328,34 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
|
||||
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
|
||||
}
|
||||
|
||||
/** A retained copy intent protects privacy but proves no physical isolation.
|
||||
* Only a real, registered linked worktree can activate completed-copy records. */
|
||||
async function verifiedIsolatedWorktree(cwd: string, allowedOwnerRoots?: Set<string>): Promise<boolean> {
|
||||
try {
|
||||
const directory = await fs.lstat(cwd), marker = await fs.lstat(path.join(cwd, ".git"));
|
||||
if (!directory.isDirectory() || directory.isSymbolicLink() || !marker.isFile() || marker.isSymbolicLink() || marker.size > 4096) return false;
|
||||
const git = async (args: string[]) => (await execFile("git", ["-C", cwd, ...args], {
|
||||
timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
|
||||
})).stdout.trim();
|
||||
const checkoutRoot = await fs.realpath(await git(["rev-parse", "--show-toplevel"]));
|
||||
if (checkoutRoot !== cwd) return false;
|
||||
const commonDir = await git(["rev-parse", "--git-common-dir"]);
|
||||
const ownerRoot = await fs.realpath(path.dirname(path.resolve(checkoutRoot, commonDir)));
|
||||
if (ownerRoot === checkoutRoot || (allowedOwnerRoots && !allowedOwnerRoots.has(ownerRoot))) return false;
|
||||
const registrations = (await git(["worktree", "list", "--porcelain", "-z"])).split("\0");
|
||||
return registrations.includes(`worktree ${checkoutRoot}`);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function unusedIsolatedCopyTarget(cwd: string): Promise<boolean> {
|
||||
const entry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!entry) return true;
|
||||
return entry.isDirectory() && !entry.isSymbolicLink() && (await fs.readdir(cwd)).length === 0;
|
||||
}
|
||||
|
||||
/** Match realization's checkout and common repository roots. A configured cwd
|
||||
* may be a subdirectory or linked worktree while Git copies a broader tree. */
|
||||
async function isolatedWorkspaceSourceRoots(sourceCwd: string): Promise<string[]> {
|
||||
@@ -369,7 +401,7 @@ export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
|
||||
* already-copied private bytes never become an unowned readable target. */
|
||||
export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
|
||||
companyId: string; sourceCwd: string; targetCwd: string; actor: AuthorizationActor;
|
||||
captured: IsolatedWorkspaceSourceAccess; issueId?: string;
|
||||
captured: IsolatedWorkspaceSourceAccess; issueId?: string; phase?: "preparing" | "verified";
|
||||
}): Promise<void> {
|
||||
if (input.captured.companyId !== input.companyId) throw forbidden("Isolated copy source identity changed");
|
||||
let sourceFailure: unknown;
|
||||
@@ -405,13 +437,34 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
|
||||
}
|
||||
}
|
||||
// Completed-copy authority is separate from the deny-only intent above.
|
||||
// A failed Git operation in an existing ordinary folder cannot mint it.
|
||||
const phase = input.phase ?? "preparing";
|
||||
let preparingCopyProjectIds: Set<string> | undefined;
|
||||
try {
|
||||
const targetEntry = await fs.lstat(input.targetCwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (targetEntry?.isSymbolicLink()) throw forbidden("Isolated copy target cannot be a symbolic link");
|
||||
if (phase === "verified") {
|
||||
if (!await verifiedIsolatedWorktree(canonicalCwd, roots)) throw forbidden("Isolated copy target is not a verified Git worktree");
|
||||
for (const scopeId of projectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "isolated_copy", scopeId });
|
||||
}
|
||||
} else if (await unusedIsolatedCopyTarget(input.targetCwd)) {
|
||||
// This allowance exists only for this pre-copy check. No proof row is
|
||||
// written until the harness confirms Git's completed registration.
|
||||
preparingCopyProjectIds = projectIds;
|
||||
}
|
||||
} catch (error) { failure ??= error; }
|
||||
// The target may already contain independently owned files. Copy source
|
||||
// READ authority never grants permission to provision/write that target.
|
||||
try {
|
||||
await assertExecutionWorkspaceRootWriteAccess(tx, {
|
||||
companyId: input.companyId, cwd: canonicalCwd, actor: input.actor, issueId: input.issueId,
|
||||
});
|
||||
const target = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd });
|
||||
const target = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd, preparingCopyProjectIds });
|
||||
for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id);
|
||||
await assertRootProjectAccess(tx, input.actor, input.companyId, target.sourceProjectIds,
|
||||
{ sharedProjectIds: target.sharedProjectIds, issueId: input.issueId });
|
||||
for (const reader of target.overlappingEntries.filter(entry => entry.kind === "reader")) {
|
||||
const { actor } = await retainedRootReader(tx, input.companyId, reader);
|
||||
for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, id);
|
||||
|
||||
@@ -486,6 +486,13 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd, targetCwd,
|
||||
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
|
||||
phase: "preparing",
|
||||
}) : undefined;
|
||||
const afterIsolatedSourceCopy = isolatedWorkspaceSourceAccess
|
||||
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd, targetCwd,
|
||||
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
|
||||
phase: "verified",
|
||||
}) : undefined;
|
||||
const {
|
||||
executionWorkspace,
|
||||
@@ -555,6 +562,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
afterSourceCopy: afterIsolatedSourceCopy,
|
||||
})
|
||||
: null,
|
||||
realizeWorkspace: () =>
|
||||
@@ -588,6 +596,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
afterSourceCopy: afterIsolatedSourceCopy,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -599,6 +608,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
actor: isolatedWorkspaceSourceActor,
|
||||
captured: isolatedWorkspaceSourceAccess,
|
||||
issueId: issueId ?? undefined,
|
||||
phase: "verified",
|
||||
});
|
||||
}
|
||||
const resolvedProjectId =
|
||||
|
||||
@@ -3324,6 +3324,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
resolveGitAuth?: GitRemoteAuthProvider | null;
|
||||
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
}): Promise<RealizedExecutionWorkspace> {
|
||||
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
||||
const strategyType = asString(rawStrategy.type, "project_primary");
|
||||
@@ -3470,6 +3471,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
}),
|
||||
});
|
||||
}
|
||||
await input.afterSourceCopy?.(repoRoot, reusablePath);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
@@ -3628,6 +3630,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
},
|
||||
);
|
||||
}
|
||||
await input.afterSourceCopy?.(repoRoot, worktreePath);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
@@ -3723,6 +3726,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
return await reuseExistingWorktree(reusablePath);
|
||||
}
|
||||
}
|
||||
await input.afterSourceCopy?.(repoRoot, worktreePath);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
@@ -3777,6 +3781,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
resolveGitAuth?: GitRemoteAuthProvider | null;
|
||||
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
afterSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
}): Promise<RealizedExecutionWorkspace | null> {
|
||||
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
|
||||
if (!cwd) return null;
|
||||
@@ -3908,6 +3913,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
});
|
||||
realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings];
|
||||
realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
||||
await input.afterSourceCopy?.(repoRoot, realized.worktreePath ?? cwd);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
@@ -4000,6 +4006,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
skipRefresh: true,
|
||||
});
|
||||
|
||||
await input.afterSourceCopy?.(repoRoot, worktreePath);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
|
||||
Reference in new issue
Block a user