mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Authorize projectless fresh workspace reservations
Check current task and responsible-user authority under the task lock before replacing a projectless binding. Cover repeated assembled preparation through the company-scoped stored workspace reader and reject a changed responsible-user identity. Validation: 4 targeted assembled workspace and authorization cases passed. Broader verification remains in GitHub CI. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
4 files changed
+38
-3
No files matched your search
@@ -82,7 +82,7 @@ describe.skipIf(!support.supported)("provider-owned fresh retry reservations", (
|
||||
await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
const owner = { agentId: f.input.agentId, environmentId: randomUUID(), pluginKey: "test.tasks", driverKey: "tasks" };
|
||||
const input = { db, companyId: f.companyId, issueId: f.issueId, runId: f.input.runId,
|
||||
agentId: f.input.agentId, fresh: f.fresh, owner, expectedBindingRevision: 1 };
|
||||
agentId: f.input.agentId, responsibleUserId: null, fresh: f.fresh, owner, expectedBindingRevision: 1 };
|
||||
const id = await reserveFreshPluginTaskWorkspace(input);
|
||||
expect(await reserveFreshPluginTaskWorkspace(input)).toBe(id);
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id));
|
||||
@@ -91,5 +91,7 @@ describe.skipIf(!support.supported)("provider-owned fresh retry reservations", (
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(task.executionWorkspaceId).toBe(id);
|
||||
expect(task.workspaceBindingRevision).toBe(2);
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "changed-user" }).where(eq(heartbeatRuns.id, f.input.runId));
|
||||
await expect(reserveFreshPluginTaskWorkspace(input)).rejects.toThrow("responsible user changed");
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,34 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a fresh projectless reservation stable through repeated preparation", async () => {
|
||||
const { input, issue, envOrchestrator } = await fixture();
|
||||
await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, issue.id));
|
||||
const pluginKey = "test.fresh-projectless";
|
||||
await db.insert(plugins).values({ pluginKey, packageName: "test-tasks", version: "1.0.0", apiVersion: 1,
|
||||
status: "ready", categories: ["workspace"], manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0",
|
||||
displayName: "Tasks", description: "Tasks", author: "Test", categories: ["workspace"],
|
||||
capabilities: ["environment.drivers.register"], entrypoints: { worker: "./worker.js" },
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Task VM", supportsTasks: true, freshWorkspaceVersion: 1 }] } });
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.task.issueRef = { ...input.task.issueRef!, projectId: null, projectWorkspaceId: null };
|
||||
input.task.context = { issueId: issue.id };
|
||||
input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] };
|
||||
input.policy.requestedExecutionWorkspaceMode = input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace";
|
||||
input.config.mergedConfig = { workspaceStrategy: { type: "project_primary" } };
|
||||
input.environment.selectedEnvironmentForConfig = { ...input.environment.localEnvironment, driver: "plugin",
|
||||
config: { pluginKey, driverKey: "tasks" } };
|
||||
const stopped = new Error("Reached projectless environment handoff");
|
||||
const acquire = vi.spyOn(envOrchestrator, "acquireForRun").mockRejectedValue(stopped);
|
||||
const read = vi.spyOn(input.services.executionWorkspacesSvc, "getStoredById");
|
||||
for (let attempt = 0; attempt < 2; attempt++) await expect(prepareHeartbeatWorkspace(db, input)).rejects.toBe(stopped);
|
||||
expect(read).toHaveBeenCalledWith(input.agent.companyId, input.run.id);
|
||||
expect(acquire).toHaveBeenCalledTimes(2);
|
||||
for (const [args] of acquire.mock.calls) expect(args.persistedExecutionWorkspace).toMatchObject({ id: input.run.id,
|
||||
providerType: "adapter_managed", metadata: { runnerTaskFreshWorkspace: { attemptId: input.run.id } } });
|
||||
const rows = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.companyId, input.agent.companyId));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
const readIssue = (id: string) => db.select().from(issues).where(eq(issues.id, id)).then(rows => rows[0]!);
|
||||
const readRun = (id: string) => db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)).then(rows => rows[0]!);
|
||||
const leases = (id: string) => db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, id));
|
||||
|
||||
@@ -419,7 +419,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
});
|
||||
const freshTaskReservationId = providerFresh && pluginTaskWorkspace && !pluginPrepared && !resolvedWorkspace.projectId && issueId
|
||||
? await reserveFreshPluginTaskWorkspace({ db, companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id,
|
||||
fresh: providerFresh, expectedBindingRevision: previousProviderBindingRevision, owner: taskWorkspaceOwner! }) : null;
|
||||
fresh: providerFresh, responsibleUserId, expectedBindingRevision: previousProviderBindingRevision, owner: taskWorkspaceOwner! }) : null;
|
||||
const freshTaskReservation = freshTaskReservationId ? await executionWorkspacesSvc.getStoredById(agent.companyId, freshTaskReservationId) : null;
|
||||
const copyOnlyProvisioning = pluginPrepared ? true : await timeWorkspace("workspace_provision_policy", () => workspaceRequiresCopyOnlyProvisioning(db, {
|
||||
companyId: agent.companyId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertTaskWorkspaceExecutionAccess } from "./task-workspace-access.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
@@ -88,7 +89,7 @@ async function verify(file: string, expected: { sha256: string; byteSize: number
|
||||
}
|
||||
|
||||
export async function reserveFreshPluginTaskWorkspace(input: {
|
||||
db: Db; companyId: string; issueId: string; runId: string; agentId: string;
|
||||
db: Db; companyId: string; issueId: string; runId: string; agentId: string; responsibleUserId: string | null;
|
||||
fresh: PluginFreshWorkspace; expectedBindingRevision: number;
|
||||
owner: Record<string, unknown>;
|
||||
}) {
|
||||
@@ -99,6 +100,10 @@ export async function reserveFreshPluginTaskWorkspace(input: {
|
||||
eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (!task || !run || run.agentId !== input.agentId || run.status !== "running" ||
|
||||
![run.issueId, run.nativeIssueId].includes(input.issueId)) throw forbidden("Workspace preparation no longer has an active run");
|
||||
await assertTaskWorkspaceExecutionAccess(tx, { type: "agent", source: "agent_jwt",
|
||||
companyId: input.companyId, agentId: input.agentId, runId: input.runId,
|
||||
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId,
|
||||
}, input.companyId, { issueId: input.issueId });
|
||||
const [reserved] = await tx.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.sourceIssueId, input.issueId),
|
||||
eq(executionWorkspaces.id, input.fresh.attemptId)));
|
||||
|
||||
Reference in new issue
Block a user