mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
fix: preserve task workspace access and continuation across edge cases
Address Apex review: authorize board mutations with complete task state, preserve pending choices through first binding, resume retained workspaces after source removal, and keep valid output when sparse capture encounters unsafe symlinks. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d31081ef1a
commit
cb0e60c510
8 files changed
+200
-17
No files matched your search
@@ -51,10 +51,34 @@ describe("workspace sparse checkpoint", () => {
|
||||
expect(await fs.readFile(path.join(host, "cache", "ignored"), "utf8")).toBe("private");
|
||||
await disposeDirectorySnapshot(f.baseline);
|
||||
});
|
||||
it("rejects escaping links and changed payloads before merge", async () => {
|
||||
const f = await fixture(); await fs.symlink("../../secret", path.join(f.root, "escape"));
|
||||
await expect(f.capture()).rejects.toThrow("Unsafe workspace archive symlink");
|
||||
await disposeDirectorySnapshot(f.baseline);
|
||||
it.each(["absolute", "escaping"])("omits an %s link while restoring ordinary changes and safe links", async (kind) => {
|
||||
const f = await fixture();
|
||||
const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
|
||||
const outside = path.join(f.temp, "secret"); await fs.writeFile(outside, "outside bytes");
|
||||
await fs.symlink(kind === "absolute" ? outside : "../secret", path.join(f.root, "unsafe"));
|
||||
await fs.writeFile(path.join(f.root, "unchanged"), "edited");
|
||||
await fs.writeFile(path.join(f.root, "new"), "new output");
|
||||
await fs.unlink(path.join(f.root, "deleted"));
|
||||
await fs.symlink("new", path.join(f.root, "safe"));
|
||||
const result = await f.capture();
|
||||
expect(result.snapshot.entries.has("unsafe")).toBe(false);
|
||||
await expect(fs.lstat(path.join(result.payload, "unsafe"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } });
|
||||
expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("edited");
|
||||
expect(await fs.readFile(path.join(host, "new"), "utf8")).toBe("new output");
|
||||
expect(await fs.readlink(path.join(host, "safe"))).toBe("new");
|
||||
await expect(fs.lstat(path.join(host, "unsafe"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.lstat(path.join(host, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readFile(outside, "utf8")).toBe("outside bytes");
|
||||
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
|
||||
});
|
||||
it("rejects an unsafe link forged into a checkpoint manifest before merge", async () => {
|
||||
const f = await fixture(); const result = await f.capture();
|
||||
const entries = new Map(result.snapshot.entries);
|
||||
entries.set("unsafe", { kind: "symlink", target: "../secret" });
|
||||
await expect(validateCheckpointPayload({ baseline: f.baseline, snapshot: { ...result.snapshot, entries }, payload: result.payload }))
|
||||
.rejects.toMatchObject({ code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
|
||||
await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline);
|
||||
});
|
||||
it("replays a checkpoint after interruption and preserves a concurrently edited deletion", async () => {
|
||||
const f = await fixture(); const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true });
|
||||
@@ -131,8 +155,17 @@ it("uses sparse native transfer end-to-end, including replay and unsupported-run
|
||||
await fs.writeFile(path.join(cache, generation, "workspace.tar"), "corrupt cache");
|
||||
const second = await prepare();
|
||||
await fs.writeFile(path.join(remote, "new"), "changed"); await fs.unlink(path.join(remote, "deleted"));
|
||||
const outside = path.join(f.temp, "outside"); await fs.writeFile(outside, "private");
|
||||
await fs.symlink(outside, path.join(remote, "absolute-link"));
|
||||
await fs.symlink("../outside", path.join(remote, "escaping-link"));
|
||||
await fs.symlink("new", path.join(remote, "safe-link"));
|
||||
await second.restoreWorkspace(); expect(payloadBytes).toBe(7);
|
||||
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("changed");
|
||||
expect(await fs.readlink(path.join(f.root, "safe-link"))).toBe("new");
|
||||
for (const name of ["absolute-link", "escaping-link"]) {
|
||||
await expect(fs.lstat(path.join(f.root, name))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
expect(await fs.readFile(outside, "utf8")).toBe("private");
|
||||
await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
supportsCheckpoint = false;
|
||||
const third = await prepare(); await fs.writeFile(path.join(remote, "new"), "fallback");
|
||||
|
||||
@@ -69,7 +69,9 @@ async function checkpointMain(
|
||||
else if (before.isSymbolicLink()) {
|
||||
const target = await fs.readlink(source);
|
||||
const resolved = path.resolve(path.dirname(source), target);
|
||||
if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) throw new Error("Unsafe workspace archive symlink");
|
||||
// Match confined full exports: omit the unsafe entry without losing
|
||||
// unrelated changed files. Never follow the link or copy its target.
|
||||
if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) continue;
|
||||
entry = { kind: "symlink", target };
|
||||
} else continue;
|
||||
if (fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint capture");
|
||||
|
||||
@@ -222,6 +222,59 @@ suite("task project repository provisioning", () => {
|
||||
}
|
||||
}, 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");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await writeFile(path.join(cwd, "work.txt"), "Retained task work");
|
||||
await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix: `D${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: "Source policy" });
|
||||
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Source", sourceType: "local_path", cwd, isPrimary: true });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Continue retained work", status: "todo", assigneeAgentId: agentId,
|
||||
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "shared" } } });
|
||||
const admit = async () => {
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
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))!;
|
||||
};
|
||||
let bindingId: string | null = null;
|
||||
if (bound) {
|
||||
expect(await admit()).toMatchObject({ status: "succeeded", error: null });
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
bindingId = task.executionWorkspaceId;
|
||||
expect(bindingId).not.toBeNull();
|
||||
}
|
||||
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, sourceId));
|
||||
const result = await admit();
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === result.id);
|
||||
if (bound) {
|
||||
expect(result).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(await realpath(calls[0]![0].context.paperclipWorkspace.cwd)).toBe(await realpath(cwd));
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, bindingId!));
|
||||
expect(task.executionWorkspaceId).toBe(bindingId);
|
||||
expect(workspace).toMatchObject({ projectId, projectWorkspaceId: null });
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Retained task work");
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, projectId));
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
const callsBeforeRevocation = execute.mock.calls.length;
|
||||
expect(await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } })).toBeNull();
|
||||
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
||||
expect(execute.mock.calls).toHaveLength(callsBeforeRevocation);
|
||||
} else {
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error).toContain("Workspace source is unavailable or inaccessible");
|
||||
expect(calls).toHaveLength(0);
|
||||
}
|
||||
}, 40_000);
|
||||
|
||||
it.each([
|
||||
{ code: "workspace_git_scan_timeout", scenario: "temporary", retryable: true },
|
||||
{ code: "workspace_git_scan_saturated", scenario: "temporary", retryable: true },
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, createDb, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { taskWorkspaceRoutes } from "../routes/task-workspaces.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { canActorReadExecutionWorkspace } from "../services/authorization.js";
|
||||
import { deleteCompany } from "../services/company-deletion.js";
|
||||
@@ -189,6 +193,78 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await expect(svc.selectTaskWorkspace({ ...request, requestKey: "stale" })).rejects.toThrow("binding changed");
|
||||
});
|
||||
|
||||
it("preserves a queued next-run choice through first binding and its original retry receipt", async () => {
|
||||
const f = await fixture(), svc = executionWorkspaceService(db);
|
||||
const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
||||
mode: "shared_workspace", strategyType: "project_primary", name: "Next folder", cwd: `/tmp/next-${f.issueId}` }).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "First worker", adapterType: "process" }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id,
|
||||
status: "running", invocationSource: "on_demand", contextSnapshot: { issueId: f.issueId } }).returning();
|
||||
const selectionRequest = { ...f, selection: { kind: "existing" as const, workspaceId: nextWorkspace.id },
|
||||
expectedBindingRevision: 0, requestKey: "choose-during-first-admission" };
|
||||
await svc.selectTaskWorkspace(selectionRequest);
|
||||
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
|
||||
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
|
||||
workspace: { id: f.workspaceId }, bindingRevision: 1,
|
||||
pendingSelection: { expectedBindingRevision: 1, intent: { request: { expectedBindingRevision: 0 } } },
|
||||
});
|
||||
expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "scheduled", bindingRevision: 1 });
|
||||
await expect(svc.selectTaskWorkspace({ ...selectionRequest, expectedBindingRevision: 1 })).rejects.toThrow("different intent");
|
||||
const admission = { ...f, runId: randomUUID() };
|
||||
await expect(svc.applyPendingTaskWorkspaceSelection(admission)).rejects.toThrow("Previous task run must finish");
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true);
|
||||
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
|
||||
workspace: { id: nextWorkspace.id }, bindingRevision: 2, pendingSelection: null,
|
||||
});
|
||||
expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "applied", bindingRevision: 2 });
|
||||
|
||||
// A later, unrelated replacement still invalidates the revision fence.
|
||||
await svc.selectTaskWorkspace({ ...f, selection: { kind: "task_directory" },
|
||||
expectedBindingRevision: 2, requestKey: "later-choice" });
|
||||
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
|
||||
await expect(svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })).rejects.toThrow("Pending workspace selection is stale");
|
||||
});
|
||||
|
||||
it("allows regular signed-in members to select files and request repositories while denying viewers", async () => {
|
||||
const f = await fixture(), svc = executionWorkspaceService(db), userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Board member", email: `${userId}@example.test`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
const [membership] = await db.insert(companyMemberships).values({ companyId: f.companyId,
|
||||
principalType: "user", principalId: userId, status: "active", membershipRole: "member" }).returning();
|
||||
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
|
||||
const actor = { type: "board" as const, source: "session" as const, userId,
|
||||
companyIds: [f.companyId], isInstanceAdmin: false };
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Authentication middleware establishes this session actor; all company,
|
||||
// issue and workspace authorization below uses the real database service.
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", taskWorkspaceRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const selection = { selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "member-selection" };
|
||||
const repository = { repository: { kind: "url" as const, url: "https://github.com/public/example" }, requestKey: "member-repository" };
|
||||
const selected = await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection);
|
||||
expect(selected.status, JSON.stringify(selected.body)).toBe(200);
|
||||
expect(selected.body).toMatchObject({ kind: "scheduled" });
|
||||
const prepared = await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository);
|
||||
expect(prepared.status, JSON.stringify(prepared.body)).toBe(200);
|
||||
expect(prepared.body).toMatchObject({ kind: "requires_next_admission" });
|
||||
const before = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
|
||||
expect(before).toMatchObject({ workspace: { id: f.workspaceId }, pendingSelection: { requestKey: selection.requestKey } });
|
||||
expect(await svc.listTaskRepositories(f.companyId, f.workspaceId)).toHaveLength(1);
|
||||
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.id, membership.id));
|
||||
const deniedSelection = { ...selection, requestKey: "viewer-selection" };
|
||||
const deniedRepository = { ...repository, requestKey: "viewer-repository" };
|
||||
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(deniedSelection)).status).toBe(403);
|
||||
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(deniedRepository)).status).toBe(403);
|
||||
await expect(svc.selectTaskWorkspace({ ...f, actor, ...deniedSelection })).rejects.toThrow("modification is not allowed");
|
||||
await expect(svc.requestTaskRepository({ ...f, actor, request: deniedRepository })).rejects.toThrow("modification is not allowed");
|
||||
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toEqual(before);
|
||||
expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, f.workspaceId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["binding", "configured_source"] as const)("supersedes an older queued selection when an ordinary update changes %s", async kind => {
|
||||
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
|
||||
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
|
||||
|
||||
@@ -20,7 +20,10 @@ export function taskWorkspaceRoutes(db: Db) {
|
||||
if (!issue) throw notFound("Task not found");
|
||||
if (!hasCompanyAccess(req, issue.companyId)) throw notFound("Task not found");
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const decision = await accessService(db).decide({ actor: req.actor, action: write ? "issue:mutate" : "issue:read", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id } });
|
||||
const decision = await accessService(db).decide({ actor: req.actor, action: write ? "issue:mutate" : "issue:read", resource: {
|
||||
type: "issue", companyId: issue.companyId, issueId: issue.id,
|
||||
status: issue.status, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden("Task workspace is outside this actor's authorization boundary");
|
||||
if (issue.executionWorkspaceId && !(await canActorReadExecutionWorkspace(db, req.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found");
|
||||
if (write && req.actor.type === "agent") {
|
||||
|
||||
@@ -9,9 +9,8 @@ import { activityLog, executionWorkspaceRepositories, executionWorkspaces, issue
|
||||
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
|
||||
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js";
|
||||
import { canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { authorizationService, canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { captureRunIdentity } from "./run-identity.js";
|
||||
import { toolAccessService } from "./tool-access.js";
|
||||
import { normalizeProjectRepositoryUrl } from "./project-repositories.js";
|
||||
@@ -47,7 +46,11 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
async function request(input: { companyId: string; issueId: string; actor: AuthorizationActor; request: PrepareWorkspaceRepository }) {
|
||||
const parsed = prepareWorkspaceRepositorySchema.parse(input.request);
|
||||
const decision = await accessService(db).decide({ actor: input.actor, action: "issue:mutate", resource: { type: "issue", companyId: input.companyId, issueId: input.issueId } });
|
||||
const [requestedIssue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(db, input.actor)));
|
||||
if (!requestedIssue) throw notFound("Task not found");
|
||||
const issueResource = (issue: typeof issues.$inferSelect) => ({ type: "issue" as const, companyId: issue.companyId, issueId: issue.id,
|
||||
status: issue.status, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId });
|
||||
const decision = await authorizationService(db).decide({ actor: input.actor, action: "issue:mutate", resource: issueResource(requestedIssue) });
|
||||
if (!decision.allowed) throw forbidden("Task workspace modification is not allowed");
|
||||
let userId = input.actor.userId ?? null;
|
||||
let localTrusted = input.actor.source === "local_implicit";
|
||||
@@ -65,6 +68,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const receipt = await db.transaction(async tx => {
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("no key update");
|
||||
if (!issue) throw notFound("Task not found");
|
||||
const currentDecision = await authorizationService(tx).decide({ actor: input.actor, action: "issue:mutate", resource: issueResource(issue) });
|
||||
if (!currentDecision.allowed) throw forbidden("Task workspace modification is not allowed");
|
||||
if (input.actor.type === "agent" && !["standard", "skill_test"].includes(issue.workMode)) throw forbidden("Repository preparation is unavailable in Ask or Plan mode");
|
||||
if (!issue.executionWorkspaceId) throw conflict("The task has no admitted workspace yet. Start it once before preparing a repository.");
|
||||
if (!(await canActorReadExecutionWorkspace(db, input.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found");
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { assertTaskWorkspaceAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-source-access.js";
|
||||
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
|
||||
import { executionWorkspaceRepositoryService } from "./execution-workspace-repositories.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
|
||||
import { executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -1330,12 +1329,15 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
|
||||
async function selectTaskWorkspace(input: { companyId: string; issueId: string; actor: AuthorizationActor;
|
||||
selection: TaskWorkspaceSelection; expectedBindingRevision: number; requestKey: string }) {
|
||||
const decision = await accessService(db).decide({ actor: input.actor, action: "issue:mutate", resource: { type: "issue", companyId: input.companyId, issueId: input.issueId } });
|
||||
if (!decision.allowed) throw forbidden("Task workspace modification is not allowed");
|
||||
return db.transaction(async tx => {
|
||||
const [task] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId),
|
||||
await issueReadSqlCondition(tx, input.actor))).for("update");
|
||||
if (!task) throw notFound("Task not found");
|
||||
const decision = await authorizationService(tx).decide({ actor: input.actor, action: "issue:mutate", resource: {
|
||||
type: "issue", companyId: task.companyId, issueId: task.id,
|
||||
status: task.status, assigneeAgentId: task.assigneeAgentId, assigneeUserId: task.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden("Task workspace modification is not allowed");
|
||||
if (task.workspaceSelection?.request?.key === input.requestKey) {
|
||||
if (task.workspaceSelection.request.expectedBindingRevision !== input.expectedBindingRevision ||
|
||||
JSON.stringify(taskWorkspaceSelectionSchema.parse(task.workspaceSelection.selection)) !== JSON.stringify(taskWorkspaceSelectionSchema.parse(input.selection))) {
|
||||
@@ -1344,7 +1346,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
return { kind: "applied" as const, bindingRevision: task.workspaceBindingRevision };
|
||||
}
|
||||
if (task.workspacePendingSelection?.requestKey === input.requestKey) {
|
||||
if (task.workspacePendingSelection.expectedBindingRevision !== input.expectedBindingRevision ||
|
||||
if ((task.workspacePendingSelection.intent.request?.expectedBindingRevision ?? task.workspacePendingSelection.expectedBindingRevision) !== input.expectedBindingRevision ||
|
||||
JSON.stringify(taskWorkspaceSelectionSchema.parse(task.workspacePendingSelection.intent.selection)) !== JSON.stringify(taskWorkspaceSelectionSchema.parse(input.selection))) {
|
||||
throw conflict("Workspace selection request key was already used for different intent");
|
||||
}
|
||||
@@ -1394,8 +1396,15 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
// Retain confidentiality even after a task is moved to another root.
|
||||
await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata,
|
||||
_issuePrivacySources: { ...(workspace.metadata?._issuePrivacySources as Record<string, unknown> ?? {}), [task.id]: true } } }).where(eq(executionWorkspaces.id, workspaceId));
|
||||
const nextRevision = task.workspaceBindingRevision + (task.executionWorkspaceId === workspaceId ? 0 : 1);
|
||||
// Realizing the current run's first folder must not invalidate an already
|
||||
// accepted choice for its next run. Keep the original request receipt for retries.
|
||||
const pending = task.workspacePendingSelection;
|
||||
const pendingAfterFirstBinding = !task.executionWorkspaceId && pending?.expectedBindingRevision === task.workspaceBindingRevision
|
||||
? { ...pending, expectedBindingRevision: nextRevision }
|
||||
: pending;
|
||||
const [bound] = await tx.update(issues).set({ ...patch, executionWorkspaceId: workspaceId,
|
||||
workspaceBindingRevision: task.workspaceBindingRevision + (task.executionWorkspaceId === workspaceId ? 0 : 1), updatedAt: new Date() })
|
||||
workspaceBindingRevision: nextRevision, workspacePendingSelection: pendingAfterFirstBinding, updatedAt: new Date() })
|
||||
.where(eq(issues.id, issueId)).returning();
|
||||
return bound;
|
||||
});
|
||||
|
||||
@@ -3605,7 +3605,9 @@ export function heartbeatService(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (taskWorkspaceIntentRow?.intent && !nativeRecoveryExecutionWorkspaceId) {
|
||||
// Once bound, retained workspace authorization above is authoritative.
|
||||
// The original source selection may outlive its configuration row.
|
||||
if (taskWorkspaceIntentRow?.intent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
|
||||
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
|
||||
selection: taskWorkspaceIntentRow.intent.selection });
|
||||
|
||||
Reference in new issue
Block a user