mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02: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
@@ -41,11 +41,11 @@ Independent repository acquisition does not inherit private-repository authority
|
||||
|
||||
Admission with no repository requests remains heartbeat-owned and requires no additional repository-mutation permission. Low-trust admission still rejects managed acquisition bytes in the same physical folder. Immutable recovery uses its admitted folder and skips new repository acquisition even if the task's current binding has changed. A reservation-only recovery hook fences legacy admitted host roots before input capture or provider dispatch. Local recovery uses its immutable local root; sandbox recovery obtains the host path from its verified immutable sync descriptor. SSH recovery uses its remote filesystem directly and does not require a host snapshot descriptor. Older projectless native runs may use the exact persisted run-ID workspace binding without a workspace row.
|
||||
|
||||
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Known reservations do not rescan historical workspace paths. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints.
|
||||
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Reservations also compare retained overlapping parent/child roots; component-boundary siblings remain independent. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints.
|
||||
|
||||
Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories, whose publication callback rechecks the intersection after staging. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement.
|
||||
|
||||
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. File listing, previews and downloads use the same read-only physical-source resolution and also check current configured project-folder aliases. Those reads create no policy or reader records. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
|
||||
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy without granting writes to the original source; actual shared target owners still require write authority. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
|
||||
|
||||
Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_r
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
rootKey: text("root_key").notNull(),
|
||||
canonicalCwd: text("canonical_cwd").notNull(),
|
||||
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "repository">().notNull(),
|
||||
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "isolated_source" | "isolated_project_source" | "repository">().notNull(),
|
||||
scopeId: uuid("scope_id").notNull(),
|
||||
issueId: uuid("issue_id"),
|
||||
agentId: uuid("agent_id"),
|
||||
|
||||
@@ -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