mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Preserve privacy across overlapping roots and isolated workspace copies
Authorize canonical parent and child ownership, retain configured roots before detachment, and preserve source/task privacy before worktree copying and provisioning. Recheck target writers and retained readers without requiring writes to isolated source folders. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d11cf62985
commit
16fc6daaf4
10 files changed
+814
-80
No files matched your search
@@ -458,6 +458,110 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each([
|
||||
["configured", "ancestor"], ["configured", "descendant"],
|
||||
["execution", "ancestor"], ["execution", "descendant"],
|
||||
] as const)("protects %s private roots from a public %s file browser, including retained moved ownership", async (ownerKind, relation) => {
|
||||
const { root, projectRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const ancestor = path.join(root, "work"), descendant = path.join(ancestor, "private");
|
||||
await fs.mkdir(descendant, { recursive: true });
|
||||
const publicRoot = relation === "ancestor" ? ancestor : descendant;
|
||||
const privateRoot = relation === "ancestor" ? descendant : ancestor;
|
||||
const relativeFile = relation === "ancestor" ? "private/secret.txt" : "secret.txt";
|
||||
await fs.writeFile(path.join(publicRoot, relativeFile), "PRIVATE_OVERLAPPING_ROOT_BYTES");
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot: publicRoot });
|
||||
await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId));
|
||||
await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
|
||||
await db.update(projectWorkspaces).set({ cwd: publicRoot }).where(eq(projectWorkspaces.id, graph.projectWorkspaceId));
|
||||
let ownerId: string;
|
||||
let privateIssueId: string | null = null;
|
||||
if (ownerKind === "configured") {
|
||||
ownerId = graph.targetProjectId;
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, ownerId));
|
||||
await db.update(projectWorkspaces).set({ cwd: privateRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
|
||||
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.projectId, ownerId))).toEqual([]);
|
||||
} else {
|
||||
const [task] = await db.insert(issues).values({ companyId: graph.companyId, title: "Private overlapping task", visibility: "private", responsibleUserId: "private-owner" }).returning();
|
||||
privateIssueId = task.id;
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: task.id,
|
||||
name: "Private overlapping workspace", cwd: privateRoot, mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" }).returning();
|
||||
ownerId = workspace.id;
|
||||
}
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
const targets = [{ workspace: "execution" }, { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId }];
|
||||
const readState = async () => ({
|
||||
access: await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId)),
|
||||
policies: await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId)),
|
||||
});
|
||||
const deny = async () => {
|
||||
const before = await readState();
|
||||
for (const target of targets) {
|
||||
for (const [endpoint, query] of [
|
||||
["list", {}], ["resolve", { path: relativeFile }], ["content", { path: relativeFile }], ["content", { path: relativeFile, download: true }],
|
||||
] as const) {
|
||||
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/${endpoint}`).query({ ...target, ...query }).expect(404);
|
||||
for (const hidden of ["PRIVATE_OVERLAPPING_ROOT_BYTES", ownerId, privateRoot, "Private overlapping"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
}
|
||||
const availability = await request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`)
|
||||
.send({ queries: [{ ...target, path: relativeFile }] }).expect(200);
|
||||
expect(availability.body.results).toEqual([expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null })]);
|
||||
}
|
||||
expect(await readState()).toEqual(before);
|
||||
};
|
||||
await deny();
|
||||
const grant = ownerKind === "configured"
|
||||
? (await db.insert(projectAccessMembers).values({ companyId: graph.companyId, projectId: ownerId, subjectType: "user", subjectId: "board-user" }).returning())[0]!
|
||||
: (await db.insert(issueAccessGrants).values({ issueId: privateIssueId!, subjectType: "user", subjectId: "board-user", source: "explicit" }).returning())[0]!;
|
||||
const beforeAllowedReads = await readState();
|
||||
for (const target of targets) {
|
||||
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, path: relativeFile }).expect(200);
|
||||
expect(response.body.content.data).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES");
|
||||
}
|
||||
expect(await readState()).toEqual(beforeAllowedReads);
|
||||
// A prior admission's deny-only owner remains attached to the physical
|
||||
// tree after the logical owner selects a different folder.
|
||||
const canonicalCwd = await fs.realpath(privateRoot);
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: graph.companyId,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), canonicalCwd,
|
||||
kind: ownerKind === "configured" ? "project_source" : "source", scopeId: ownerId });
|
||||
if (ownerKind === "configured") {
|
||||
await db.update(projectWorkspaces).set({ cwd: targetProjectRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
|
||||
} else {
|
||||
await db.update(executionWorkspaces).set({ cwd: targetProjectRoot }).where(eq(executionWorkspaces.id, ownerId));
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
||||
}
|
||||
await deny();
|
||||
expect(await fs.readFile(path.join(publicRoot, relativeFile), "utf8")).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES");
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("keeps sibling string-prefix roots independent without registering file readers", async () => {
|
||||
const { root, projectRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const privateRoot = path.join(root, "work"), publicRoot = path.join(root, "worker");
|
||||
await fs.mkdir(privateRoot); await fs.mkdir(publicRoot);
|
||||
await fs.writeFile(path.join(publicRoot, "public.txt"), "PUBLIC_SIBLING_BYTES");
|
||||
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot: publicRoot });
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId));
|
||||
await db.update(projectWorkspaces).set({ cwd: privateRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: graph.companyId, title: "Private sibling", visibility: "private", responsibleUserId: "private-owner" }).returning();
|
||||
await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssue.id, cwd: privateRoot,
|
||||
name: "Private sibling", mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" });
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
const target = { workspace: "execution", path: "public.txt" };
|
||||
const content = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query(target).expect(200);
|
||||
expect(content.body.content.data).toBe("PUBLIC_SIBLING_BYTES");
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`).query({ workspace: "execution" }).expect(200);
|
||||
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, download: true }).expect(200);
|
||||
const availability = await request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`).send({ queries: [target] }).expect(200);
|
||||
expect(availability.body.results).toEqual([expect.objectContaining({ openable: true })]);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual([]);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]);
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("does not confuse identical repositories in unrelated available roots with private file provenance", async () => {
|
||||
const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { fileResourceRoutes } from "../routes/file-resources.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -7,7 +11,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectAccessMembers, projectWorkspaces } from "@paperclipai/db";
|
||||
import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync";
|
||||
import { buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js";
|
||||
@@ -464,6 +468,101 @@ 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) => {
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), privateProjectId = randomUUID();
|
||||
const sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "public-source");
|
||||
const privateCwd = path.join(cwd, "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");
|
||||
git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed");
|
||||
const initialWorktrees = git("worktree", "list", "--porcelain");
|
||||
await db.insert(companies).values({ id: companyId, name: "Private copy", issuePrefix: `C${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" });
|
||||
await db.insert(projects).values([
|
||||
{ id: projectId, companyId, name: "Public Git source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
|
||||
{ id: privateProjectId, companyId, name: "Private nested source", visibility: "private" },
|
||||
]);
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{ 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 },
|
||||
]);
|
||||
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" } : {}) } },
|
||||
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 } });
|
||||
expect(run).not.toBeNull();
|
||||
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 });
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
return (await heartbeat.getRun(run!.id))!;
|
||||
};
|
||||
const denied = await admit();
|
||||
expect(denied.status).toBe("failed");
|
||||
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
|
||||
expect(git("worktree", "list", "--porcelain")).toBe(initialWorktrees);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull();
|
||||
await db.insert(projectAccessMembers).values([
|
||||
{ companyId, projectId: privateProjectId, subjectType: "agent", subjectId: agentId },
|
||||
{ companyId, projectId: privateProjectId, subjectType: "user", subjectId: "responsible-user" },
|
||||
]);
|
||||
// Isolated copies need source read access, never an assignment/write grant.
|
||||
expect(await accessService(db).decide({ actor: { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: "responsible-user" },
|
||||
action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId }, scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false });
|
||||
const allowed = await admit();
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === allowed.id);
|
||||
let copiedCwd: string;
|
||||
if (provisionFails) {
|
||||
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])!;
|
||||
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();
|
||||
await db.insert(executionWorkspaces).values({ id: aliasId, companyId, projectId: null, sourceIssueId: issueId,
|
||||
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));
|
||||
} else {
|
||||
expect(allowed).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]![0].context.paperclipWorkspace.strategy).toBe("git_worktree");
|
||||
copiedCwd = calls[0]![0].context.paperclipWorkspace.cwd;
|
||||
}
|
||||
const workspace = { cwd: copiedCwd };
|
||||
expect(await realpath(workspace.cwd)).not.toBe(await realpath(cwd));
|
||||
expect(await readFile(path.join(workspace.cwd, "private/secret.txt"), "utf8")).toBe("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "responsible-user", companyIds: [companyId], source: "session", isInstanceAdmin: false };
|
||||
next();
|
||||
});
|
||||
app.use("/api", fileResourceRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const fileUrl = `/api/issues/${issueId}/file-resources/content`;
|
||||
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt" }).expect(200)).body.content.data).toBe("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, privateProjectId), eq(projectAccessMembers.subjectId, "responsible-user")));
|
||||
for (const download of [false, true]) {
|
||||
const response = await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt", download }).expect(404);
|
||||
expect(JSON.stringify(response.body)).not.toContain("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
}
|
||||
expect(await readFile(path.join(workspace.cwd, "private/secret.txt"), "utf8")).toBe("NESTED_PRIVATE_TRACKED_BYTES");
|
||||
} finally {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
|
||||
}
|
||||
}, 40_000);
|
||||
|
||||
it.each([true, false])("handles a deleted configured source with a retained workspace: %s", async (bound) => {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "retained-source");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryAccess,
|
||||
executionWorkspaces, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("configured project root provenance", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let root: string;
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-configured-provenance-");
|
||||
db = createDb(database.connectionString);
|
||||
root = await mkdtemp(path.join(tmpdir(), "paperclip-configured-provenance-"));
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await database?.cleanup();
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["move", "remove", "remove_project", "replace_repositories"] as const)(
|
||||
"retains nested private files before first admission when configured source changes: %s", async (mutation) => {
|
||||
const companyId = randomUUID(), userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId), privateCwd = path.join(cwd, "private"), unrelatedCwd = `${cwd}-unrelated`;
|
||||
await mkdir(privateCwd, { recursive: true });
|
||||
await mkdir(unrelatedCwd);
|
||||
await writeFile(path.join(privateCwd, "secret.txt"), "Private configured source\n");
|
||||
await db.insert(companies).values({ id: companyId, name: "Configured provenance", issuePrefix: `P${companyId.slice(0, 6)}` });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Reader", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process", status: "idle" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
|
||||
const [source] = await db.insert(projects).values({ companyId, name: "Private source", visibility: "private",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Private files", cwd: privateCwd,
|
||||
repoUrl: "https://github.com/example/private", metadata: { githubRepositoryId: "fixture-private" } }).returning();
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Public parent", cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning();
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Public task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id });
|
||||
const actor = { type: "board" as const, source: "session" as const, userId, ignoreInstanceAdmin: true };
|
||||
const access = { companyId, cwd, sourceWorkspaceId: workspace.id, actor };
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
|
||||
const service = projectService(db, { captureWorkspaceUpdates: false });
|
||||
if (mutation === "move") await service.updateWorkspace(source.id, configured.id, { cwd: unrelatedCwd });
|
||||
else if (mutation === "remove") await service.removeWorkspace(source.id, configured.id);
|
||||
else if (mutation === "remove_project") await service.remove(source.id);
|
||||
else await service.replaceRepositories(source.id, []);
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId));
|
||||
expect(retained).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
|
||||
expect.objectContaining({ kind: "shared_project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
|
||||
]));
|
||||
expect(retained.some(row => row.kind === "reader")).toBe(false);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/source project/);
|
||||
// The common-prefix sibling is physically independent of the old root.
|
||||
if (mutation !== "move") await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: unrelatedCwd, actor })).resolves.toBeUndefined();
|
||||
if (mutation === "remove_project") return; // Missing sticky owner must remain denied.
|
||||
await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId });
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).resolves.toBeUndefined();
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/protected|not authorized/);
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: source.id, assigneeAgentId: agentId } });
|
||||
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("can remove a configured folder that never existed without inventing provenance", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Missing source", issuePrefix: `M${companyId.slice(0, 6)}` });
|
||||
const [source] = await db.insert(projects).values({ companyId, name: "Missing private source", visibility: "private" }).returning();
|
||||
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Missing", cwd: path.join(root, companyId, "never-created") }).returning();
|
||||
await expect(projectService(db, { captureWorkspaceUpdates: false }).removeWorkspace(source.id, configured.id)).resolves.toMatchObject({ id: configured.id });
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { assertExecutionWorkspaceRootReadAccess, 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";
|
||||
@@ -124,6 +124,234 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } }));
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ owner: "configured", direction: "descendant" }, { owner: "configured", direction: "ancestor" },
|
||||
{ owner: "execution", direction: "descendant" }, { owner: "execution", direction: "ancestor" },
|
||||
])("authorizes newly overlapping private $owner $direction owners before selection, admission and publication", async ({ owner, direction }) => {
|
||||
const f = await fixture();
|
||||
const child = path.join(f.admission.cwd, "private");
|
||||
await mkdir(child);
|
||||
const privateCwd = direction === "descendant" ? child : f.admission.cwd;
|
||||
if (direction === "ancestor") {
|
||||
f.admission.cwd = child;
|
||||
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
}
|
||||
await f.service.prepareForAdmission(f.admission); // A retained reader cannot hide later nested owners.
|
||||
let grant: () => Promise<unknown>;
|
||||
let detach: () => Promise<unknown>;
|
||||
if (owner === "configured") {
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Nested private source", visibility: "private" }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private files", cwd: privateCwd }).returning();
|
||||
grant = () => 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 },
|
||||
]);
|
||||
detach = async () => {
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
|
||||
};
|
||||
} else {
|
||||
const peer = await standardPeer(f);
|
||||
await db.update(executionWorkspaces).set({ cwd: privateCwd }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
|
||||
grant = () => db.insert(issueAccessGrants).values([
|
||||
{ issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId },
|
||||
{ issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId },
|
||||
]);
|
||||
detach = async () => {
|
||||
const moved = path.join(root, `${peer.issueId}-moved`); await mkdir(moved);
|
||||
await executionWorkspaceService(db).update(peer.workspace.id, { cwd: moved });
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId));
|
||||
};
|
||||
}
|
||||
await writeFile(path.join(privateCwd, "private.txt"), "Nested private files\n");
|
||||
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor, sourceWorkspaceId: f.workspace.id });
|
||||
const select = () => executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
|
||||
selection: { kind: "existing", workspaceId: f.workspace.id }, expectedBindingRevision: 0, requestKey: randomUUID() });
|
||||
await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
await expect(select()).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
await grant();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(read()).resolves.toBeUndefined();
|
||||
await detach();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/);
|
||||
});
|
||||
|
||||
it("does not confuse sibling component prefixes with overlapping roots", async () => {
|
||||
const f = await fixture();
|
||||
const sibling = `${f.admission.cwd}er`; await mkdir(sibling);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private source", visibility: "private" }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Sibling", cwd: sibling });
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"),
|
||||
canonicalCwd: await realpath(sibling), kind: "project_source", scopeId: project.id });
|
||||
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"),
|
||||
canonicalCwd: await realpath(sibling), mode: "anonymous_only" });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.request({ kind: "catalog", id: "private-repo" })).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "managed", direction: "ancestor" }, { mode: "managed", direction: "descendant" },
|
||||
{ mode: "anonymous_only", direction: "ancestor" }, { mode: "anonymous_only", direction: "descendant" },
|
||||
] as const)("fences $mode root policy against opposite-mode $direction acquisition", async ({ mode, direction }) => {
|
||||
const f = await fixture();
|
||||
const child = path.join(f.admission.cwd, "nested"); await mkdir(child);
|
||||
const existingRoot = direction === "ancestor" ? child : f.admission.cwd;
|
||||
if (direction === "descendant") {
|
||||
f.admission.cwd = child;
|
||||
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
}
|
||||
const canonicalCwd = await realpath(existingRoot);
|
||||
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode });
|
||||
if (mode === "managed") {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only/);
|
||||
} else await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only/);
|
||||
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reauthorizes a nested private publication against an already-admitted parent reader", async () => {
|
||||
const f = await fixture();
|
||||
await f.service.prepareForAdmission(f.admission);
|
||||
const peer = await standardPeer(f), child = path.join(f.admission.cwd, "private"); await mkdir(child);
|
||||
peer.admission.cwd = child;
|
||||
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
|
||||
const request = () => f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository: { kind: "url", url: "https://github.com/public/nested" }, requestKey: randomUUID() } });
|
||||
await expect(request()).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await db.insert(issueAccessGrants).values([
|
||||
{ issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId },
|
||||
{ issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId },
|
||||
]);
|
||||
const receipt = await request();
|
||||
await localPrivateClone({ ...f, issueId: peer.issueId }, async () => {
|
||||
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId));
|
||||
});
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await expect(fs.lstat(path.join(child, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(acquisition.materialize).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["subdirectory", "linked_worktree", "nested_target"] as const)("retains private Git owner provenance copied through a %s without requiring source write grants", async sourceKind => {
|
||||
const f = await fixture(), exec = promisify(execFileCallback);
|
||||
const privateCwd = path.join(f.admission.cwd, "private"), publicCwd = path.join(f.admission.cwd, "public");
|
||||
await mkdir(privateCwd); await mkdir(publicCwd);
|
||||
await writeFile(path.join(privateCwd, "secret.txt"), "Copied private Git bytes\n");
|
||||
await writeFile(path.join(publicCwd, "readme.txt"), "Public nested configuration\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: "Private copied source", visibility: "private",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private nested files", cwd: privateCwd });
|
||||
if (sourceKind === "nested_target") {
|
||||
const [outer] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected ancestor source",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: outer.id, name: "Ancestor source", cwd: f.admission.cwd });
|
||||
}
|
||||
let sourceCwd = publicCwd;
|
||||
if (sourceKind === "linked_worktree") {
|
||||
sourceCwd = path.join(root, `${f.issueId}-linked`);
|
||||
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", sourceCwd]);
|
||||
}
|
||||
await expect(assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
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 captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor });
|
||||
expect(captured.projectIds).toContain(project.id);
|
||||
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" });
|
||||
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "copy", targetCwd]);
|
||||
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured });
|
||||
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");
|
||||
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.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]);
|
||||
await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\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 targetCwd = path.join(root, `${f.issueId}-protected-target`);
|
||||
const protectedCwd = relation === "exact" ? targetCwd : path.join(targetCwd, "private");
|
||||
await mkdir(protectedCwd, { recursive: true });
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected target",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Target owner", cwd: protectedCwd });
|
||||
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, "source.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_source", scopeId: f.workspace.id })]));
|
||||
});
|
||||
|
||||
it("excludes available lexical descendants whose canonical roots are unrelated", async () => {
|
||||
const f = await fixture();
|
||||
const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated);
|
||||
const alias = path.join(f.admission.cwd, "link"); await symlink(unrelated, alias);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private owner", visibility: "private" }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias });
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias,
|
||||
mode: "shared_workspace", strategyType: "project_primary" }).returning();
|
||||
await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId, requestedByIssueId: f.issueId, executionWorkspaceId: workspace.id,
|
||||
repositoryIdentity: "https://github.com/private/unrelated", repoUrl: "https://github.com/private/unrelated", relativePath: "unused",
|
||||
requestedRef: "HEAD", requestKey: randomUUID() });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["revoked", "deleted_source", "deleted_owner", "retained_reader"] as const)("protects already-copied bytes when post-copy authorization fails (%s)", async failure => {
|
||||
const f = await fixture(), exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
|
||||
await writeFile(path.join(f.admission.cwd, "secret.txt"), "Failed copy still private\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 [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Failed private copy", visibility: "private" }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", 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 captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
|
||||
const targetCwd = path.join(root, `${f.issueId}-failed-copy`);
|
||||
if (failure === "retained_reader") {
|
||||
await mkdir(targetCwd);
|
||||
const peer = await standardPeer(f);
|
||||
await db.update(executionWorkspaces).set({ cwd: targetCwd }).where(eq(executionWorkspaces.id, peer.workspace.id));
|
||||
await f.service.prepareForAdmission({ ...peer.admission, cwd: targetCwd });
|
||||
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd,
|
||||
actor: f.actor, captured })).rejects.toThrow(/source project/);
|
||||
await expect(fs.lstat(path.join(targetCwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
return;
|
||||
}
|
||||
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "failed-copy", targetCwd]);
|
||||
if (failure === "revoked") await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
|
||||
if (failure === "deleted_owner") await db.delete(projects).where(eq(projects.id, project.id));
|
||||
if (failure === "deleted_source") await rm(f.admission.cwd, { recursive: true });
|
||||
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured })).rejects.toThrow();
|
||||
const canonicalCwd = await realpath(targetCwd);
|
||||
const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.rootKey, createHash("sha256").update(canonicalCwd).digest("hex")));
|
||||
expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_project_source", scopeId: project.id })]));
|
||||
const peer = await standardPeer(f);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: peer.actor })).rejects.toThrow(/source project/);
|
||||
expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Failed copy still private\n");
|
||||
});
|
||||
|
||||
it.each(["empty", "different_identity", "unreadable_alias", "loop_alias", "anonymous_private_edits", "authorized_peer"] as const)(
|
||||
"authorizes every physical repository source for a standard reader with %s", async scenario => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
@@ -771,11 +999,11 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
});
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
// Once fenced, unrelated historical paths need no further filesystem reads.
|
||||
// Rechecking current owners must still tolerate an unrelated unavailable path.
|
||||
vi.restoreAllMocks();
|
||||
const actual = fs.realpath.bind(fs);
|
||||
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
|
||||
if (String(pathname) === unavailableCwd) throw new Error("Historical path must not be revisited");
|
||||
if (String(pathname) === unavailableCwd) throw Object.assign(new Error("Historical path remains unavailable"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
|
||||
@@ -36,22 +36,31 @@ async function canonicalRepositoryRoot(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Physical folder authority is symmetric: a parent captures its descendants,
|
||||
* while a child remains inside its ancestor's retained file ownership. */
|
||||
function repositoryRootsOverlap(left: string, right: string): boolean {
|
||||
const contains = (parent: string, child: string) => {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
||||
};
|
||||
return contains(left, right) || contains(right, left);
|
||||
}
|
||||
|
||||
/** An unavailable legacy path must not block unrelated roots. Preserve the
|
||||
* unresolved suffix and reject roots inside an inaccessible prefix, since a
|
||||
* hidden symlink may still point at the candidate's admitted directory. */
|
||||
async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Promise<boolean> {
|
||||
const absolute = path.resolve(cwd);
|
||||
if (absolute === candidate) return true;
|
||||
let probe = absolute;
|
||||
let suffix = "";
|
||||
let inaccessiblePrefix: string | null = null;
|
||||
for (;;) {
|
||||
try {
|
||||
const canonical = await fs.realpath(probe);
|
||||
if (path.join(canonical, suffix) === candidate) return true;
|
||||
if (repositoryRootsOverlap(path.join(canonical, suffix), candidate)) return true;
|
||||
if (!inaccessiblePrefix) return false;
|
||||
const unresolved = path.join(canonical, path.relative(probe, inaccessiblePrefix));
|
||||
return candidate === unresolved || candidate.startsWith(`${unresolved}${path.sep}`);
|
||||
return repositoryRootsOverlap(candidate, unresolved);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? "")) throw error;
|
||||
@@ -102,24 +111,44 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
}) {
|
||||
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
|
||||
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
const sourceIds = new Set(entries.filter(entry => entry.kind === "source").map(entry => entry.scopeId));
|
||||
if (input.sourceWorkspaceId) sourceIds.add(input.sourceWorkspaceId);
|
||||
const inspectCurrentAliases = input.inspectCurrentAliases || !entries.some(entry => entry.kind === "reader");
|
||||
if (inspectCurrentAliases) {
|
||||
// Include current physical aliases as well as sticky historical sources.
|
||||
// Ordinary task files carry privacy even without acquisition receipts.
|
||||
const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.companyId, input.companyId));
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspace.cwd) continue;
|
||||
const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (peerRoot === canonicalCwd) sourceIds.add(workspace.id);
|
||||
const companyEntries = await tx.select().from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, input.companyId));
|
||||
const entries = companyEntries.filter(entry => entry.rootKey === rootKey);
|
||||
const overlappingEntries = companyEntries.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
|
||||
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)
|
||||
|| overlappingEntries.some(entry => createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
|
||||
throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
}
|
||||
// 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 isCopiedAncestor = (projectId: string, sourceRoot: string) => copiedProjectIds.has(projectId)
|
||||
&& sourceRoot !== canonicalCwd && repositoryRootsOverlap(sourceRoot, canonicalCwd)
|
||||
&& !path.relative(sourceRoot, canonicalCwd).startsWith(`..${path.sep}`)
|
||||
&& path.relative(sourceRoot, canonicalCwd) !== "..";
|
||||
const sharedSourceRoots = new Map<string, Set<string>>();
|
||||
const addSharedSource = (id: string, cwd: string) => {
|
||||
const roots = sharedSourceRoots.get(id) ?? new Set<string>();
|
||||
roots.add(cwd); sharedSourceRoots.set(id, roots);
|
||||
};
|
||||
for (const entry of overlappingEntries) if (entry.kind === "source") addSharedSource(entry.scopeId, entry.canonicalCwd);
|
||||
const sharedSourceIds = new Set(sharedSourceRoots.keys());
|
||||
const isolatedSourceIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_source").map(entry => entry.scopeId));
|
||||
const sourceIds = new Set([...sharedSourceIds, ...isolatedSourceIds]);
|
||||
if (input.sourceWorkspaceId) { sourceIds.add(input.sourceWorkspaceId); sharedSourceIds.add(input.sourceWorkspaceId); addSharedSource(input.sourceWorkspaceId, canonicalCwd); }
|
||||
// Recheck current roots on every admission/publication. A later nested owner
|
||||
// can introduce private files even after this root has retained readers.
|
||||
const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.companyId, input.companyId));
|
||||
for (const workspace of workspaces) {
|
||||
if (!workspace.cwd) continue;
|
||||
const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd)) {
|
||||
sourceIds.add(workspace.id); sharedSourceIds.add(workspace.id); addSharedSource(workspace.id, peerRoot);
|
||||
}
|
||||
}
|
||||
const inventory = await tx.select({ id: executionWorkspaceRepositories.id, workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
|
||||
@@ -149,7 +178,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
for (const peer of inventory) {
|
||||
const name = path.basename(taskRepositoryRelativePath(peer.identity));
|
||||
const published = publishedNames.has(name);
|
||||
if (!published && !inspectCurrentAliases) continue;
|
||||
const peerRoot = peer.cwd ? await fs.realpath(peer.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
@@ -158,11 +186,14 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
// copies in available unrelated roots do not contribute files here.
|
||||
// Retained sources survive cwd changes; unavailable legacy aliases with
|
||||
// matching publications remain ambiguous and therefore fail closed.
|
||||
const matches = sourceIds.has(peer.workspaceId) || peerRoot === canonicalCwd
|
||||
const matches = sourceIds.has(peer.workspaceId) || (peerRoot !== null && repositoryRootsOverlap(peerRoot, canonicalCwd))
|
||||
|| (published && peerRoot === null && !knownOrigins.has(peer.id))
|
||||
|| (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
|
||||
|| (peerRoot === null && !entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
|
||||
if (matches) {
|
||||
sourceIds.add(peer.workspaceId);
|
||||
if (!isolatedSourceIds.has(peer.workspaceId) || (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd))) {
|
||||
sharedSourceIds.add(peer.workspaceId); addSharedSource(peer.workspaceId, peerRoot ?? canonicalCwd);
|
||||
}
|
||||
if (published) recognizedNames.add(name);
|
||||
}
|
||||
}
|
||||
@@ -173,15 +204,16 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
.from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId));
|
||||
// Retain the privacy owner, not its disposable workspace configuration.
|
||||
// Execution sources may later lose their project FK; retained files must not.
|
||||
const sourceProjectIds = new Set(entries.filter(entry => entry.kind === "project_source").map(entry => entry.scopeId));
|
||||
const sharedProjectIds = new Set(entries.filter(entry => entry.kind === "shared_project_source").map(entry => entry.scopeId));
|
||||
const isolatedProjectIds = new Set<string>();
|
||||
const sourceProjectIds = new Set(overlappingEntries.filter(entry => (entry.kind === "project_source" || entry.kind === "isolated_project_source")).map(entry => entry.scopeId));
|
||||
const sharedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "shared_project_source" && !isCopiedAncestor(entry.scopeId, entry.canonicalCwd)).map(entry => entry.scopeId));
|
||||
const isolatedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_project_source").map(entry => entry.scopeId));
|
||||
if (sourceIds.size) {
|
||||
const owners = await tx.select({ projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
|
||||
const owners = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id)))));
|
||||
for (const owner of owners) if (owner.projectId) {
|
||||
sourceProjectIds.add(owner.projectId);
|
||||
if (owner.strategyType === "git_worktree") isolatedProjectIds.add(owner.projectId);
|
||||
if (owner.strategyType === "git_worktree" || !sharedSourceIds.has(owner.id)
|
||||
|| [...sharedSourceRoots.get(owner.id) ?? []].every(cwd => isCopiedAncestor(owner.projectId!, cwd))) isolatedProjectIds.add(owner.projectId);
|
||||
else sharedProjectIds.add(owner.projectId);
|
||||
}
|
||||
}
|
||||
@@ -191,16 +223,16 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (sourceRoot !== canonicalCwd) continue;
|
||||
if (!sourceRoot || !repositoryRootsOverlap(sourceRoot, canonicalCwd)) continue;
|
||||
sourceProjectIds.add(source.projectId);
|
||||
sharedProjectIds.add(source.projectId);
|
||||
if (!isCopiedAncestor(source.projectId, sourceRoot)) sharedProjectIds.add(source.projectId);
|
||||
}
|
||||
// Older retained owners have no sharing classification. Require writes
|
||||
// unless an authoritative retained workspace proves an isolated copy.
|
||||
for (const entry of entries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
|
||||
for (const entry of overlappingEntries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
|
||||
sharedProjectIds.add(entry.scopeId);
|
||||
}
|
||||
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds };
|
||||
return { canonicalCwd, rootKey, entries, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds };
|
||||
}
|
||||
|
||||
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
|
||||
@@ -215,12 +247,50 @@ async function assertRootProjectAccess(db: Db | DbTransaction, actor: Authorizat
|
||||
}
|
||||
}
|
||||
|
||||
async function retainedRootReader(tx: DbTransaction, companyId: string, reader: typeof executionWorkspaceRepositoryAccess.$inferSelect) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, companyId)));
|
||||
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, companyId))) : [];
|
||||
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, companyId))) : [];
|
||||
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|
||||
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== reader.issueId) {
|
||||
throw forbidden("Repository root retains a reader whose identity is no longer available");
|
||||
}
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId,
|
||||
agentId: reader.agentId!, runId: reader.scopeId, onBehalfOfUserId: reader.responsibleUserId === "local-board" ? null : reader.responsibleUserId };
|
||||
return { actor, issue };
|
||||
}
|
||||
|
||||
export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, companyId: string) {
|
||||
// Acquire before task/workspace row locks. Hold only for policy and receipt
|
||||
// transactions, never Git, provider execution, or snapshot capture.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`);
|
||||
}
|
||||
|
||||
/** A configured folder owns project files before its first task admission.
|
||||
* Preserve that ownership before its configuration can move or disappear.
|
||||
* Caller holds the company root fence and the fresh configured-workspace lock. */
|
||||
export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTransaction, input: {
|
||||
companyId: string; projectId: string; cwd: string | null;
|
||||
}): Promise<void> {
|
||||
if (!input.cwd) return;
|
||||
const directory = await fs.stat(input.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!directory) return;
|
||||
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
|
||||
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
|
||||
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
for (const kind of ["project_source", "shared_project_source"] as const) {
|
||||
if (!entries.some(entry => entry.kind === kind && entry.scopeId === input.projectId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId: input.projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve deny-only owners before a workspace can detach from existing files.
|
||||
* Caller holds the company root fence, followed by the workspace row lock. */
|
||||
export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction, input: {
|
||||
@@ -233,10 +303,10 @@ export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction
|
||||
});
|
||||
if (!directory) return;
|
||||
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, {
|
||||
const { canonicalCwd, rootKey, entries, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await resolveRepositoryRootSources(tx, {
|
||||
companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
|
||||
});
|
||||
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds]] as const) {
|
||||
for (const [kind, ids] of [["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]] as const) {
|
||||
for (const scopeId of ids) {
|
||||
if (!entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId });
|
||||
@@ -254,9 +324,108 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
|
||||
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
const git = async (cwd: string, argument: string) => (await execFile("git", ["-C", cwd, "rev-parse", argument], {
|
||||
timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
|
||||
})).stdout.trim();
|
||||
const checkoutRoot = await fs.realpath(path.resolve(await git(sourceCwd, "--show-toplevel")));
|
||||
const commonDir = await git(checkoutRoot, "--git-common-dir");
|
||||
const ownerRoot = await fs.realpath(path.dirname(path.resolve(checkoutRoot, commonDir)));
|
||||
return [...new Set([checkoutRoot, ownerRoot])];
|
||||
}
|
||||
|
||||
/** Internal, deny-only snapshot captured before copying; never accepted from a model or API payload. */
|
||||
export type IsolatedWorkspaceSourceAccess = {
|
||||
companyId: string;
|
||||
roots: string[];
|
||||
workspaceIds: string[];
|
||||
projectIds: string[];
|
||||
};
|
||||
|
||||
export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
|
||||
companyId: string; sourceCwd: string; actor: AuthorizationActor;
|
||||
}): Promise<IsolatedWorkspaceSourceAccess> {
|
||||
const roots = await isolatedWorkspaceSourceRoots(input.sourceCwd);
|
||||
return db.transaction(async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const workspaceIds = new Set<string>(), projectIds = new Set<string>();
|
||||
for (const cwd of roots) {
|
||||
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true });
|
||||
for (const id of closure.sourceIds) {
|
||||
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id);
|
||||
workspaceIds.add(id);
|
||||
}
|
||||
await assertRootProjectAccess(tx, input.actor, input.companyId, closure.sourceProjectIds);
|
||||
for (const id of closure.sourceProjectIds) projectIds.add(id);
|
||||
}
|
||||
return { companyId: input.companyId, roots, workspaceIds: [...workspaceIds], projectIds: [...projectIds] };
|
||||
});
|
||||
}
|
||||
|
||||
/** Retain source privacy before copying and after realization, including a failed copy admission.
|
||||
* Commit deny-only ownership before reporting a revoked/missing source, so
|
||||
* 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;
|
||||
}): Promise<void> {
|
||||
if (input.captured.companyId !== input.companyId) throw forbidden("Isolated copy source identity changed");
|
||||
let sourceFailure: unknown;
|
||||
const roots = new Set(input.captured.roots);
|
||||
try { for (const cwd of await isolatedWorkspaceSourceRoots(input.sourceCwd)) roots.add(cwd); }
|
||||
catch (error) { sourceFailure = error; }
|
||||
const canonicalCwd = await canonicalRepositoryRoot(input.targetCwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const denied = await db.transaction(async tx => {
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const existing = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
|
||||
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
|
||||
if (existing.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
|
||||
const workspaceIds = new Set(input.captured.workspaceIds), projectIds = new Set(input.captured.projectIds);
|
||||
let failure = sourceFailure;
|
||||
for (const cwd of roots) {
|
||||
try {
|
||||
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true });
|
||||
for (const id of closure.sourceIds) workspaceIds.add(id);
|
||||
for (const id of closure.sourceProjectIds) projectIds.add(id);
|
||||
} catch (error) { failure ??= error; }
|
||||
}
|
||||
for (const id of workspaceIds) {
|
||||
try { await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id); }
|
||||
catch (error) { failure ??= error; }
|
||||
}
|
||||
for (const id of projectIds) {
|
||||
try { await assertRootProjectAccess(tx, input.actor, input.companyId, new Set([id])); }
|
||||
catch (error) { failure ??= error; }
|
||||
}
|
||||
for (const [kind, ids] of [["isolated_source", workspaceIds], ["isolated_project_source", projectIds]] as const) {
|
||||
for (const scopeId of ids) if (!existing.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
|
||||
}
|
||||
}
|
||||
// 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 });
|
||||
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);
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds);
|
||||
}
|
||||
} catch (error) { failure ??= error; }
|
||||
return failure;
|
||||
});
|
||||
if (denied) throw denied;
|
||||
}
|
||||
|
||||
/** Selection validates writable source scope without registering a reader or changing policy. */
|
||||
export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId: string;
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
|
||||
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
|
||||
const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
|
||||
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
|
||||
@@ -297,9 +466,14 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function reserveRootPolicy(tx: DbTransaction, companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
|
||||
const canonicalCwd = await canonicalRepositoryRoot(cwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
const [existing] = await tx.select().from(executionWorkspaceRepositoryRoots).where(and(
|
||||
eq(executionWorkspaceRepositoryRoots.companyId, companyId), eq(executionWorkspaceRepositoryRoots.rootKey, rootKey)));
|
||||
if (existing && (existing.canonicalCwd !== canonicalCwd || existing.mode !== mode)) throw forbidden(lowTrustRepositoryMessage);
|
||||
const reservations = await tx.select().from(executionWorkspaceRepositoryRoots)
|
||||
.where(eq(executionWorkspaceRepositoryRoots.companyId, companyId));
|
||||
const existing = reservations.find(entry => entry.rootKey === rootKey);
|
||||
const overlapping = reservations.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
|
||||
if ((existing && existing.canonicalCwd !== canonicalCwd)
|
||||
|| overlapping.some(entry => entry.mode !== mode || createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
|
||||
throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
if (mode === "anonymous_only") {
|
||||
// Legacy receipts predate root reservations. Even pending/staging work
|
||||
// can publish after admission, so no managed intent in this root is safe.
|
||||
@@ -331,7 +505,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!existing) for (const peer of managed) {
|
||||
for (const peer of managed) {
|
||||
if (peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)) throw forbidden(lowTrustRepositoryMessage);
|
||||
}
|
||||
}
|
||||
@@ -342,9 +516,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function authorizeRootAccess(tx: DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
|
||||
}) {
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, input);
|
||||
const readers = entries.filter(entry => entry.kind === "reader");
|
||||
if (input.reader && !readers.some(reader => reader.scopeId === input.reader!.runId)) {
|
||||
const { canonicalCwd, rootKey, entries, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await resolveRepositoryRootSources(tx, input);
|
||||
const readers = overlappingEntries.filter(entry => entry.kind === "reader");
|
||||
if (input.reader && !entries.some(entry => entry.kind === "reader" && entry.scopeId === input.reader!.runId)) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
readers.push({ id: randomUUID(), companyId: input.companyId, rootKey, canonicalCwd, kind: "reader", scopeId: input.reader.runId,
|
||||
issueId: input.reader.issueId, agentId: input.reader.agentId, responsibleUserId: run?.responsibleUserId ?? null, createdAt: new Date(), updatedAt: new Date() });
|
||||
@@ -356,15 +530,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
&& sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId)
|
||||
&& readers.every(reader => reader.issueId === input.reader!.issueId);
|
||||
for (const reader of readers) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, input.companyId))) : [];
|
||||
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, input.companyId))) : [];
|
||||
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|
||||
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== reader.issueId) {
|
||||
throw forbidden("Repository root retains a reader whose identity is no longer available");
|
||||
}
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId,
|
||||
agentId: reader.agentId!, runId: reader.scopeId, onBehalfOfUserId: reader.responsibleUserId === "local-board" ? null : reader.responsibleUserId };
|
||||
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
|
||||
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
|
||||
if (ownAdmissionOnly) {
|
||||
@@ -393,23 +559,18 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId });
|
||||
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
|
||||
}
|
||||
for (const sourceId of sourceIds) {
|
||||
if (!entries.some(entry => entry.kind === "source" && entry.scopeId === sourceId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "source", scopeId: sourceId });
|
||||
}
|
||||
}
|
||||
for (const sourceId of sourceProjectIds) {
|
||||
if (!entries.some(entry => entry.kind === "project_source" && entry.scopeId === sourceId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "project_source", scopeId: sourceId });
|
||||
}
|
||||
}
|
||||
for (const sourceId of sharedProjectIds) {
|
||||
if (!entries.some(entry => entry.kind === "shared_project_source" && entry.scopeId === sourceId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "shared_project_source", scopeId: sourceId });
|
||||
for (const [kind, ids] of [["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds],
|
||||
["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]] as const) {
|
||||
for (const scopeId of ids) {
|
||||
if (!entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const reader of readers) {
|
||||
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
|
||||
if (reader.rootKey === rootKey && !entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) {
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function reserveRootAccess(input: Parameters<typeof authorizeRootAccess>[1]) {
|
||||
|
||||
@@ -28,6 +28,10 @@ import { readNativeWorkspaceSyncReference, readNativeWorkspaceSyncLocalCwd } fro
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { materializeNativeChatTaskRoot } from "../native-runtime/native-chat-workspace.js";
|
||||
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
|
||||
import {
|
||||
assertIsolatedWorkspaceSourceReadAccess,
|
||||
retainIsolatedWorkspaceSourceAccess,
|
||||
} from "../execution-workspace-repositories.js";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
@@ -468,6 +472,21 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
agentId: agent.id,
|
||||
},
|
||||
);
|
||||
const isolatedWorkspaceSourceActor = { type: "agent" as const, source: "agent_jwt" as const, companyId: agent.companyId,
|
||||
agentId: agent.id, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
// Capture the source's ownership before Git can copy tracked bytes. Keep the
|
||||
// server-owned snapshot so a concurrent deletion cannot erase copied privacy.
|
||||
const isolatedWorkspaceSourceAccess = !isDotRun && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId
|
||||
&& (latestWorkspaceStrategyType === "git_worktree"
|
||||
|| (workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"))
|
||||
? await assertIsolatedWorkspaceSourceReadAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd: executionWorkspaceBase.baseCwd, actor: isolatedWorkspaceSourceActor,
|
||||
}) : null;
|
||||
const beforeIsolatedSourceCopy = isolatedWorkspaceSourceAccess
|
||||
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId, sourceCwd, targetCwd,
|
||||
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
|
||||
}) : undefined;
|
||||
const {
|
||||
executionWorkspace,
|
||||
reusedExecutionWorkspace,
|
||||
@@ -535,6 +554,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
.enableWorkspaceDirtyQuarantineRepair,
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
})
|
||||
: null,
|
||||
realizeWorkspace: () =>
|
||||
@@ -567,9 +587,20 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
|
||||
.enableWorkspaceDirtyQuarantineRepair,
|
||||
recorder: workspaceOperationRecorder,
|
||||
resolveGitAuth: workspaceGitAuthProvider,
|
||||
beforeSourceCopy: beforeIsolatedSourceCopy,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (isolatedWorkspaceSourceAccess && executionWorkspace.strategy === "git_worktree") {
|
||||
await retainIsolatedWorkspaceSourceAccess(db, {
|
||||
companyId: agent.companyId,
|
||||
sourceCwd: executionWorkspaceBase.baseCwd,
|
||||
targetCwd: executionWorkspace.cwd,
|
||||
actor: isolatedWorkspaceSourceActor,
|
||||
captured: isolatedWorkspaceSourceAccess,
|
||||
issueId: issueId ?? undefined,
|
||||
});
|
||||
}
|
||||
const resolvedProjectId =
|
||||
reusableExistingExecutionWorkspace
|
||||
? reusableExistingExecutionWorkspace.projectId
|
||||
|
||||
@@ -37,6 +37,17 @@ import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-poli
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { lockExecutionWorkspaceRootProvenance, preserveConfiguredProjectWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
|
||||
|
||||
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
/** Root provenance fences precede project/workspace locks in every caller. */
|
||||
async function lockProjectRootMutation(tx: ProjectTransaction, projectId: string) {
|
||||
const [identity] = await tx.select({ companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId));
|
||||
if (!identity) return null;
|
||||
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
|
||||
return tx.select().from(projects).where(eq(projects.id, projectId)).for("update").then(rows => rows[0] ?? null);
|
||||
}
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
@@ -914,7 +925,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
|
||||
replaceRepositories: async (projectId: string, repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals | null> => {
|
||||
return db.transaction(async (tx) => {
|
||||
const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
const project = await lockProjectRootMutation(tx, projectId);
|
||||
if (!project) return null;
|
||||
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||
const existing = await service.listWorkspaces(projectId);
|
||||
@@ -1055,13 +1066,17 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
const [retained] = await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
||||
remove: async (id: string) => db.transaction(async tx => {
|
||||
const project = await lockProjectRootMutation(tx, id);
|
||||
if (!project) return null;
|
||||
const [retained] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.projectId, id)).limit(1);
|
||||
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
|
||||
const [row] = await db.delete(projects).where(eq(projects.id, id)).returning();
|
||||
const workspaces = await tx.select().from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
|
||||
for (const workspace of workspaces) await preserveConfiguredProjectWorkspaceRootProvenance(tx, workspace);
|
||||
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
|
||||
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
|
||||
},
|
||||
}),
|
||||
|
||||
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
|
||||
const rows = await db
|
||||
@@ -1174,7 +1189,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
data: UpdateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
if (!await lockProjectRootMutation(tx, projectId)) return null;
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
@@ -1184,6 +1199,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
@@ -1241,6 +1257,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
: data.metadata;
|
||||
}
|
||||
|
||||
if ((["cwd", "sourceType", "repoUrl", "remoteProvider", "remoteWorkspaceRef", "sharedWorkspaceKey"] as const)
|
||||
.some(key => patch[key] !== undefined && patch[key] !== existing[key])) {
|
||||
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary === true) {
|
||||
await tx
|
||||
@@ -1327,7 +1348,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
|
||||
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
|
||||
if (!await lockProjectRootMutation(tx, projectId)) return null;
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
@@ -1337,9 +1358,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
const row = await tx
|
||||
.delete(projectWorkspaces)
|
||||
|
||||
@@ -3323,6 +3323,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
enableWorkspaceDirtyQuarantineRepair?: boolean;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
resolveGitAuth?: GitRemoteAuthProvider | null;
|
||||
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
}): Promise<RealizedExecutionWorkspace> {
|
||||
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
||||
const strategyType = asString(rawStrategy.type, "project_primary");
|
||||
@@ -3404,6 +3405,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
},
|
||||
);
|
||||
}
|
||||
await input.beforeSourceCopy?.(repoRoot, worktreePath);
|
||||
let pendingForwardBranchReconcile: PendingForwardBranchReconcile | null = null;
|
||||
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
|
||||
? rawStrategy.baseRef
|
||||
@@ -3426,6 +3428,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||
|
||||
async function reuseExistingWorktree(reusablePath: string, effectiveBranchName = branchName, extraWarnings: string[] = []) {
|
||||
if (reusablePath !== worktreePath) await input.beforeSourceCopy?.(repoRoot, reusablePath);
|
||||
// An exact-branch attach must never move the requested branch, so skip
|
||||
// the unstarted-worktree fast-forward that template-derived reuse gets.
|
||||
const refresh = currentBaseRefSha && !requestedExistingBranch
|
||||
@@ -3773,6 +3776,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
enableWorkspaceDirtyQuarantineRepair?: boolean;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
resolveGitAuth?: GitRemoteAuthProvider | null;
|
||||
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
|
||||
}): Promise<RealizedExecutionWorkspace | null> {
|
||||
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
|
||||
if (!cwd) return null;
|
||||
@@ -3825,6 +3829,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
);
|
||||
}
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], baseCwd);
|
||||
await input.beforeSourceCopy?.(repoRoot, realized.worktreePath ?? cwd);
|
||||
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
|
||||
if (await directoryExists(cwd)) {
|
||||
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
||||
|
||||
Reference in new issue
Block a user