diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index b8aa995d37f..641ff3ab1bd 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2878,6 +2878,14 @@ or grant access to the other task. An explicit pending workspace choice takes pr ### Operator identity and permission for manual dispatch +Routine creation keeps the authenticated responsible user separate from creator +attribution. An agent-created routine uses the server-resolved on-behalf-of user +from its run or API key; the agent remains the creator. Without a bound identity, +the service retains its parent-task and company-default fallback. Automatic +routine executions use the saved revision's responsible user. A person clicking +Run now supplies a new execution identity, so that manual task uses the clicking +person's authority and AI account selection rather than the routine creator's. + A legacy queued-message Interrupt is a new instruction from the user who clicks it. The new run uses that user's execution identity, including when someone else wrote the queued messages. Message bodies and historical authors stay unchanged. diff --git a/maps/agent-access-map/routines.md b/maps/agent-access-map/routines.md index 83a34dd306d..0b40eb397ff 100644 --- a/maps/agent-access-map/routines.md +++ b/maps/agent-access-map/routines.md @@ -39,8 +39,8 @@ Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. Gaps: No dedicated production manage_routine binding; generic `call_api` provides -the permitted REST path. Creation needs a responsible user resolved from the parent -issue or company default. +the permitted REST path. Creation resolves responsibility from the authenticated +agent's on-behalf-of user, then the parent issue or company default. ## `triggers` diff --git a/server/src/__tests__/issue-scheduled-retry-routes.test.ts b/server/src/__tests__/issue-scheduled-retry-routes.test.ts index ddc62e1ec8d..20ef726340a 100644 --- a/server/src/__tests__/issue-scheduled-retry-routes.test.ts +++ b/server/src/__tests__/issue-scheduled-retry-routes.test.ts @@ -689,6 +689,12 @@ describeEmbeddedPostgres("issue scheduled retry routes", () => { : await request(app).patch(`/api/issues/${f.issueId}`).send({ comment: "Verified source recovery; continue the original work." }); expect(res.status, JSON.stringify(res.body)).toBe(method === "post" ? 201 : 200); + const [comment] = await db.select({ id: issueComments.id }).from(issueComments) + .where(eq(issueComments.issueId, f.issueId)); + expect(comment).toBeDefined(); + await expect.poll(async () => db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, f.companyId), + sql`${agentWakeupRequests.payload}->>'commentId' = ${comment!.id}`))).toHaveLength(1); await heartbeat.drainActiveRunExecutions(); const progress = once(child, "message", { signal: AbortSignal.timeout(5_000) }); child.send("continue-work"); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 1f63d4661f8..e4f5b923cfe 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -33,7 +33,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { envConfigSchema, type Routine } from "@paperclipai/shared"; +import { createRoutineSchema, envConfigSchema, type Routine } from "@paperclipai/shared"; import { issueService } from "../services/issues.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import * as providerRegistry from "../secrets/provider-registry.ts"; @@ -1602,6 +1602,30 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { ]); }); + it.each(["parent", "company", "missing", "board"] as const)( + "preserves routine creation responsibility fallback: %s", async source => { + const { companyId, agentId, routine, svc } = await seedFixture(); + const parentUserId = randomUUID(); + const boardUserId = randomUUID(); + const [parent] = await db.insert(issues).values({ companyId, title: "Parent context", status: "backlog", + responsibleUserId: parentUserId }).returning(); + if (source === "missing") await db.update(companies).set({ defaultResponsibleUserId: null }).where(eq(companies.id, companyId)); + const creation = svc.create(companyId, createRoutineSchema.parse({ + title: "Responsibility fallback", assigneeAgentId: agentId, status: "paused", + ...(source === "parent" ? { parentIssueId: parent.id } : {}), + }), source === "board" ? { userId: boardUserId, actorResponsibleUserId: "other-user" } : { agentId }); + if (source === "missing") { + await expect(creation).rejects.toMatchObject({ status: 422 }); + } else { + expect(await creation).toMatchObject({ + responsibleUserId: source === "board" ? boardUserId : source === "parent" ? parentUserId : routine.responsibleUserId, + createdByAgentId: source === "board" ? null : agentId, + createdByUserId: source === "board" ? boardUserId : null, + }); + } + }, + ); + it("retains manual runner attribution without adding fresh routine issues to Mine", async () => { const { companyId, agentId, issueSvc, routine, svc } = await seedFixture(); const userId = randomUUID(); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index 4c6e1f81ee7..84587bba5e7 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -161,6 +161,7 @@ export function routineRoutes( const created = await svc.create(companyId, req.body, { agentId: req.actor.type === "agent" ? req.actor.agentId : null, userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null, + ...(req.actor.type === "agent" ? { actorResponsibleUserId: req.actor.onBehalfOfUserId ?? null } : {}), runId: req.actor.runId ?? null, }); const actor = getActorInfo(req); diff --git a/server/src/services/native-runtime/runner-api.integration.test.ts b/server/src/services/native-runtime/runner-api.integration.test.ts index 15d9b687c8c..80f8b9ee04b 100644 --- a/server/src/services/native-runtime/runner-api.integration.test.ts +++ b/server/src/services/native-runtime/runner-api.integration.test.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises"; import { join, dirname } from "node:path"; import { and, eq } from "drizzle-orm"; -import { agents, agentWakeupRequests, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companySkillVersions, companySkillTestRuns, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; +import { agents, agentApiKeys, agentWakeupRequests, assets, authUsers, documents, heartbeatRuns, issues, projects, routineDocuments, routineRevisions, routines, runnerApiResponseReservations, companySkills, companySkillVersions, companySkillTestRuns, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; import { initializeRunIdentity } from "../run-identity.js"; import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import * as nativeSetupReadiness from "./setup-readiness.js"; @@ -243,6 +243,90 @@ else if(m.id!==undefined) send({id:m.id,result:{}}); .toMatchObject({ status: "blocked", billingCode: "REST-PARITY" }); }); + it.each(["company owner", "unrelated parent", "no fallback"])( + "keeps the authenticated native run responsible user over %s", async fallback => { + const fixture = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); + const ownerUserId = randomUUID(); + if (fallback !== "no fallback") { + await server.db.insert(authUsers).values({ id: ownerUserId, name: "Company owner", email: `${ownerUserId}@fixture.invalid`, + emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await server.db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "user", + principalId: ownerUserId, status: "active", membershipRole: "owner" }); + } + if (fallback === "unrelated parent") { + await server.db.update(issues).set({ responsibleUserId: ownerUserId }).where(eq(issues.id, fixture.blockerId)); + } + const call = { tool: "call_api", callId: "bound-routine", arguments: { + operationId: "POST /api/companies/{companyId}/routines", body: { + title: "Native run owned routine", assigneeAgentId: fixture.agentId, status: "paused", + ...(fallback === "unrelated parent" ? { parentIssueId: fixture.blockerId } : {}), + actorResponsibleUserId: ownerUserId, responsibleUserId: ownerUserId, createdByUserId: ownerUserId, + }, + } }; + const created = await fixture.authority.execute(call); + expect(created).toMatchObject({ ok: true, status: 201 }); + expect(await new PaperclipRunnerToolAuthority(server.db, fixture).execute(call)).toEqual(created); + const [routine] = await server.db.select({ id: routines.id, responsibleUserId: routines.responsibleUserId, + createdByAgentId: routines.createdByAgentId, createdByUserId: routines.createdByUserId }).from(routines) + .where(eq(routines.companyId, fixture.companyId)); + expect(routine).toEqual({ id: expect.any(String), responsibleUserId: fixture.responsibleUserId, + createdByAgentId: fixture.agentId, createdByUserId: null }); + const revisions = await server.db.select({ responsibleUserId: routineRevisions.responsibleUserId, + createdByRunId: routineRevisions.createdByRunId }).from(routineRevisions) + .where(eq(routineRevisions.routineId, routine.id)); + expect(revisions).toEqual([{ responsibleUserId: fixture.responsibleUserId, createdByRunId: fixture.runId }]); + await expect(fixture.authority.execute({ ...call, callId: "foreign-routine", arguments: { + ...call.arguments, pathParams: { companyId: fixture.foreignCompanyId }, + } })).rejects.toThrow("another company"); + await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, fixture.runId)); + await expect(fixture.authority.execute({ ...call, callId: "cancelled-routine" })).rejects.toThrow("binding_not_authorized"); + expect(await server.db.select({ id: routines.id }).from(routines).where(eq(routines.companyId, fixture.companyId))) + .toEqual([{ id: routine.id }]); + }, + ); + + it("uses the current run identity when an agent JWT carries a stale user claim", async () => { + const fixture = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); + const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, "stale-user"); + const response = await fetch(`${fixture.apiUrl}/api/companies/${fixture.companyId}/routines`, { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Current identity routine", assigneeAgentId: fixture.agentId, status: "paused" }), + }); + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ responsibleUserId: fixture.responsibleUserId, + createdByAgentId: fixture.agentId, createdByUserId: null }); + }); + + it("rejects routine creation after the bound responsible user loses company membership", async () => { + const fixture = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); + await server.db.delete(companyMemberships).where(and(eq(companyMemberships.companyId, fixture.companyId), + eq(companyMemberships.principalId, fixture.responsibleUserId!))); + await expect(fixture.authority.execute({ tool: "call_api", callId: "revoked-user-routine", arguments: { + operationId: "POST /api/companies/{companyId}/routines", body: { + title: "Revoked authority routine", assigneeAgentId: fixture.agentId, status: "paused", + }, + } })).rejects.toMatchObject({ status: 403 }); + expect(await server.db.select({ id: routines.id }).from(routines).where(eq(routines.companyId, fixture.companyId))).toEqual([]); + }); + + it.each([true, false])("keeps agent API-key responsibility binding (present=%s)", async bound => { + const fixture = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true }); + const token = `pcp_fixture_${randomUUID()}`; + await server.db.insert(agentApiKeys).values({ companyId: fixture.companyId, agentId: fixture.agentId, + name: "Routine API fixture", keyHash: createHash("sha256").update(token).digest("hex"), + responsibleUserId: bound ? fixture.responsibleUserId : null }); + const response = await fetch(`${fixture.apiUrl}/api/companies/${fixture.companyId}/routines`, { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "API-key routine", assigneeAgentId: fixture.agentId, status: "paused" }), + }); + expect(response.status).toBe(bound ? 201 : 403); + const rows = await server.db.select({ responsibleUserId: routines.responsibleUserId, + createdByAgentId: routines.createdByAgentId, createdByUserId: routines.createdByUserId }).from(routines) + .where(eq(routines.companyId, fixture.companyId)); + expect(rows).toEqual(bound ? [{ responsibleUserId: fixture.responsibleUserId, + createdByAgentId: fixture.agentId, createdByUserId: null }] : []); + }); + it("manages owned routines and triggers with durable replay, attribution and owner denials", async () => { const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true }); await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId)); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 210ba52de70..74d421e0cb9 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -154,7 +154,12 @@ async function resolveRoutineResponsibleUserId(db: Db, companyId: string, actorU return resolveCompanyDefaultResponsibleUserId(db, companyId); } -type Actor = { agentId?: string | null; userId?: string | null; runId?: string | null }; +type Actor = { + agentId?: string | null; + userId?: string | null; + actorResponsibleUserId?: string | null; + runId?: string | null; +}; type RoutineRow = typeof routines.$inferSelect; type RoutineTriggerRow = typeof routineTriggers.$inferSelect; @@ -2217,7 +2222,12 @@ export function routineService( ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - const responsibleUserId = await resolveRoutineResponsibleUserId(db, companyId, actor.userId, input.parentIssueId ?? null); + const responsibleUserId = await resolveRoutineResponsibleUserId( + db, + companyId, + actor.userId ?? actor.actorResponsibleUserId, + input.parentIssueId ?? null, + ); if (!responsibleUserId) { throw unprocessable("Routine requires a responsible user"); }