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:
Nicky LeachandPaperclip committed 2026-10-22 17:18:13 -07:00
1 parent 495d12dda8
commit 2b2d7bad43
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)));