diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 7d02c8d04e0..31f37f0abc6 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -361,6 +361,57 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await expect(db.select().from(routineRuns).where(eq(routineRuns.id, run.id))).resolves.toHaveLength(1); }); + it("blocks agents from restoring routine revisions assigned to another agent", async () => { + const { companyId, routine, svc } = await seedFixture(); + const otherAgentId = randomUUID(); + await db.insert(agents).values({ + id: otherAgentId, + companyId, + name: "OtherCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + const revision1Id = routine.latestRevisionId!; + + await svc.update(routine.id, { assigneeAgentId: otherAgentId }, {}); + + await expect( + svc.restoreRevision(routine.id, revision1Id, { agentId: otherAgentId }), + ).rejects.toMatchObject({ + status: 403, + message: "Agents can only restore routine revisions assigned to themselves", + }); + await expect(svc.get(routine.id)).resolves.toMatchObject({ + assigneeAgentId: otherAgentId, + latestRevisionNumber: 2, + }); + }); + + it("blocks restoring routine revisions assigned to agents that are no longer assignable", async () => { + const { agentId, routine, svc } = await seedFixture(); + const revision1Id = routine.latestRevisionId!; + await svc.update(routine.id, { description: "revision 2" }, {}); + await db + .update(agents) + .set({ status: "terminated" }) + .where(eq(agents.id, agentId)); + + await expect( + svc.restoreRevision(routine.id, revision1Id, { userId: "board-user" }), + ).rejects.toMatchObject({ + status: 409, + message: "Cannot assign routines to terminated agents", + }); + await expect(svc.get(routine.id)).resolves.toMatchObject({ + description: "revision 2", + latestRevisionNumber: 2, + }); + }); + it("appends safe trigger metadata revisions without leaking webhook secrets", async () => { const { routine, svc } = await seedFixture(); const created = await svc.createTrigger(routine.id, { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 67bbccdf725..332905b812a 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -605,6 +605,17 @@ export function routineService( if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents"); } + async function assertRestorableAssignee( + companyId: string, + assigneeAgentId: string | null | undefined, + actor: Actor, + ) { + await assertAssignableAgent(companyId, assigneeAgentId); + if (actor.agentId && assigneeAgentId !== actor.agentId) { + throw forbidden("Agents can only restore routine revisions assigned to themselves"); + } + } + async function assertProject(companyId: string, projectId: string | null | undefined) { if (!projectId) return; const project = await db @@ -1830,6 +1841,9 @@ export function routineService( } const snapshot = targetRevision.snapshot as RoutineRevisionSnapshotV1; + const routineSnapshot = snapshot.routine; + await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); + const currentTriggers = await db .select({ id: routineTriggers.id }) .from(routineTriggers) @@ -1862,7 +1876,6 @@ export function routineService( .then((rows) => rows[0] ?? null); if (!locked) throw notFound("Routine not found"); - const routineSnapshot = snapshot.routine; const now = new Date(); const [restoredRoutine] = await txDb .update(routines)