mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Retain source projects and repository receipt origins
Fence configured folder owners before capture and publication. Preserve valid task continuation after configuration removal while keeping revoked or deleted project scopes private. Record origin only at new receipt creation to distinguish unavailable independent checkouts from legacy aliases. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
93b1a95969
commit
2503d8dda4
4 files changed
+115
-28
No files matched your search
+1
-1
@@ -753,6 +753,6 @@ Migration [0334_right_hannibal_king.sql](../packages/db/src/migrations/0334_righ
|
||||
|
||||
Migration [0335_oval_screwball.sql](../packages/db/src/migrations/0335_oval_screwball.sql) adds internal `execution_workspace_repository_roots` records. A company and hash of its canonical physical path uniquely identify a sticky `anonymous_only` or `managed` acquisition reservation. Short transactions serialize reservations with repository requests and credential promotion, preventing private publication after low-trust admission checks. Records have no workspace foreign key: mutable workspace metadata, relocation, archival or deletion cannot release a physical folder still accessible to an admitted run. There is no automatic mode change or reservation deletion.
|
||||
|
||||
Migration [0336_chilly_hulk.sql](../packages/db/src/migrations/0336_chilly_hulk.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain workspace identities; reader entries retain the exact admitted run, task, agent and responsible user. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
|
||||
Migration [0336_chilly_hulk.sql](../packages/db/src/migrations/0336_chilly_hulk.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution and configured workspace identities; reader entries retain the exact admitted run, task, agent and responsible user. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
|
||||
|
||||
No migration scans or copies existing agent home directories. Keep admitted native descriptors and recovery references until compatible controllers finish their persistence obligations. See [the architecture rollout contract](plans/2026-10-09-task-workspaces.md).
|
||||
@@ -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">().notNull(),
|
||||
kind: text("kind").$type<"source" | "reader" | "project_source" | "repository">().notNull(),
|
||||
scopeId: uuid("scope_id").notNull(),
|
||||
issueId: uuid("issue_id"),
|
||||
agentId: uuid("agent_id"),
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from "node:path";
|
||||
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, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { assertExecutionWorkspaceRootReadAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
@@ -154,7 +154,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["retained", "legacy"] as const)("keeps independent copies of the same repository separate (%s provenance)", async provenance => {
|
||||
it.each(["retained", "legacy", "known_origin_removed"] as const)("keeps independent copies of the same repository separate (%s provenance)", async provenance => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const peerCwd = path.join(root, peer.issueId);
|
||||
await mkdir(peerCwd);
|
||||
@@ -172,6 +172,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(peerCheckout.cwd).not.toBe(privateCheckout.cwd);
|
||||
expect(await readFile(path.join(peerCheckout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
if (provenance === "legacy") await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
if (provenance === "known_origin_removed") await rm(f.admission.cwd, { recursive: true });
|
||||
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
|
||||
acquisition.materialize.mockImplementationOnce(actual.materializeManagedProjectWorkspace);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toHaveLength(1);
|
||||
@@ -183,6 +184,62 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual(beforeRead);
|
||||
});
|
||||
|
||||
it("checks configured private aliases against both admitted agent and responsible user before capture and publication", async () => {
|
||||
const f = await fixture();
|
||||
const alias = path.join(root, `${f.issueId}-configured-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private configured files", visibility: "private" }).returning();
|
||||
const [configured] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Configured alias", cwd: alias }).returning();
|
||||
await writeFile(path.join(f.admission.cwd, "private-configured.txt"), "Private configured project bytes\n");
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId });
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]);
|
||||
const [grant] = await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id,
|
||||
subjectType: "user", subjectId: f.userId }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
const moved = path.join(root, `${f.issueId}-configured-moved`);
|
||||
await mkdir(moved);
|
||||
await db.update(projectWorkspaces).set({ cwd: moved }).where(eq(projectWorkspaces.id, configured.id));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, configured.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).resolves.toBeUndefined();
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
expect(acquisition.materialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains execution-source project privacy after its workspace project pointer is cleared", async () => {
|
||||
const f = await fixture();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Execution source owner", visibility: "private" }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId });
|
||||
const [grant] = await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.update(executionWorkspaces).set({ projectId: null }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/source project/);
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await db.delete(projects).where(eq(projects.id, project.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project/);
|
||||
});
|
||||
|
||||
it("rechecks retained own-workspace privacy without adding an empty-admission membership gate", async () => {
|
||||
const f = await fixture();
|
||||
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId));
|
||||
|
||||
@@ -120,7 +120,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
if (peerRoot === canonicalCwd) sourceIds.add(workspace.id);
|
||||
}
|
||||
}
|
||||
const inventory = await tx.select({ workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
|
||||
const inventory = await tx.select({ id: executionWorkspaceRepositories.id, workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
|
||||
identity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories)
|
||||
.innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
@@ -138,6 +138,11 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
// aggregate owns only task-repo-* publications and their retained provenance.
|
||||
for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name);
|
||||
}
|
||||
// Birth records distinguish new, fenced acquisitions from legacy receipts.
|
||||
// Never infer origin for an old receipt merely because it is encountered now.
|
||||
const repositoryOrigins = await tx.select({ id: executionWorkspaceRepositoryAccess.scopeId }).from(executionWorkspaceRepositoryAccess)
|
||||
.where(and(eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.kind, "repository")));
|
||||
const knownOrigins = new Set(repositoryOrigins.map(origin => origin.id));
|
||||
const recognizedNames = new Set<string>();
|
||||
for (const peer of inventory) {
|
||||
const name = path.basename(taskRepositoryRelativePath(peer.identity));
|
||||
@@ -152,7 +157,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
// 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
|
||||
|| (published && peerRoot === null)
|
||||
|| (published && peerRoot === null && !knownOrigins.has(peer.id))
|
||||
|| (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
|
||||
if (matches) {
|
||||
sourceIds.add(peer.workspaceId);
|
||||
@@ -160,33 +165,46 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
}
|
||||
}
|
||||
if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance");
|
||||
return { canonicalCwd, rootKey, entries, sourceIds };
|
||||
// Configured folders can contain project files before any task has an
|
||||
// execution workspace. Read access must include their current aliases too.
|
||||
const configured = await tx.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd })
|
||||
.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));
|
||||
if (sourceIds.size) {
|
||||
const owners = await tx.select({ projectId: executionWorkspaces.projectId }).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);
|
||||
}
|
||||
for (const source of configured) {
|
||||
if (!source.cwd || sourceProjectIds.has(source.projectId)) continue;
|
||||
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (sourceRoot !== canonicalCwd) continue;
|
||||
sourceProjectIds.add(source.projectId);
|
||||
}
|
||||
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds };
|
||||
}
|
||||
|
||||
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>) {
|
||||
for (const projectId of projectIds) {
|
||||
const [project] = await db.select({ id: projects.id }).from(projects).where(and(
|
||||
eq(projects.id, projectId), eq(projects.companyId, companyId), await projectReadSqlCondition(db, actor)));
|
||||
if (!project) throw forbidden("Workspace source project is no longer accessible");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** File readers share admission's source closure, without changing root policy. */
|
||||
export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
|
||||
}): Promise<void> {
|
||||
const { canonicalCwd, sourceIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
|
||||
const { sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
|
||||
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
|
||||
// Configured folders can contain project files before any task has an
|
||||
// execution workspace. Read access must include their current aliases too.
|
||||
const configured = await db.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd })
|
||||
.from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId));
|
||||
const checkedProjects = new Set<string>();
|
||||
for (const source of configured) {
|
||||
if (!source.cwd || checkedProjects.has(source.projectId)) continue;
|
||||
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (sourceRoot !== canonicalCwd) continue;
|
||||
const [project] = await db.select({ id: projects.id }).from(projects).where(and(
|
||||
eq(projects.id, source.projectId), eq(projects.companyId, input.companyId), await projectReadSqlCondition(db, input.actor)));
|
||||
if (!project) throw forbidden("Workspace source project is no longer accessible");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, input.actor, input.companyId, source.projectId);
|
||||
checkedProjects.add(source.projectId);
|
||||
}
|
||||
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
|
||||
}
|
||||
|
||||
/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */
|
||||
@@ -267,7 +285,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function authorizeRootAccess(tx: DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; preserveOwnAdmission?: boolean;
|
||||
}) {
|
||||
const { canonicalCwd, rootKey, entries, sourceIds } = await resolveRepositoryRootSources(tx, input);
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(tx, input);
|
||||
const readers = entries.filter(entry => entry.kind === "reader");
|
||||
if (input.reader && !readers.some(reader => reader.scopeId === input.reader!.runId)) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
@@ -290,6 +308,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
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 };
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds);
|
||||
if (ownAdmissionOnly) {
|
||||
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
|
||||
@@ -311,12 +330,20 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.actor) for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
|
||||
if (input.actor) {
|
||||
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds);
|
||||
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 reader of readers) {
|
||||
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
|
||||
}
|
||||
@@ -390,6 +417,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
return row;
|
||||
}
|
||||
const [row] = await tx.insert(executionWorkspaceRepositories).values({ companyId: input.companyId, executionWorkspaceId: workspace.id, requestedByIssueId: input.issueId, ...source, relativePath: taskRepositoryRelativePath(source.repositoryIdentity), requestedRef, requestKey: parsed.requestKey, requestKeys: [parsed.requestKey] }).returning();
|
||||
const canonicalCwd = await canonicalRepositoryRoot(workspace.cwd);
|
||||
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId,
|
||||
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), canonicalCwd, kind: "repository", scopeId: row.id });
|
||||
await tx.insert(activityLog).values({ companyId: input.companyId, actorType: input.actor.type === "agent" ? "agent" : "user", actorId: input.actor.agentId ?? input.actor.userId ?? "local-board", action: "workspace.repository.requested", entityType: "execution_workspace", entityId: workspace.id, runId: input.actor.runId ?? null, responsibleUserId: userId, details: { repositoryId: row.id, issueId: input.issueId, applies: "next_normal_admission" } });
|
||||
return row;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user