mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
Enforce shared-root write authority and recover low-disk exports safely
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
52ea8711ef
commit
1b98e5df2f
10 files changed
+352
-36
No files matched your search
@@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns } from "@paperclipai/db";
|
||||
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
@@ -33,6 +33,30 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
|
||||
});
|
||||
it("keeps projectless protected-folder aliases readable while enforcing scoped write grants", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await server.db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected folder", cwd: f.workspace });
|
||||
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
|
||||
name: "Projectless alias", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning();
|
||||
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
|
||||
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
||||
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
|
||||
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
|
||||
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
|
||||
await expect(call("select_workspace", selection)).rejects.toThrow(/protected|not authorized/);
|
||||
await expect(call("prepare_repository", repository)).rejects.toThrow(/protected|not authorized/);
|
||||
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
|
||||
membershipRole: "member", status: "active" });
|
||||
await server.db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId!, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]);
|
||||
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
|
||||
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
});
|
||||
|
||||
it("does not expose repository preparation or workspace selection to Ask mode", async () => {
|
||||
const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true });
|
||||
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow();
|
||||
@@ -88,7 +112,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
||||
await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId));
|
||||
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } });
|
||||
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
|
||||
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).rejects.toThrow(/outside.*boundary/i);
|
||||
// Project permission cannot expose the unrelated task's files through a
|
||||
// shared physical root. Give that fixture its own folder before testing
|
||||
// the permitted source-only preparation path.
|
||||
@@ -96,6 +120,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
||||
const unrelatedCwd = await mkdtemp(join(dirname(f.workspace), "unrelated-workspace-"));
|
||||
await server.db.update(executionWorkspaces).set({ cwd: unrelatedCwd }).where(eq(executionWorkspaces.id, unrelated.id));
|
||||
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
|
||||
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
|
||||
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
{ kind: "source", scopeId: f.workspaceId, cwd: await fs.realpath(oldCwd) },
|
||||
{ kind: "project_source", scopeId: project.id, cwd: await fs.realpath(oldCwd) },
|
||||
]));
|
||||
expect(retained.every(entry => ["source", "project_source"].includes(entry.kind))).toBe(true);
|
||||
expect(retained.every(entry => ["source", "project_source", "shared_project_source"].includes(entry.kind))).toBe(true);
|
||||
expect(await fs.readFile(path.join(oldCwd, "private.txt"), "utf8")).toBe("Retained old files");
|
||||
expect(await service.update(f.workspaceId, { metadata: null })).toMatchObject({
|
||||
metadata: { _issuePrivacySources: { [ancestor.id]: true, [f.issueId]: true } },
|
||||
|
||||
@@ -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, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
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 { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
|
||||
@@ -228,6 +228,94 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
});
|
||||
|
||||
it("requires the admitted actor's write grant for a projectless alias of a protected configured root", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.principalId, f.userId));
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected shared root",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const alias = path.join(root, `${f.issueId}-protected-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected source", cwd: alias }).returning();
|
||||
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor });
|
||||
await expect(read()).resolves.toBeUndefined();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
|
||||
await expect(f.request({ kind: "url", url: "https://github.com/public/writable" })).rejects.toThrow(/protected|not authorized/);
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } });
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(read()).resolves.toBeUndefined();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
|
||||
});
|
||||
|
||||
it("keeps genuine isolated Git copies read-only against their protected source project", async () => {
|
||||
const f = await fixture();
|
||||
const sourceRoot = path.join(root, `${f.issueId}-isolated-source`);
|
||||
await mkdir(sourceRoot);
|
||||
const exec = promisify(execFileCallback);
|
||||
await exec("git", ["init", "--initial-branch=main", sourceRoot]);
|
||||
await writeFile(path.join(sourceRoot, "README.md"), "Protected source\n");
|
||||
await exec("git", ["-C", sourceRoot, "add", "."]);
|
||||
await exec("git", ["-C", sourceRoot, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
|
||||
await exec("git", ["-C", sourceRoot, "worktree", "add", "-b", "isolated-task", f.admission.cwd]);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected copy source",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original", cwd: sourceRoot }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
|
||||
// Physical configured ownership overrides a misleading isolation label.
|
||||
await db.update(projectWorkspaces).set({ cwd: f.admission.cwd }).where(eq(projectWorkspaces.id, source.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
|
||||
});
|
||||
|
||||
it.each(["move", "delete"] as const)("retains protected shared execution-source write scope after %s", async change => {
|
||||
const f = await fixture(), source = await standardPeer(f);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected execution owner",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, source.workspace.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
|
||||
const grants = await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]).returning();
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
if (change === "move") {
|
||||
const moved = path.join(root, `${source.issueId}-shared-moved`);
|
||||
await mkdir(moved);
|
||||
await executionWorkspaceService(db).update(source.workspace.id, { cwd: moved, projectId: null });
|
||||
} else await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.workspace.id));
|
||||
for (const grant of grants) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized|workspace access/);
|
||||
});
|
||||
|
||||
it("does not require historical readers to retain write grants for another authorized writer", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Shared source writers",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Shared", cwd: f.admission.cwd });
|
||||
const prior = await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
|
||||
]).returning();
|
||||
await f.service.prepareForAdmission(f.admission);
|
||||
await db.insert(principalPermissionGrants).values([
|
||||
{ companyId: f.companyId, principalType: "agent", principalId: peer.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
|
||||
{ companyId: f.companyId, principalType: "user", principalId: peer.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
|
||||
]);
|
||||
for (const grant of prior) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
|
||||
await expect(f.service.guardForPublication(peer.admission)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
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`);
|
||||
|
||||
@@ -172,29 +172,44 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
// 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>();
|
||||
if (sourceIds.size) {
|
||||
const owners = await tx.select({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
|
||||
const owners = await tx.select({ 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);
|
||||
for (const owner of owners) if (owner.projectId) {
|
||||
sourceProjectIds.add(owner.projectId);
|
||||
if (owner.strategyType === "git_worktree") isolatedProjectIds.add(owner.projectId);
|
||||
else sharedProjectIds.add(owner.projectId);
|
||||
}
|
||||
}
|
||||
for (const source of configured) {
|
||||
if (!source.cwd || sourceProjectIds.has(source.projectId)) continue;
|
||||
if (!source.cwd || sharedProjectIds.has(source.projectId)) continue;
|
||||
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (sourceRoot !== canonicalCwd) continue;
|
||||
sourceProjectIds.add(source.projectId);
|
||||
sharedProjectIds.add(source.projectId);
|
||||
}
|
||||
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds };
|
||||
// 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)) {
|
||||
sharedProjectIds.add(entry.scopeId);
|
||||
}
|
||||
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds };
|
||||
}
|
||||
|
||||
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>) {
|
||||
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
|
||||
write?: { sharedProjectIds: Set<string> } & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">) {
|
||||
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);
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId, {
|
||||
...write, write: write?.sharedProjectIds.has(projectId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,10 +231,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 } = await resolveRepositoryRootSources(tx, {
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, {
|
||||
companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
|
||||
});
|
||||
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds]] as const) {
|
||||
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds]] 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 });
|
||||
@@ -237,6 +252,15 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
|
||||
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
} & 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);
|
||||
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds });
|
||||
}
|
||||
|
||||
/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */
|
||||
export function executionWorkspaceRepositoryService(db: Db) {
|
||||
async function catalog(companyId: string, userId: string | null, localTrusted: boolean) {
|
||||
@@ -308,9 +332,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
type RootReader = { runId: string; issueId: string; agentId: string };
|
||||
async function authorizeRootAccess(tx: DbTransaction, input: {
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; preserveOwnAdmission?: boolean;
|
||||
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
|
||||
}) {
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(tx, input);
|
||||
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 [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
@@ -333,7 +357,8 @@ 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);
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
|
||||
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
|
||||
if (ownAdmissionOnly) {
|
||||
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
|
||||
@@ -356,7 +381,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
}
|
||||
if (input.actor) {
|
||||
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds);
|
||||
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
|
||||
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) {
|
||||
@@ -369,6 +395,11 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
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 reader of readers) {
|
||||
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
|
||||
}
|
||||
@@ -427,7 +458,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
await reserveRootPolicy(tx, input.companyId, workspace.cwd, anonymousOnly ? "anonymous_only" : "managed");
|
||||
}
|
||||
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
|
||||
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor });
|
||||
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, issueId: input.issueId });
|
||||
const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`)));
|
||||
for (const row of existing) {
|
||||
assertAcquisitionReceipt(row, anonymousOnly);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseObject } from "../adapters/utils.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
|
||||
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
|
||||
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess } from "./execution-workspace-repositories.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
|
||||
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
@@ -1412,6 +1412,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
));
|
||||
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId });
|
||||
if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, {
|
||||
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor,
|
||||
issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
|
||||
});
|
||||
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user