From 5bad074baa814b2db817147345d9012e1e80a8dd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:34:04 -0600 Subject: [PATCH] feat: add individual agent interaction permissions (#20411) Add eleven sparse incoming and outgoing restrictions, human CLI/API administration, shared mutation enforcement, and pending-delivery checks. Preserve existing defaults, agent settings, and existing authorization boundaries. Co-Authored-By: Paperclip --- cli/src/commands/client/agent.ts | 37 ++ packages/plugins/sdk/src/protocol.ts | 1 + .../src/agent-interaction-permissions.ts | 41 ++ packages/shared/src/index.ts | 1 + packages/shared/src/types/agent.ts | 2 + packages/shared/src/validators/agent.ts | 3 + .../agent-budget-configuration.test.ts | 7 +- .../agent-instruction-revisions.test.ts | 19 +- .../agent-interaction-acceptance.test.ts | 122 +++++ .../agent-interaction-mutations.test.ts | 308 +++++++++++++ .../agent-interaction-permissions.test.ts | 260 +++++++++++ .../agent-interaction-policy.test.ts | 197 ++++++++ .../agent-permissions-routes.test.ts | 24 +- .../src/__tests__/agent-skills-routes.test.ts | 6 +- .../chat-channels.integration.test.ts | 2 +- .../company-portability-routes.test.ts | 2 +- .../src/__tests__/company-portability.test.ts | 18 +- .../decision-effect-recovery-work.test.ts | 10 +- .../src/__tests__/decisions-service.test.ts | 23 + .../plugin-worker-invocation-scope.cjs | 6 +- ...ue-agent-mutation-ownership-routes.test.ts | 44 +- ...issue-assignee-invokability-routes.test.ts | 24 +- .../__tests__/issue-attachment-routes.test.ts | 2 + .../issue-comment-reopen-routes.test.ts | 19 +- .../issue-execution-policy-routes.test.ts | 23 +- .../__tests__/issue-telemetry-routes.test.ts | 53 +-- .../native-status-arbiter-corpus.test.ts | 13 + server/src/__tests__/openapi-routes.test.ts | 1 + ...access-authorization-host-services.test.ts | 17 + .../__tests__/plugin-worker-manager.test.ts | 30 +- server/src/__tests__/routines-service.test.ts | 12 + server/src/app.ts | 2 + .../src/lib/agent-interaction-permissions.ts | 24 + server/src/lib/agent-records.ts | 3 + .../agent-lifecycle/adapters/configuration.ts | 1 + .../agent-lifecycle/adapters/effects.ts | 1 + .../agent-lifecycle/adapters/records.ts | 2 + server/src/modules/agent-lifecycle/index.ts | 20 + .../adapters/queued-comment-postgres.test.ts | 25 + .../adapters/queued-comment-postgres.ts | 13 +- .../routes/agent-interaction-permissions.ts | 32 ++ server/src/routes/agents.ts | 13 +- server/src/routes/companies.ts | 2 + server/src/routes/issues.ts | 53 ++- server/src/routes/openapi.ts | 22 + server/src/services/agent-configuration.ts | 42 +- server/src/services/agent-file-store.ts | 51 ++- server/src/services/agent-hiring.ts | 7 +- .../services/agent-instruction-revisions.ts | 28 +- .../agent-interaction-delivery.test.ts | 365 +++++++++++++++ .../services/agent-interaction-delivery.ts | 430 ++++++++++++++++++ .../services/agent-interaction-permissions.ts | 62 +++ .../src/services/agent-interaction-policy.ts | 138 ++++++ server/src/services/agent-lifecycle.ts | 13 +- server/src/services/agents.ts | 14 +- server/src/services/authorization.ts | 32 +- server/src/services/built-in-agents.ts | 10 +- server/src/services/case-handoffs.ts | 14 +- server/src/services/change-consent-gate.ts | 7 + .../chat-interaction-arbitration.test.ts | 9 +- .../services/chat-interaction-arbitration.ts | 3 + .../services/chat-interaction-publications.ts | 3 + server/src/services/company-portability.ts | 93 +++- server/src/services/connection-management.ts | 22 +- server/src/services/decisions.ts | 30 +- server/src/services/document-annotations.ts | 7 + server/src/services/documents.ts | 31 +- server/src/services/heartbeat/queue.ts | 33 +- .../src/services/heartbeat/run-preparation.ts | 1 + .../services/heartbeat/runtime-execution.ts | 9 +- .../src/services/issue-assignment-wakeup.ts | 3 + .../issue-thread-interactions.test.ts | 14 +- .../src/services/issue-thread-interactions.ts | 47 +- server/src/services/issue-title.ts | 3 + server/src/services/issues.ts | 125 ++++- .../automatic-completion-reviews.ts | 16 +- .../external-chat-wait.integration.test.ts | 23 +- .../native-chat-review-presentation.ts | 14 +- .../native-routine-status.test.ts | 63 ++- .../native-runtime/obsolete-policy-reviews.ts | 14 +- .../paperclip-control-plane-port.test.ts | 7 + .../paperclip-runner-tool-authority.ts | 29 +- .../status-decision-committer.ts | 118 ++++- .../superseded-external-review-requests.ts | 5 +- .../native-runtime/unread-steered-messages.ts | 3 + server/src/services/plugin-host-services.ts | 48 +- .../services/plugin-interaction-context.ts | 17 + server/src/services/plugin-worker-manager.ts | 35 +- .../provider-failure-classification.test.ts | 4 + server/src/services/recovery/service.ts | 10 + server/src/services/routines.ts | 38 +- server/src/services/tool-access.ts | 14 +- .../services/tool-access/assignment-locks.ts | 9 +- .../tool-access/connections/installs.ts | 9 +- .../services/tool-access/profiles/service.ts | 8 + server/src/services/tool-gateway.ts | 329 +++++++------- server/src/services/work-products.ts | 24 +- 97 files changed, 3590 insertions(+), 433 deletions(-) create mode 100644 packages/shared/src/agent-interaction-permissions.ts create mode 100644 server/src/__tests__/agent-interaction-acceptance.test.ts create mode 100644 server/src/__tests__/agent-interaction-mutations.test.ts create mode 100644 server/src/__tests__/agent-interaction-permissions.test.ts create mode 100644 server/src/__tests__/agent-interaction-policy.test.ts create mode 100644 server/src/lib/agent-interaction-permissions.ts create mode 100644 server/src/routes/agent-interaction-permissions.ts create mode 100644 server/src/services/agent-interaction-delivery.test.ts create mode 100644 server/src/services/agent-interaction-delivery.ts create mode 100644 server/src/services/agent-interaction-permissions.ts create mode 100644 server/src/services/agent-interaction-policy.ts create mode 100644 server/src/services/plugin-interaction-context.ts diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 189e313d466..4d9fc75578e 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -6,6 +6,9 @@ import { updateAgentInstructionsBundleSchema, updateAgentInstructionsPathSchema, updateAgentPermissionsSchema, + updateAgentInteractionPermissionsSchema, + AGENT_INTERACTION_PERMISSION_KEYS, + type AgentInteractionPermissionsState, updateAgentPermissionGrantSchema, updateAgentSchema, upsertAgentInstructionsFileSchema, @@ -463,6 +466,40 @@ export function registerAgentCommands(program: Command): void { }), ); + function printInteractionPermissions(value: AgentInteractionPermissionsState, json: boolean) { + if (json) return printOutput(value, { json: true }); + for (const key of AGENT_INTERACTION_PERMISSION_KEYS) { + const source = Object.hasOwn(value.overrides, key) ? "explicit" : "default"; + console.log(`${key}: ${value.effective[key]} (${source})`); + } + } + + addCommonClientOptions(agent.command("interaction-permissions") + .description("Show individual interaction restrictions and their defaults") + .argument("", "Agent UUID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const value = await ctx.api.get(apiPath`/api/agents/${agentId}/interaction-permissions`); + if (!value) throw new Error("Agent interaction permissions not found"); + printInteractionPermissions(value, ctx.json); + } catch (err) { handleCommandError(err); } + })); + + addCommonClientOptions(agent.command("interaction-permissions:update") + .description("Set individual interaction restrictions; null restores a default (human permission managers only)") + .argument("", "Agent UUID") + .requiredOption("--payload-json ", "Partial interaction permissions map: true, false, or null") + .action(async (agentId: string, opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentInteractionPermissionsSchema.parse(parseJson(opts.payloadJson)); + const value = await ctx.api.patch(apiPath`/api/agents/${agentId}/interaction-permissions`, payload); + if (!value) throw new Error("Agent interaction permissions not found"); + printInteractionPermissions(value, ctx.json); + } catch (err) { handleCommandError(err); } + })); + for (const [command, enabled] of [["permission:grant", true], ["permission:revoke", false]] as const) { addCommonClientOptions( agent.command(command) diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index ff3a43d51a8..215258cc886 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -289,6 +289,7 @@ export type PluginRpcErrorCode = */ export interface PluginInvocationScope { companyId: string; + agentOrigin?: { agentId: string; runId?: string | null }; projectGit?: { projectId: string; workspaceId?: string; runId?: string; agentId?: string; operation?: "prepare" | "inspect" | "remove" }; } diff --git a/packages/shared/src/agent-interaction-permissions.ts b/packages/shared/src/agent-interaction-permissions.ts new file mode 100644 index 00000000000..cdcca207858 --- /dev/null +++ b/packages/shared/src/agent-interaction-permissions.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const AGENT_INTERACTION_PERMISSION_KEYS = [ + "canAssignTasksToAgents", + "canReceiveAssignmentsFromAgents", + "canAssignTasksToHumans", + "canMentionAgents", + "canReceiveMentionsFromAgents", + "canCommentOnOtherTasks", + "canReceiveCommentsFromAgents", + "canEditOtherTasks", + "canReceiveTaskEditsFromAgents", + "canConfigureOtherAgents", + "canReceiveConfigurationChangesFromAgents", +] as const; + +export type AgentInteractionPermissionKey = (typeof AGENT_INTERACTION_PERMISSION_KEYS)[number]; +export type AgentInteractionPermissions = Partial>; +export type AgentInteractionPermissionsPatch = Partial>; + +const shape = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, z.boolean().optional()])) as + Record>; +const patchShape = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, z.boolean().nullable().optional()])) as + Record>>; + +export const agentInteractionPermissionsSchema = z.object(shape).strict(); +export const updateAgentInteractionPermissionsSchema = z.object(patchShape).strict() + .refine(value => Object.keys(value).length > 0, "Provide at least one interaction permission"); + +export function resolveAgentInteractionPermissions(value: unknown): Record { + const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, record[key] !== false])) as + Record; +} + +export interface AgentInteractionPermissionsState { + agentId: string; + companyId: string; + overrides: AgentInteractionPermissions; + effective: Record; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 02e55f1be26..9ce4d55a17f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2907,6 +2907,7 @@ export { readProviderFailure, type ProviderFailure } from "./provider-failure.ge export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js"; export { readBoardBootstrap } from "./board-bootstrap.js"; export * from "./provider-rate-limits.js"; +export * from "./agent-interaction-permissions.js"; export { TASK_IDENTITY_CHANGED_FIELD, issueActivityChangesTaskIdentity, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index ec6079f87cb..08a3d3b7693 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -17,12 +17,14 @@ import type { } from "../trust-policy.js"; import type { AgentOrgChainHealth } from "../agent-eligibility.js"; import type { AgentApiKeyScope } from "../validators/agent.js"; +import type { AgentInteractionPermissions } from "../agent-interaction-permissions.js"; export interface AgentPermissions extends Record { canCreateAgents: boolean; canCreateSkills?: boolean; trustPreset?: TrustPreset; authorizationPolicy?: TrustAuthorizationPolicy; + interactions?: AgentInteractionPermissions; } export interface AgentProjectAccess { diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index d7f6d52a722..a2a58891196 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -11,6 +11,7 @@ import { envConfigSchema } from "./secret.js"; import { trustAuthorizationPolicySchema, trustPresetSchema } from "./trust-policy.js"; import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js"; import { objectWithoutDefaults } from "./partial.js"; +import { agentInteractionPermissionsSchema } from "../agent-interaction-permissions.js"; export const agentPermissionsSchema = z.object({ // No schema default: the server derives the default (enabled unless the @@ -19,6 +20,7 @@ export const agentPermissionsSchema = z.object({ canCreateSkills: z.boolean().optional().default(true), trustPreset: trustPresetSchema.optional(), authorizationPolicy: trustAuthorizationPolicySchema.optional(), + interactions: agentInteractionPermissionsSchema.optional(), }).catchall(z.unknown()); export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]); @@ -320,6 +322,7 @@ export const updateAgentPermissionsSchema = z.object({ canAssignTasks: z.boolean(), trustPreset: trustPresetSchema.optional(), authorizationPolicy: trustAuthorizationPolicySchema.optional(), + interactions: z.never({ error: "Use /api/agents/:id/interaction-permissions to change interaction permissions" }).optional(), }); export type UpdateAgentPermissions = z.infer; diff --git a/server/src/__tests__/agent-budget-configuration.test.ts b/server/src/__tests__/agent-budget-configuration.test.ts index 4f2ff10c8d1..d3935fdff8e 100644 --- a/server/src/__tests__/agent-budget-configuration.test.ts +++ b/server/src/__tests__/agent-budget-configuration.test.ts @@ -5,7 +5,12 @@ import { prepareAgentConfiguration } from "../services/agent-configuration.js"; const existing = { id: "agent", companyId: "company", status: "idle", budgetMonthlyCents: 500 } as AgentConfigurationRecord; // Preparation queues retention work through the database before reading the patch. -const fakeDb = () => ({ execute: async () => [] }) as unknown as Db; +const fakeDb = () => ({ + execute: async () => [], + select: () => ({ from: () => ({ where: () => ({ + orderBy: async () => [{ id: "ceo", companyId: "company", permissions: {} }], + }) }) }), +}) as unknown as Db; describe("budget authority in configuration preparation", () => { it("rejects agent-originated budget changes before persistence or policy audit", async () => { const db = fakeDb(); diff --git a/server/src/__tests__/agent-instruction-revisions.test.ts b/server/src/__tests__/agent-instruction-revisions.test.ts index ae58868d34f..d236189cb50 100644 --- a/server/src/__tests__/agent-instruction-revisions.test.ts +++ b/server/src/__tests__/agent-instruction-revisions.test.ts @@ -7,7 +7,7 @@ import { and, eq, sql } from "drizzle-orm"; import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; -import { agentFileStore } from "../services/agent-file-store.js"; +import { agentFileStore, snapshotAgentFiles } from "../services/agent-file-store.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js"; import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js"; @@ -65,6 +65,23 @@ describe("canonical instruction revisions", () => { async function save(content: string, baseRevisionId: string | null) { return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor); } + it("checks changed supporting instructions during bulk synchronization without rescanning historical mentions", async () => { + const historical = `Existing [@Target](agent://${agentId}) reference`; + await fs.writeFile(path.join(root, "NOTES.md"), historical); + await base(); + const sourceDir = path.join(home, `incoming-${randomUUID()}`); + await fs.cp(root, sourceDir, { recursive: true }); + const baseline = await snapshotAgentFiles(root); + await db.update(agents).set({ permissions: { interactions: { canMentionAgents: false } } }).where(eq(agents.id, actorId)); + await fs.writeFile(path.join(sourceDir, "other.md"), "Unrelated instructions"); + await agentFileStore(db).apply({ ...target(), sourceDir, baseline }, actor); + expect(await fs.readFile(path.join(root, "other.md"), "utf8")).toBe("Unrelated instructions"); + const nextBaseline = await snapshotAgentFiles(root); + await fs.writeFile(path.join(sourceDir, "NOTES.md"), `${historical} changed`); + await expect(agentFileStore(db).apply({ ...target(), sourceDir, baseline: nextBaseline }, actor)) + .rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } }); + expect(await fs.readFile(path.join(root, "NOTES.md"), "utf8")).toBe(historical); + }); it("exposes recorded creation dates and update dates for root and supporting files", async () => { const files = agentFileStore(db); const instructions = agentInstructionsService(db); diff --git a/server/src/__tests__/agent-interaction-acceptance.test.ts b/server/src/__tests__/agent-interaction-acceptance.test.ts new file mode 100644 index 00000000000..748f432d35f --- /dev/null +++ b/server/src/__tests__/agent-interaction-acceptance.test.ts @@ -0,0 +1,122 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { and, eq } from "drizzle-orm"; +import { expect, it } from "vitest"; +import { activityLog, agentApiKeys, agents, agentWakeupRequests, authUsers, boardApiKeys, companies, + companyMemberships, createDb, heartbeatRuns, issueComments, issues, principalPermissionGrants } from "@paperclipai/db"; +import { AGENT_INTERACTION_PERMISSION_KEYS, PERMISSION_KEYS } from "@paperclipai/shared"; +import { createApp } from "../app.js"; +import { createStorageService } from "../storage/service.js"; +import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; + +const execute = promisify(execFile); + +it("configures each control with the CLI against the assembled app and enforces API and native mutations", async () => { + const database = await startEmbeddedPostgresTestDatabase("interaction-acceptance-"); + const db = createDb(database.connectionString); + const directory = await mkdtemp(join(tmpdir(), "interaction-acceptance-storage-")); + let app: Awaited> | undefined; + let server: ReturnType["listen"]> | undefined; + try { + const companyId = randomUUID(), sender = randomUUID(), recipient = randomUUID(), userId = randomUUID(); + const senderKey = `pcp_agent_${randomUUID()}`, boardKey = `pcp_board_${randomUUID()}`; + const hash = (token: string) => createHash("sha256").update(token).digest("hex"); + await db.insert(companies).values({ id: companyId, name: "Permission acceptance", issuePrefix: "IPA", requireBoardApprovalForNewAgents: false }); + await db.insert(authUsers).values({ id: userId, name: "Operator", email: `${userId}@example.com`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values([sender, recipient].map((id, index) => ({ id, companyId, name: index ? "Recipient" : "Sender", + adapterType: "process", status: "idle", permissions: {}, runtimeConfig: { heartbeat: { wakeOnDemand: false, intervalSec: 0 } } }))); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" }, + ...[sender, recipient].map(principalId => ({ companyId, principalType: "agent", principalId, membershipRole: "member", status: "active" })), + ]); + await db.insert(principalPermissionGrants).values(["user", "agent"].flatMap(principalType => PERMISSION_KEYS.map(permissionKey => ({ + companyId, principalType, principalId: principalType === "user" ? userId : sender, permissionKey, + })))); + await db.insert(boardApiKeys).values({ userId, name: "Acceptance human", keyHash: hash(boardKey) }); + await db.insert(agentApiKeys).values({ companyId, agentId: sender, name: "Acceptance agent", keyHash: hash(senderKey), responsibleUserId: userId }); + const [owned, peer] = await db.insert(issues).values([ + { companyId, title: "Owned task", assigneeAgentId: sender, status: "todo" }, + { companyId, title: "Peer task", assigneeAgentId: recipient, status: "todo" }, + ]).returning(); + app = await createApp(db, { + uiMode: "none", serverPort: 0, bindHost: "127.0.0.1", allowedHostnames: ["127.0.0.1"], + deploymentMode: "authenticated", deploymentExposure: "private", authReady: true, companyDeletionEnabled: false, + managedPluginAutoInstall: [], storageService: createStorageService(createLocalDiskStorageProvider(directory)), + decisionServiceOptions: { wakeOriginAgent: async () => null }, + }); + await app.locals.bundledPluginsStartup; + server = app.listen(0, "127.0.0.1"); + await new Promise(resolve => server!.once("listening", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No HTTP test address"); + const base = `http://127.0.0.1:${address.port}`; + const repo = fileURLToPath(new URL("../../../", import.meta.url)); + const cli = async (agentId: string, patch?: Record) => { + const result = await execute(process.execPath, ["--import", `${repo}cli/node_modules/tsx/dist/loader.mjs`, `${repo}cli/src/index.ts`, + "agent", patch ? "interaction-permissions:update" : "interaction-permissions", agentId, "--api-base", base, + "--api-key", boardKey, "--json", ...(patch ? ["--payload-json", JSON.stringify(patch)] : [])], { + cwd: repo, env: { ...process.env, PAPERCLIP_TELEMETRY_DISABLED: "1" }, timeout: 60000, + }); + return JSON.parse(result.stdout); + }; + const api = async (path: string, body: unknown, method = "POST", token = senderKey) => { + const response = await fetch(`${base}/api${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) }); + return { status: response.status, body: await response.json() }; + }; + const snapshot = async () => ({ + tasks: await db.select().from(issues).where(eq(issues.companyId, companyId)), + comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)), + wakes: await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)), + }); + expect((await cli(sender)).overrides).toEqual({}); + expect((await cli(recipient)).overrides).toEqual({}); + for (const key of AGENT_INTERACTION_PERMISSION_KEYS) { + const policyAgent = key.startsWith("canReceive") ? recipient : sender; + expect((await cli(policyAgent, { [key]: false })).overrides).toEqual({ [key]: false }); + const before = await snapshot(); + const response = key.includes("Assign") ? await api(`/companies/${companyId}/issues`, { + title: "Prohibited delegation", status: "todo", ...(key === "canAssignTasksToHumans" ? { assigneeUserId: userId } : { assigneeAgentId: recipient }), + }) : key.includes("Mention") ? await api(`/issues/${owned!.id}/comments`, { body: `[@Recipient](agent://${recipient})` }) + : key.includes("Comment") ? await api(`/issues/${peer!.id}/comments`, { body: "Prohibited reply" }) + : key.includes("Edit") ? await api(`/issues/${peer!.id}`, { title: "Prohibited edit" }, "PATCH") + : await api(`/agents/${recipient}`, { capabilities: "Prohibited configuration" }, "PATCH"); + expect(response, key).toMatchObject({ status: 403, body: { details: { code: "agent_interaction_denied", permission: key } } }); + expect(await snapshot(), key).toEqual(before); + expect((await cli(policyAgent, { [key]: null })).overrides).toEqual({}); + } + await cli(recipient, { canReceiveAssignmentsFromAgents: false }); + const runId = randomUUID(); + await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, sender)); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: sender, status: "running", runtimeMode: "native", + nativeIssueId: owned!.id, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: owned!.id } }); + await db.update(issues).set({ executionRunId: runId, status: "in_progress" }).where(eq(issues.id, owned!.id)); + let nativeWakes = 0; + const native = new PaperclipRunnerToolAuthority(db, { companyId, agentId: sender, issueId: owned!.id, runId, + enqueueWakeup: async () => { nativeWakes++; return null; } }); + await expect(native.execute({ tool: "create_task", callId: randomUUID(), arguments: { + idempotencyKey: randomUUID(), title: "Prohibited native delegation", assigneeActorId: recipient, + } })).rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents" } }); + expect(nativeWakes).toBe(0); + await cli(recipient, { canReceiveAssignmentsFromAgents: null, canReceiveCommentsFromAgents: false }); + expect((await api(`/issues/${peer!.id}/comments`, { body: "Authorized human message" }, "POST", boardKey)).status).toBe(201); + expect((await api(`/agents/${sender}/interaction-permissions`, { canMentionAgents: true }, "PATCH")).status).toBe(403); + await cli(recipient, { canReceiveCommentsFromAgents: null }); + const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "agent.interaction_permissions_updated"))); + expect(audit).toHaveLength(25); + expect(audit.every(row => row.actorId === userId)).toBe(true); + expect((await db.select().from(agents).where(eq(agents.companyId, companyId))).every(row => !Object.hasOwn(row.permissions, "interactions"))).toBe(true); + } finally { + await app?.locals.paperclipShutdown?.(); + if (server) await new Promise((resolve, reject) => server!.close(error => error ? reject(error) : resolve())); + await db.$client.end(); + await database.cleanup(); + await rm(directory, { recursive: true, force: true }); + } +}, 240000); diff --git a/server/src/__tests__/agent-interaction-mutations.test.ts b/server/src/__tests__/agent-interaction-mutations.test.ts new file mode 100644 index 00000000000..ee1a58f108b --- /dev/null +++ b/server/src/__tests__/agent-interaction-mutations.test.ts @@ -0,0 +1,308 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, companies, createDb, documents, heartbeatRuns, issueComments, issueThreadInteractions, issueWorkProducts, issues, + EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { buildAgentMentionHref } from "@paperclipai/shared"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { issueService } from "../services/issues.js"; +import { documentService } from "../services/documents.js"; +import { workProductService } from "../services/work-products.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; +import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; +import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; + +describe("agent interaction mutation boundaries", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("agent-interaction-mutations-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), sender = randomUUID(), recipient = randomUUID(), issueId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Interactions", issuePrefix: `I${companyId.slice(0, 6)}` }); + await db.insert(agents).values([ + { id: sender, companyId, name: "Sender", adapterType: "process", status: "idle", permissions: {} }, + { id: recipient, companyId, name: "Recipient", adapterType: "process", status: "idle", permissions: {} }, + ]); + await db.insert(issues).values({ id: issueId, companyId, title: "Original", status: "todo", assigneeAgentId: recipient }); + return { companyId, sender, recipient, issueId }; + } + async function restrict(agentId: string, interactions: Record) { + await db.update(agents).set({ permissions: { interactions } }).where(eq(agents.id, agentId)); + } + const denied = (permission: string) => ({ status: 403, details: expect.objectContaining({ code: "agent_interaction_denied", permission }) }); + + it("keeps unrestricted peer comments and edits working without persisting defaults", async () => { + const f = await seed(), svc = issueService(db); + await svc.addComment(f.issueId, "Peer reply", { agentId: f.sender }); + await svc.update(f.issueId, { title: "Edited", actorAgentId: f.sender }); + const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + const rows = await db.select().from(agents).where(eq(agents.companyId, f.companyId)); + expect(task.title).toBe("Edited"); + expect(rows.every(row => Object.keys(row.permissions).length === 0)).toBe(true); + }); + + it("blocks incoming comments before saving anything, including through plugin host services", async () => { + const f = await seed(); + await restrict(f.recipient, { canReceiveCommentsFromAgents: false }); + const services = buildHostServices(db, "interaction-test", "interaction-test", { + forPlugin: () => ({ emit: vi.fn(), subscribe: vi.fn(), clear: vi.fn() }), + } as any); + try { + await expect(services.issues.createComment({ companyId: f.companyId, issueId: f.issueId, + body: "Blocked plugin reply", authorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveCommentsFromAgents")); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toEqual([]); + } finally { services.dispose(); } + }); + + it("rejects an outgoing comment on an unassigned task but permits self comments", async () => { + const f = await seed(), svc = issueService(db); + await restrict(f.sender, { canCommentOnOtherTasks: false }); + await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId)); + await expect(svc.addComment(f.issueId, "No", { agentId: f.sender })).rejects.toMatchObject(denied("canCommentOnOtherTasks")); + await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId)); + await svc.addComment(f.issueId, "Own work", { agentId: f.sender }); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(1); + }); + + it("rejects structured forbidden mentions even on the sender's own task", async () => { + const f = await seed(), svc = issueService(db); + await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId)); + await restrict(f.recipient, { canReceiveMentionsFromAgents: false }); + const mention = `[@Recipient](${buildAgentMentionHref(f.recipient)})`; + await expect(svc.addComment(f.issueId, mention, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveMentionsFromAgents")); + await expect(svc.update(f.issueId, { description: mention, actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveMentionsFromAgents")); + const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(task.description).toBeNull(); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toEqual([]); + await svc.addComment(f.issueId, "Plain prose about Recipient", { agentId: f.sender }); + }); + + it("blocks peer edits and reassignment-to-self against the current owner", async () => { + const f = await seed(), svc = issueService(db); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + await expect(svc.update(f.issueId, { description: "New instructions", actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + await expect(svc.update(f.issueId, { assigneeAgentId: f.sender, actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].assigneeAgentId).toBe(f.recipient); + }); + + it("keeps comments independent from task-edit permission", async () => { + const f = await seed(), svc = issueService(db); + await restrict(f.sender, { canEditOtherTasks: false }); + await svc.addComment(f.issueId, "Allowed comment", { agentId: f.sender }); + await svc.update(f.issueId, { actorAgentId: f.sender }); + await expect(svc.update(f.issueId, { title: "No", actorAgentId: f.sender })).rejects.toMatchObject(denied("canEditOtherTasks")); + }); + + it("rejects creating work for a protected recipient without inserting a task", async () => { + const f = await seed(); + await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false }); + await expect(issueService(db).create(f.companyId, { title: "Blocked new work", createdByAgentId: f.sender, + assigneeAgentId: f.recipient, status: "todo" })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents")); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1); + }); + + it("allows initial plan content under assignment authority without requiring peer edit permission", async () => { + const f = await seed(); + await restrict(f.sender, { canEditOtherTasks: false }); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + const created = await issueService(db).create(f.companyId, { title: "New assigned work", createdByAgentId: f.sender, + assigneeAgentId: f.recipient, initialPlan: "Initial instructions", status: "todo" }); + expect((await documentService(db).getIssueDocumentByKey(created.id, "plan"))?.body).toBe("Initial instructions"); + await expect(documentService(db).upsertIssueDocument({ issueId: created.id, key: "notes", format: "markdown", + body: "Later instructions", createdByAgentId: f.sender })).rejects.toMatchObject(denied("canEditOtherTasks")); + }); + + it("checks assignment restrictions on native create_task before saving tasks or enqueueing wakes", async () => { + const f = await seed(), runId = randomUUID(), wakes = vi.fn(); + await db.update(issues).set({ assigneeAgentId: f.sender, status: "in_progress" }).where(eq(issues.id, f.issueId)); + await db.update(agents).set({ adapterType: "paperclip_runner", status: "active" }).where(eq(agents.id, f.sender)); + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.sender, + status: "running", runtimeMode: "native", nativeIssueId: f.issueId, + invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: f.issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId)); + await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false }); + const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.sender, + issueId: f.issueId, runId, enqueueWakeup: wakes }); + await expect(authority.execute({ tool: "create_task", callId: randomUUID(), arguments: { + idempotencyKey: "protected-recipient", title: "Delegated", assigneeActorId: f.recipient, + } })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents")); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1); + expect(wakes).not.toHaveBeenCalled(); + }); + + it("treats an agent-addressed review as assignment even when task ownership stays unchanged", async () => { + const f = await seed(); + await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId)); + await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false }); + await expect(issueThreadInteractionService(db).create({ id: f.issueId, companyId: f.companyId }, { + kind: "ask_user_questions", resolverPolicy: "board_or_agents", addresseeAgentId: f.recipient, + continuationPolicy: "wake_assignee", payload: { version: 1, + questions: [{ id: "review", prompt: "Review?", selectionMode: "single", options: [{ id: "yes", label: "Yes" }] }] }, + }, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents")); + expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]); + }); + + it("rejects a native reassignment reason before stopping the currently running recipient", async () => { + const f = await seed(), runId = randomUUID(), targetRunId = randomUUID(), stop = vi.fn(), wake = vi.fn(); + await db.update(issues).set({ assigneeAgentId: f.sender, status: "in_progress" }).where(eq(issues.id, f.issueId)); + await db.update(agents).set({ adapterType: "paperclip_runner", status: "active" }).where(eq(agents.id, f.sender)); + const [target] = await db.insert(issues).values({ companyId: f.companyId, title: "Running peer task", + status: "in_progress", assigneeAgentId: f.recipient }).returning(); + await db.insert(heartbeatRuns).values([ + { id: runId, companyId: f.companyId, agentId: f.sender, status: "running", runtimeMode: "native", nativeIssueId: f.issueId, + invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: f.issueId } }, + { id: targetRunId, companyId: f.companyId, agentId: f.recipient, status: "running", runtimeMode: "native", nativeIssueId: target.id, + invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: target.id } }, + ]); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId)); + await db.update(issues).set({ executionRunId: targetRunId }).where(eq(issues.id, target.id)); + await restrict(f.recipient, { canReceiveCommentsFromAgents: false }); + const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.sender, + issueId: f.issueId, runId, enqueueWakeup: wake, stopTaskForReassignment: stop }); + await expect(authority.execute({ tool: "reassign_task", callId: randomUUID(), arguments: { + idempotencyKey: "protected-reason", taskId: target.id, assigneeActorId: f.sender, + expectedAssigneeActorId: f.recipient, expectedAssigneeUserId: null, expectedStatusVersion: target.statusVersion, + reason: "Taking over your task", + } })).rejects.toMatchObject(denied("canReceiveCommentsFromAgents")); + expect(stop).not.toHaveBeenCalled(); + expect(wake).not.toHaveBeenCalled(); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, targetRunId)))[0].status).toBe("running"); + expect((await db.select().from(issues).where(eq(issues.id, target.id)))[0].assigneeAgentId).toBe(f.recipient); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, target.id))).toEqual([]); + }); + + it("protects peer configuration suggestions before creating a confirmation card", async () => { + const f = await seed(); + await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId)); + await restrict(f.recipient, { canReceiveConfigurationChangesFromAgents: false }); + await expect(issueThreadInteractionService(db).create({ id: f.issueId, companyId: f.companyId }, { + kind: "request_confirmation", payload: { version: 1, prompt: "Change instructions?", + target: { type: "custom", key: `agent:${f.recipient}:instructions`, revisionId: "proposed" } }, + }, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveConfigurationChangesFromAgents")); + expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]); + }); + + it("preserves trusted plugin origin despite a supplied human comment actor", async () => { + const f = await seed(); + const write = () => withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.sender }, () => + issueService(db).addComment(f.issueId, "Plugin reply", { userId: "local-board" })); + const comment = await write(); + expect(comment.authorAgentId).toBe(f.sender); + expect(comment.authorUserId).toBeNull(); + await restrict(f.recipient, { canReceiveCommentsFromAgents: false }); + await expect(write()).rejects.toMatchObject(denied("canReceiveCommentsFromAgents")); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(1); + }); + + it("blocks plugin authorization-policy writes to a protected task", async () => { + const f = await seed(); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + const services = buildHostServices(db, "interaction-test", "interaction-test", { + forPlugin: () => ({ emit: vi.fn(), subscribe: vi.fn(), clear: vi.fn() }), + } as any); + try { + await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.sender }, () => + services.authorization.updatePolicy({ companyId: f.companyId, resourceType: "issue", resourceId: f.issueId, + policy: { example: "policy" } }))).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionPolicy).toBeNull(); + } finally { services.dispose(); } + }); + + it("blocks parent influence when creating a child even if the child is self-assigned", async () => { + const f = await seed(); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + await expect(issueService(db).create(f.companyId, { title: "Child", createdByAgentId: f.sender, + assigneeAgentId: f.sender, parentId: f.issueId, status: "todo" })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1); + }); + + it("protects document writes and restores without adding revisions", async () => { + const f = await seed(), svc = documentService(db); + const original = await svc.upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Original" }); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + await expect(svc.upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Peer instructions", + baseRevisionId: original.document.latestRevisionId, createdByAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + await expect(svc.deleteIssueDocument(f.issueId, "plan", f.sender)).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + expect((await svc.getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Original"); + }); + + it("checks mentions in documents and work products, and permits a human write", async () => { + const f = await seed(), docs = documentService(db), products = workProductService(db); + await restrict(f.sender, { canMentionAgents: false }); + const mention = `[@Recipient](${buildAgentMentionHref(f.recipient)})`; + await expect(docs.upsertIssueDocument({ issueId: f.issueId, key: "notes", format: "markdown", body: mention, + createdByAgentId: f.sender })).rejects.toMatchObject(denied("canMentionAgents")); + await expect(products.createForIssue(f.issueId, f.companyId, { type: "artifact", provider: "external", status: "ready", title: mention }, f.sender)) + .rejects.toMatchObject(denied("canMentionAgents")); + expect(await db.select().from(documents).where(eq(documents.companyId, f.companyId))).toEqual([]); + expect(await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, f.issueId))).toEqual([]); + await docs.upsertIssueDocument({ issueId: f.issueId, key: "notes", format: "markdown", body: mention }); + }); + + it("does not rescan an untouched historical mention when only unrelated fields change", async () => { + const f = await seed(); + await db.update(issues).set({ description: `[@Recipient](${buildAgentMentionHref(f.recipient)})` }).where(eq(issues.id, f.issueId)); + await restrict(f.sender, { canMentionAgents: false }); + await issueService(db).update(f.issueId, { priority: "high", actorAgentId: f.sender }); + expect((await db.select().from(issues).where(and(eq(issues.id, f.issueId), eq(issues.companyId, f.companyId))))[0].priority).toBe("high"); + }); + + it("checks a work product's destination task when moving it from the sender's own task", async () => { + const f = await seed(), products = workProductService(db); + const [own] = await db.insert(issues).values({ companyId: f.companyId, title: "Own task", assigneeAgentId: f.sender }).returning(); + const product = await products.createForIssue(own.id, f.companyId, + { type: "artifact", provider: "external", status: "ready", title: "Own output" }, f.sender); + await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false }); + await expect(products.update(product!.id, { issueId: f.issueId }, f.sender)) + .rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents")); + expect((await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.id, product!.id)))[0].issueId).toBe(own.id); + }); + + it("checks bulk import assignments before reserving issue numbers or inserting tasks", async () => { + const f = await seed(); + const [before] = await db.select().from(companies).where(eq(companies.id, f.companyId)); + await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false }); + await expect(issueService(db).importIssues(f.companyId, [{ id: randomUUID(), ref: "imported", title: "Imported task", + projectId: null, projectWorkspaceId: null, description: "New initial instructions", assigneeAgentId: f.recipient, + status: "todo", priority: "medium", billingCode: null, assigneeAdapterOverrides: null, + executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null, + }], f.sender)).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents")); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1); + expect((await db.select().from(companies).where(eq(companies.id, f.companyId)))[0].issueCounter).toBe(before.issueCounter); + }); + + it("waits for company accounting before locking actors during task creation", async () => { + const f = await seed(); + let creation: Promise | undefined; + try { + await db.transaction(async importer => { + await importer.select({ id: companies.id }).from(companies).where(eq(companies.id, f.companyId)).for("no key update"); + let started!: (pid: number) => void; + const creatorPid = new Promise(resolve => { started = resolve; }); + creation = db.transaction(async creator => { + const rows = await creator.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`); + started(rows[0].pid); + return issueService(db).create(f.companyId, { title: "Concurrent creation", assigneeAgentId: f.recipient, + createdByAgentId: f.sender, status: "todo" }, creator); + }); + const pid = await creatorPid; + let waitingQuery = ""; + for (let attempt = 0; attempt < 100; attempt += 1) { + const rows = await db.execute<{ query: string; wait_event_type: string | null }>( + sql`select query, wait_event_type from pg_stat_activity where pid = ${pid}`); + if (rows[0]?.wait_event_type === "Lock") { waitingQuery = rows[0].query; break; } + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(waitingQuery).toContain('"companies"'); + await importer.execute(sql`select id from ${agents} where id in (${f.sender}, ${f.recipient}) for no key update nowait`); + }); + } finally { await creation; } + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2); + }); +}); diff --git a/server/src/__tests__/agent-interaction-permissions.test.ts b/server/src/__tests__/agent-interaction-permissions.test.ts new file mode 100644 index 00000000000..5417a0760b0 --- /dev/null +++ b/server/src/__tests__/agent-interaction-permissions.test.ts @@ -0,0 +1,260 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { companyPortabilityService } from "../services/company-portability.js"; +import { createAgentLifecycle } from "../services/agent-lifecycle.js"; +import { toolAccessService } from "../services/tool-access.js"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js"; +import { updateAgentInteractionPermissions } from "../services/agent-interaction-permissions.js"; +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeFileStorageDb, principalPermissionGrants, + issues, issueComments, toolApplications, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileBindings, toolMcpGateways, + EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { AGENT_INTERACTION_PERMISSION_KEYS, resolveAgentInteractionPermissions } from "@paperclipai/shared"; +import { agentInteractionPermissionRoutes } from "../routes/agent-interaction-permissions.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentService } from "../services/agents.js"; +import { preserveAgentInteractionPermissions, assertNoInitialAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js"; + +const execute = promisify(execFile); + +describe("individual agent interaction permissions", () => { + let database: Awaited>; + let db: ReturnType; + let storageHome: string; + beforeAll(async () => { + storageHome = await mkdtemp(join(tmpdir(), "interaction-permissions-home-")); + vi.stubEnv("PAPERCLIP_HOME", storageHome); + database = await startEmbeddedPostgresTestDatabase("agent-interaction-permissions-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await closeFileStorageDb(db, 1); await db?.$client.end(); await database?.cleanup(); await rm(storageHome, { recursive: true, force: true }); vi.unstubAllEnvs(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), peerId = randomUUID(), userId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Interaction permissions", issuePrefix: `I${companyId.slice(0, 6)}` }); + await db.insert(authUsers).values({ id: userId, name: "Permission manager", email: `${userId}@example.com`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values([agentId, peerId].map(id => ({ id, companyId, name: id, adapterType: "process", status: "idle", + permissions: { canCreateAgents: true, unrelated: "preserved" } }))); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" }, + ...[agentId, peerId].map(principalId => ({ companyId, principalType: "agent", principalId, membershipRole: "member", status: "active" })), + ]); + await db.insert(principalPermissionGrants).values(["users:manage_permissions", "agents:read"].map(permissionKey => ({ + companyId, principalType: "user", principalId: userId, permissionKey, + }))); + return { companyId, agentId, peerId, userId }; + } + type Fixture = Awaited>; + const board = (f: Fixture): express.Request["actor"] => ({ type: "board", source: "session", userId: f.userId, + companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "member", status: "active" }] }); + const actor = (f: Fixture): express.Request["actor"] => ({ type: "agent", source: "agent_key", agentId: f.agentId, companyId: f.companyId }); + function app(identity: express.Request["actor"]) { + const api = express(); + api.use(express.json()); + api.use((req, _res, next) => { req.actor = identity; next(); }); + api.use("/api", agentInteractionPermissionRoutes(db)); + api.use(errorHandler); + return api; + } + const path = (f: Fixture) => `/api/agents/${f.agentId}/interaction-permissions`; + const read = (f: Fixture) => db.select().from(agents).where(eq(agents.id, f.agentId)).then(rows => rows[0]!); + + it("resolves absent settings without writing defaults, changes only supplied keys, and removes overrides with null", async () => { + const f = await seed(), api = app(board(f)); + const initial = await read(f); + const response = await request(api).get(path(f)); + expect(response.status, response.text).toBe(200); + expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, overrides: {}, effective: resolveAgentInteractionPermissions({}) }); + expect((await read(f)).permissions).toEqual(initial.permissions); + const first = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: false }); + expect(first.status, first.text).toBe(200); + expect(first.body.overrides).toEqual({ canReceiveCommentsFromAgents: false }); + const second = await request(api).patch(path(f)).send({ canAssignTasksToHumans: true }); + expect(second.body.overrides).toEqual({ canReceiveCommentsFromAgents: false, canAssignTasksToHumans: true }); + const reset = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: null, canAssignTasksToHumans: null }); + expect(reset.status, reset.text).toBe(200); + expect(reset.body.overrides).toEqual({}); + expect((await read(f)).permissions).toEqual(initial.permissions); + const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), eq(activityLog.action, "agent.interaction_permissions_updated"))); + expect(audit).toHaveLength(3); + expect(audit.every(row => row.actorId === f.userId)).toBe(true); + }); + + it("allows own configuration reads but denies agent/CEO changes, foreign users, and humans without permission management", async () => { + const f = await seed(), foreign = await seed(); + await db.update(agents).set({ role: "ceo" }).where(eq(agents.id, f.agentId)); + expect((await request(app(actor(f))).get(path(f))).status).toBe(200); + expect((await request(app(actor(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403); + expect((await request(app(board(foreign))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(404); + await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.peerId }, () => + updateAgentInteractionPermissions(db, board(f), f.companyId, f.agentId, { canMentionAgents: false }))) + .rejects.toMatchObject({ status: 403 }); + await db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, "users:manage_permissions"))); + expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403); + expect((await read(f)).permissions).not.toHaveProperty("interactions"); + }); + + it("rejects malformed patches and pending hires without side effects", async () => { + const f = await seed(), api = app(board(f)); + for (const input of [{}, { canMentionAgents: "false" }, { canMentionAgents: false, unexpected: false }]) { + expect((await request(api).patch(path(f)).send(input)).status).toBe(400); + } + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId)); + expect((await request(api).patch(path(f)).send({ canMentionAgents: false })).status).toBe(409); + expect((await read(f)).permissions).not.toHaveProperty("interactions"); + }); + + it("serializes independent policy patches and preserves restrictions through legacy permission saves", async () => { + const f = await seed(), api = app(board(f)); + const responses = await Promise.all([ + request(api).patch(path(f)).send({ canMentionAgents: false }), + request(api).patch(path(f)).send({ canAssignTasksToAgents: false }), + agentService(db).updatePermissions(f.agentId, { canCreateAgents: false }), + ]); + expect(responses.slice(0, 2).map(result => result && "status" in result ? result.status : null)).toEqual([200, 200]); + const saved = await read(f); + expect(saved.permissions).toMatchObject({ canCreateAgents: false, unrelated: "preserved", + interactions: { canMentionAgents: false, canAssignTasksToAgents: false } }); + await expect(agentService(db).updatePermissions(f.agentId, { canCreateAgents: false, interactions: {} })) + .rejects.toMatchObject({ status: 403 }); + await agentService(db).update(f.agentId, { permissions: { canCreateSkills: true } }, { recordRevision: { createdByUserId: f.userId } }); + expect((await read(f)).permissions.interactions).toEqual(saved.permissions.interactions); + expect(preserveAgentInteractionPermissions(saved.permissions, {})).toHaveProperty("interactions", saved.permissions.interactions); + expect(() => assertNoInitialAgentInteractionPermissions({ interactions: {} })).toThrow(/interaction-permissions/); + }); + + it("uses the real CLI against HTTP and PostgreSQL for inspect, all eleven settings, and reset", async () => { + const f = await seed(), api = app(board(f)); + const server = api.listen(0, "127.0.0.1"); + await new Promise(resolve => server.once("listening", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP fixture unavailable"); + const repo = fileURLToPath(new URL("../../../", import.meta.url)); + const cli = async (command: string, payload?: Record) => { + const result = await execute(process.execPath, ["--import", `${repo}cli/node_modules/tsx/dist/loader.mjs`, `${repo}cli/src/index.ts`, + "agent", command, f.agentId, "--api-base", `http://127.0.0.1:${address.port}`, "--api-key", "fixture-board-token", "--json", + ...(payload ? ["--payload-json", JSON.stringify(payload)] : [])], { + cwd: repo, env: { ...process.env, PAPERCLIP_TELEMETRY_DISABLED: "1" }, timeout: 60000, + }); + return JSON.parse(result.stdout); + }; + try { + expect((await cli("interaction-permissions")).overrides).toEqual({}); + expect((await cli("interaction-permissions:update", { canReceiveAssignmentsFromAgents: false })).overrides) + .toEqual({ canReceiveAssignmentsFromAgents: false }); + const allDisabled = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, false])); + expect((await cli("interaction-permissions:update", allDisabled)).effective).toEqual(allDisabled); + expect((await read(f)).permissions.interactions).toEqual(allDisabled); + const peer = await db.select().from(agents).where(eq(agents.id, f.peerId)); + expect(peer[0]!.permissions).not.toHaveProperty("interactions"); + expect((await cli("interaction-permissions:update", Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, null])))).overrides).toEqual({}); + expect((await read(f)).permissions).not.toHaveProperty("interactions"); + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }, 120000); + + it("preserves historical mentions on a full configuration save but rejects changed mention-bearing text", async () => { + const f = await seed(); + const mention = `Existing [@Peer](agent://${f.peerId}) context`; + await db.update(agents).set({ capabilities: mention }).where(eq(agents.id, f.agentId)); + expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(200); + const options = { recordRevision: { createdByAgentId: f.agentId, source: "interaction-test" } }; + await agentService(db).update(f.agentId, { title: "Updated title", capabilities: mention }, options); + expect((await read(f)).title).toBe("Updated title"); + await expect(agentService(db).update(f.agentId, { title: "Rejected title", capabilities: `${mention} changed` }, options)) + .rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } }); + expect((await read(f)).title).toBe("Updated title"); + const peer = { ...f, agentId: f.peerId }; + expect((await request(app(board(f))).patch(path(peer)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200); + await expect(agentService(db).update(f.peerId, { title: "Prohibited peer change" }, options)) + .rejects.toMatchObject({ status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } }); + expect((await read(peer)).title).toBeNull(); + }); + it("blocks protected peer tool assignments without affecting shared profile changes", async () => { + const f = await seed(); + const target = { ...f, agentId: f.peerId }; + expect((await request(app(board(f))).patch(path(target)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200); + const [profile] = await db.insert(toolProfiles).values({ companyId: f.companyId, profileKey: "interaction-tools", name: "Interaction tools" }).returning(); + const [application] = await db.insert(toolApplications).values({ companyId: f.companyId, name: "Tool fixture", type: "mcp_http" }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id, + name: "Tool connection", uid: randomUUID(), transport: "mcp_remote", authKind: "none", enabled: true, status: "active" }).returning(); + const service = toolAccessService(db); + const source = { actorType: "agent" as const, actorId: f.agentId }; + const denied = { status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } }; + await expect(service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 }, source)).rejects.toMatchObject(denied); + await expect(service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: f.peerId }] }, source)).rejects.toMatchObject(denied); + await expect(createToolGatewayService(db).createNamedGateway({ companyId: f.companyId, + body: { name: "Protected gateway", profileId: profile.id, agentId: f.peerId }, actor: { agentId: f.agentId } })).rejects.toMatchObject(denied); + expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.companyId, f.companyId))).toHaveLength(0); + expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId))).toHaveLength(0); + expect(await db.select().from(toolMcpGateways).where(eq(toolMcpGateways.companyId, f.companyId))).toHaveLength(0); + await service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 }); + await withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.agentId }, async () => { + await service.updateProfile(profile.id, { description: "Shared profile remains editable" }); + await expect(service.unbindProfile(profile.id, { targetType: "agent", targetId: f.peerId })).rejects.toMatchObject(denied); + const gateway = await createToolGatewayService(db).createNamedGateway({ companyId: f.companyId, + body: { name: "Own gateway", profileId: profile.id, agentId: f.agentId } }); + await expect(createToolGatewayService(db).updateNamedGateway({ companyId: f.companyId, gatewayId: gateway.id, + body: { agentId: f.peerId } })).rejects.toMatchObject(denied); + }); + expect((await service.getProfile(profile.id, f.companyId)).description).toBe("Shared profile remains editable"); + }); + + it("preserves importer identity and rolls back forbidden imported work while retaining historical comments", async () => { + const f = await seed(); + await db.update(agents).set({ name: "Peer" }).where(eq(agents.id, f.peerId)); + const target = { ...f, agentId: f.peerId }; + const files: Record = { + "COMPANY.md": '---\nschema: "agentcompanies/v1"\nname: "Imported"\n---\n', + "tasks/work/TASK.md": '---\nname: "Imported task"\nassignee: "peer"\n---\nInitial instructions.', + "tasks/work/doc.md": "Initial document.", + ".paperclip.yaml": 'schemaVersion: 7\ntasks:\n work:\n status: todo\n comments:\n - body: "Historical comment"\n authorType: system\n documents:\n - key: spec\n title: Specification\n format: markdown\n path: tasks/work/doc.md\n', + }; + const input = { source: { type: "inline" as const, files }, include: { company: false, agents: false, projects: false, issues: true }, + target: { mode: "existing_company" as const, companyId: f.companyId }, collisionStrategy: "skip" as const }; + const options = { mode: "agent_safe" as const, sourceCompanyId: f.companyId, actorAgentId: f.agentId, actorAgentCompanyId: f.companyId }; + await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: false }); + await expect(companyPortabilityService(db).importBundle(input, null, options)) + .rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents" } }); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0); + await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: null, canReceiveCommentsFromAgents: false }); + await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false, canCommentOnOtherTasks: false }); + files["tasks/work/doc.md"] = `[@Peer](agent://${f.peerId})`; + await expect(companyPortabilityService(db).importBundle(input, null, options)) + .rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } }); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0); + files["tasks/work/doc.md"] = "Initial document."; + await companyPortabilityService(db).importBundle(input, null, options); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual([ + expect.objectContaining({ assigneeAgentId: f.peerId }), + ]); + expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toEqual([ + expect.objectContaining({ body: "Historical comment", authorType: "system", authorAgentId: null }), + ]); + files["tasks/work/TASK.md"] = '---\nname: "Unassigned imported task"\n---\nInitial instructions.'; + files["agents/new-peer/AGENTS.md"] = '---\nname: "New Peer"\nrole: engineer\n---\nInitial instructions.'; + files[".paperclip.yaml"] += 'agents:\n new-peer:\n adapterType: claude_local\n'; + const created = await companyPortabilityService(db).importBundle({ ...input, adapterOverrides: { "new-peer": { adapterType: "claude_local" } }, include: { ...input.include, agents: true }, + target: { mode: "new_company", newCompanyName: "Agent imported company" } }, null, options); + expect(created.warnings.filter(warning => warning.includes("materialize"))).toEqual([]); + expect(await db.select().from(issues).where(eq(issues.companyId, created.company.id))).toHaveLength(1); + expect(await db.select().from(agents).where(eq(agents.companyId, created.company.id))).toEqual([ + expect.objectContaining({ name: "New Peer" }), + ]); + await expect(createAgentLifecycle(db).requestHire(f.companyId, { name: "Mentioned hire", adapterType: "process", capabilities: `[@Peer](agent://${f.peerId})` }, + { createdByAgentId: f.agentId })).rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } }); + expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Mentioned hire")))).toHaveLength(0); + }); + +}); diff --git a/server/src/__tests__/agent-interaction-policy.test.ts b/server/src/__tests__/agent-interaction-policy.test.ts new file mode 100644 index 00000000000..11959320936 --- /dev/null +++ b/server/src/__tests__/agent-interaction-policy.test.ts @@ -0,0 +1,197 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, authUsers, companies, companyMemberships, createDb, issues, principalPermissionGrants, + EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { AGENT_INTERACTION_PERMISSION_KEYS, type AgentInteractionPermissionKey } from "@paperclipai/shared"; +import { eq } from "drizzle-orm"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentInteractionDenial, assertAgentInteraction, type AgentInteractionInput } from "../services/agent-interaction-policy.js"; +import { authorizationService, authorizationDeniedDetails } from "../services/authorization.js"; +import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js"; + +describe("agent interaction authorization", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("agent-interaction-policy-"); + db = createDb(database.connectionString); + await db.insert(authUsers).values({ id: "human", name: "Human", email: "human@example.com", createdAt: new Date(), updatedAt: new Date() }); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), actorAgentId = randomUUID(), targetAgentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Interactions", issuePrefix: `I${companyId.slice(0, 6)}` }); + await db.insert(agents).values([actorAgentId, targetAgentId].map((id, i) => ({ + id, companyId, name: `Agent ${i}`, status: "idle", adapterType: "process", permissions: {}, + }))); + await db.insert(companyMemberships).values([actorAgentId, targetAgentId].map(principalId => ({ + companyId, principalId, principalType: "agent", membershipRole: "member", status: "active", + }))); + const [task, own, unassigned, human, conversation] = await db.insert(issues).values([ + { assigneeAgentId: targetAgentId }, { assigneeAgentId: actorAgentId }, {}, { assigneeUserId: "human" }, + { conversationAgentId: actorAgentId, conversationUserId: "human", assigneeAgentId: actorAgentId, conversationState: "active" as const }, + ].map((value, i) => ({ companyId, title: `Task ${i}`, status: "todo", ...value }))).returning(); + return { companyId, actorAgentId, targetAgentId, task: task!, own: own!, unassigned: unassigned!, human: human!, conversation: conversation! }; + } + type Fixture = Awaited>; + async function set(agentId: string, key: AgentInteractionPermissionKey, value = false) { + await db.update(agents).set({ permissions: { interactions: { [key]: value } } }).where(eq(agents.id, agentId)); + } + const input = (f: Fixture, operation: AgentInteractionInput["operation"], more: Partial = {}): AgentInteractionInput => ({ + companyId: f.companyId, actorAgentId: f.actorAgentId, operation, ...more, + }); + + const cases: Array<[AgentInteractionPermissionKey, "incoming" | "outgoing", AgentInteractionInput["operation"]]> = [ + ["canAssignTasksToAgents", "outgoing", "assign"], ["canReceiveAssignmentsFromAgents", "incoming", "assign"], + ["canAssignTasksToHumans", "outgoing", "assign"], ["canMentionAgents", "outgoing", "mention"], + ["canReceiveMentionsFromAgents", "incoming", "mention"], ["canCommentOnOtherTasks", "outgoing", "comment"], + ["canReceiveCommentsFromAgents", "incoming", "comment"], ["canEditOtherTasks", "outgoing", "edit"], + ["canReceiveTaskEditsFromAgents", "incoming", "edit"], ["canConfigureOtherAgents", "outgoing", "configure"], + ["canReceiveConfigurationChangesFromAgents", "incoming", "configure"], + ]; + it.each(cases)("%s independently restricts its operation and can be restored", async (key, direction, operation) => { + const f = await seed(); + const action = input(f, operation, { + ...(operation === "comment" || operation === "edit" ? { issueId: f.task.id } : + key === "canAssignTasksToHumans" ? { assigneeUserId: "human" } : { targetAgentId: f.targetAgentId }), + }); + expect(await agentInteractionDenial(db, action)).toBeNull(); + const policyAgentId = direction === "incoming" ? f.targetAgentId : f.actorAgentId; + await set(policyAgentId, key); + expect(await agentInteractionDenial(db, action)).toMatchObject({ permission: key, direction, policyAgentId }); + await expect(assertAgentInteraction(db, action)).rejects.toMatchObject({ status: 403, details: { code: "agent_interaction_denied", permission: key } }); + expect(await agentInteractionDenial(db, { ...action, actorAgentId: null })).toBeNull(); + if (operation !== "configure") expect(await agentInteractionDenial(db, input(f, "configure", { targetAgentId: f.targetAgentId }))).toBeNull(); + await set(policyAgentId, key, true); + expect(await agentInteractionDenial(db, action)).toBeNull(); + await db.update(agents).set({ permissions: {} }).where(eq(agents.id, policyAgentId)); + expect(await agentInteractionDenial(db, action)).toBeNull(); + }); + + it("uses current ownership including conversations, never creator or manager relationships", async () => { + const f = await seed(); + await db.update(agents).set({ reportsTo: f.actorAgentId }).where(eq(agents.id, f.targetAgentId)); + await db.update(issues).set({ createdByAgentId: f.actorAgentId, parentId: f.own.id }).where(eq(issues.id, f.task.id)); + for (const operation of ["comment", "edit"] as const) { + await set(f.actorAgentId, operation === "comment" ? "canCommentOnOtherTasks" : "canEditOtherTasks"); + for (const task of [f.own, f.conversation]) expect(await agentInteractionDenial(db, input(f, operation, { issueId: task.id }))).toBeNull(); + for (const task of [f.task, f.unassigned, f.human]) expect(await agentInteractionDenial(db, input(f, operation, { issueId: task.id }))).not.toBeNull(); + } + await db.update(issues).set({ assigneeAgentId: f.targetAgentId }).where(eq(issues.id, f.own.id)); + expect(await agentInteractionDenial(db, input(f, "edit", { issueId: f.own.id }))).not.toBeNull(); + }); + + it("protects a source owner from self-claim/unassignment and parent edits independently of destination", async () => { + const f = await seed(); + await set(f.targetAgentId, "canReceiveTaskEditsFromAgents"); + for (const destination of [{}, { targetAgentId: f.actorAgentId }, { assigneeUserId: "human" }]) { + expect(await agentInteractionDenial(db, input(f, "assign", { issueId: f.task.id, ...destination }))) + .toMatchObject({ permission: "canReceiveTaskEditsFromAgents", operation: "edit" }); + } + expect(await agentInteractionDenial(db, input(f, "assign", { parentIssueId: f.task.id, targetAgentId: f.actorAgentId }))) + .toMatchObject({ issueId: f.task.id, permission: "canReceiveTaskEditsFromAgents" }); + expect(await agentInteractionDenial(db, input(f, "assign", { targetAgentId: f.targetAgentId }))).toBeNull(); + expect(await agentInteractionDenial(db, input(f, "assign", { issueId: f.task.id, targetAgentId: f.targetAgentId, delivery: true }))).toBeNull(); + }); + + it("checks every structured mention alongside the operation while allowing self and ordinary text", async () => { + const f = await seed(), foreign = await seed(); + await set(f.targetAgentId, "canReceiveMentionsFromAgents"); + const action = input(f, "comment", { issueId: f.own.id }); + expect(await agentInteractionDenial(db, { ...action, texts: [`[@self](agent://${f.actorAgentId}) and [@target](agent://${f.targetAgentId})`] })) + .toMatchObject({ operation: "mention", targetAgentId: f.targetAgentId }); + expect(await agentInteractionDenial(db, { ...action, texts: [`@Agent 1 [@self](agent://${f.actorAgentId}) [@foreign](agent://${foreign.targetAgentId}) [@unknown](agent://not-a-uuid)`] })).toBeNull(); + await set(f.actorAgentId, "canMentionAgents"); + expect(await agentInteractionDenial(db, { ...action, texts: [`[@self](agent://${f.actorAgentId})`] })).toBeNull(); + }); + + it("enforces explicit denials after shared authorization, including CEO/grants and responsible-human attribution", async () => { + const f = await seed(); + await db.update(agents).set({ role: "ceo", permissions: { canCreateAgents: true } }).where(eq(agents.id, f.actorAgentId)); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.actorAgentId, permissionKey: "tasks:assign" }); + const actor = { type: "agent" as const, agentId: f.actorAgentId, companyId: f.companyId }; + const resource = { type: "issue" as const, companyId: f.companyId, assigneeAgentId: f.targetAgentId }; + expect((await authorizationService(db).decide({ actor, action: "tasks:assign", resource })).allowed).toBe(true); + await set(f.targetAgentId, "canReceiveAssignmentsFromAgents"); + const decision = await authorizationService(db).decide({ actor, action: "tasks:assign", resource }); + expect(decision).toMatchObject({ allowed: false, reason: "deny_agent_interaction" }); + expect(authorizationDeniedDetails(decision)).toMatchObject({ code: "agent_interaction_denied", permission: "canReceiveAssignmentsFromAgents", direction: "incoming" }); + await db.update(agents).set({ permissions: { trustPreset: "low_trust_review", interactions: { canAssignTasksToAgents: true }, + authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, issueIds: [f.own.id] } } } }).where(eq(agents.id, f.actorAgentId)); + await set(f.targetAgentId, "canReceiveAssignmentsFromAgents", true); + expect((await authorizationService(db).decide({ actor, action: "tasks:assign", resource })).allowed).toBe(false); + }); + + it("preserves sparse existing records and checks fresh policy even for a reused authorizer", async () => { + const f = await seed(); + const auth = authorizationService(db); + const query = { actor: { type: "agent" as const, agentId: f.actorAgentId, companyId: f.companyId }, action: "issue:comment" as const, + resource: { type: "issue" as const, companyId: f.companyId, issueId: f.task.id, assigneeAgentId: f.targetAgentId, assigneeUserId: null, status: "todo" } }; + expect((await auth.decide(query)).allowed).toBe(true); + expect((await db.select().from(agents).where(eq(agents.id, f.actorAgentId)))[0]?.permissions).toEqual({}); + await set(f.targetAgentId, "canReceiveCommentsFromAgents"); + expect((await auth.decide(query)).allowed).toBe(false); + expect(cases.map(([key]) => key).sort()).toEqual([...AGENT_INTERACTION_PERMISSION_KEYS].sort()); + }); + + it("serializes an authorized mutation with a concurrent human permission change", async () => { + const f = await seed(); + let locked!: () => void; + const ready = new Promise(resolve => { locked = resolve; }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const mutation = db.transaction(async tx => { + await assertAgentInteraction(tx, input(f, "edit", { issueId: f.task.id, lock: true })); + await tx.update(issues).set({ title: "Authorized before revocation" }).where(eq(issues.id, f.task.id)); + locked(); + await held; + }); + await ready; + try { + await expect(db.transaction(async tx => { + await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.targetAgentId)).for("update", { noWait: true }); + })).rejects.toMatchObject({ cause: { code: "55P03" } }); + } finally { + release(); + await mutation; + } + await set(f.targetAgentId, "canReceiveTaskEditsFromAgents"); + await expect(db.transaction(async tx => { + await assertAgentInteraction(tx, input(f, "edit", { issueId: f.task.id, lock: true })); + await tx.update(issues).set({ title: "Must not persist" }).where(eq(issues.id, f.task.id)); + })).rejects.toMatchObject({ status: 403 }); + expect((await db.select({ title: issues.title }).from(issues).where(eq(issues.id, f.task.id)))[0]?.title).toBe("Authorized before revocation"); + }); + + it("uses trusted plugin origin even when a handler labels the operation human or another agent", async () => { + const f = await seed(); + await set(f.targetAgentId, "canReceiveCommentsFromAgents"); + await withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.actorAgentId }, async () => { + for (const actorAgentId of [null, f.targetAgentId]) { + expect(await agentInteractionDenial(db, input(f, "comment", { actorAgentId, issueId: f.task.id }))) + .toMatchObject({ permission: "canReceiveCommentsFromAgents", actorAgentId: f.actorAgentId }); + } + const decision = await authorizationService(db).decide({ + actor: { type: "board", source: "local_implicit" }, action: "issue:comment", + resource: { type: "issue", companyId: f.companyId, issueId: f.task.id }, + }); + expect(decision).toMatchObject({ allowed: false, code: "agent_interaction_denied" }); + await expect(agentInteractionDenial(db, input(f, "comment", { companyId: randomUUID() }))) + .rejects.toMatchObject({ status: 404 }); + }); + }); + + it("checks the original company's sender for an independently authorized cross-company import", async () => { + const source = await seed(), destination = await seed(); + const action = input(destination, "assign", { actorAgentId: source.actorAgentId, targetAgentId: destination.targetAgentId }); + await expect(agentInteractionDenial(db, action)).rejects.toMatchObject({ status: 404 }); + const attributed = { ...action, actorCompanyId: source.companyId }; + expect(await agentInteractionDenial(db, attributed)).toBeNull(); + await set(source.actorAgentId, "canAssignTasksToAgents"); + expect(await agentInteractionDenial(db, attributed)).toMatchObject({ permission: "canAssignTasksToAgents", direction: "outgoing" }); + await set(source.actorAgentId, "canAssignTasksToAgents", true); + await set(destination.targetAgentId, "canReceiveAssignmentsFromAgents"); + expect(await agentInteractionDenial(db, attributed)).toMatchObject({ permission: "canReceiveAssignmentsFromAgents", direction: "incoming" }); + }); +}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 132e8dee1af..2c9759fd40a 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1370,7 +1370,7 @@ describe("agent permission routes", () => { expect.objectContaining({ status: "idle", }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, ); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( companyId, @@ -1500,7 +1500,7 @@ describe("agent permission routes", () => { }, }, }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1537,7 +1537,7 @@ describe("agent permission routes", () => { model: DEFAULT_OPENCODE_LOCAL_MODEL, }), }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1576,7 +1576,7 @@ describe("agent permission routes", () => { model: "anthropic/claude-sonnet-4-5", }), }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1618,6 +1618,8 @@ describe("agent permission routes", () => { { runnerResolved: true, aiConnectionInstall: undefined, + createdByAgentId: null, + interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, @@ -1992,7 +1994,7 @@ describe("agent permission routes", () => { expect.objectContaining({ defaultEnvironmentId: environmentId, }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -2079,7 +2081,7 @@ describe("agent permission routes", () => { adapterType: expectedAdapterType, defaultEnvironmentId: environmentId, }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType)) { if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); @@ -2303,7 +2305,7 @@ describe("agent permission routes", () => { expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true, - }); + }, { actorAgentId: null }); expect(res.body.permissions.canCreateSkills).toBe(false); }); @@ -2344,7 +2346,7 @@ describe("agent permission routes", () => { expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ canCreateAgents: false, ceoHiringDeniedBy: expect.objectContaining({ actorType: "user", actorId: "board-user", at: expect.any(String) }), - })); + }), { actorAgentId: null }); // The toggle is authoritative: turning it off also removes an explicit agents:create grant. expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(companyId, "agent", agentId, "agents:create", false, null); @@ -2357,7 +2359,7 @@ describe("agent permission routes", () => { .send({ canCreateAgents: false, canAssignTasks: true, trustPreset: "standard" })); expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ trustPreset: "standard", canCreateAgents: false, ceoHiringDeniedBy: expect.objectContaining({ actorId: "board-user" }), - })); + }), { actorAgentId: null }); // An earlier record is kept rather than restamped. const earlier = { actorType: "user", actorId: "first-board-user", at: "2026-11-01T00:00:00.000Z" }; @@ -2366,7 +2368,7 @@ describe("agent permission routes", () => { await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/permissions`) .send({ canCreateAgents: false, canCreateSkills: false, canAssignTasks: true })); - expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ ceoHiringDeniedBy: earlier })); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ ceoHiringDeniedBy: earlier }), { actorAgentId: null }); // Other roles get no record. mockAgentService.updatePermissions.mockClear(); @@ -2374,7 +2376,7 @@ describe("agent permission routes", () => { await requestApp(app, (baseUrl) => request(baseUrl) .patch(`/api/agents/${agentId}/permissions`) .send({ canCreateAgents: false, canAssignTasks: true })); - expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true }); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true }, { actorAgentId: null }); }); it("rejects CEO permission updates outside the caller company scope", async () => { diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index dce83a29d4c..decbdeb4a46 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -967,7 +967,7 @@ describe("agent skill routes", () => { }), }), }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1019,7 +1019,7 @@ describe("agent skill routes", () => { expect.objectContaining({ role: "security", }), - { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1473,6 +1473,8 @@ describe("agent skill routes", () => { { runnerResolved: true, aiConnectionInstall: undefined, + createdByAgentId: null, + interactionTexts: [expect.any(String)], createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 9ceb3a0ee18..9fa666c38a6 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -78646,7 +78646,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { kind: "request_confirmation", status: "pending", effectiveResolverPolicy: "human_only", - createdByAgentId: null, + createdByAgentId: fixture.assignedAgentId, createdByUserId: null, }); await db diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 7dc7400a1d5..a04c2e87ff2 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -1268,7 +1268,7 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith( { ...importRequest, pauseAutomations: true }, null, - { mode: "agent_safe", sourceCompanyId: companyId, pauseAutomations: true, assertCanCreateAgents: expect.any(Function) }, + { mode: "agent_safe", sourceCompanyId: companyId, actorAgentId: ceoAgentId, actorAgentCompanyId: companyId, pauseAutomations: true, assertCanCreateAgents: expect.any(Function) }, ); }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 347d01c29c9..11c1822fdc2 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1545,7 +1545,7 @@ describe("company portability", () => { projectWorkspaceId: "workspace-imported", title: "Write launch task", }), - ])); + ]), undefined, undefined); }); it("normalizes invalid imported project icon names to null", async () => { @@ -4015,6 +4015,8 @@ describe("company portability", () => { labelIds: ["label-created-bug", "label-created-urgent"], }), ]), + undefined, + undefined, ); }); @@ -4073,6 +4075,8 @@ describe("company portability", () => { labelIds: ["target-bug", "label-created-urgent"], }), ]), + undefined, + undefined, ); expect(result.warnings).toContain( "Existing label color was kept for bug; the imported bundle used different colors.", @@ -4131,6 +4135,8 @@ describe("company portability", () => { expect.arrayContaining([ expect.objectContaining({ labelIds: [] }), ]), + undefined, + undefined, ); expect(result.warnings).toContain( "Task kickoff dropped 2 label references because the bundle carries raw label ids that do not exist in the target company.", @@ -4370,6 +4376,8 @@ describe("company portability", () => { sourceTrust: null, }), ]), + undefined, + undefined, ); expect(insertedRelationValues).toEqual([ { @@ -4424,6 +4432,8 @@ describe("company portability", () => { expect.arrayContaining([ expect.objectContaining({ title: "Beta task" }), ]), + undefined, + undefined, ); expect(documentSvc.createIssueDocumentsForImport).not.toHaveBeenCalled(); expect(workProductSvc.createManyForImport).not.toHaveBeenCalled(); @@ -5283,6 +5293,8 @@ describe("company portability", () => { expect.arrayContaining([ expect.objectContaining({ title: "Kickoff" }), ]), + undefined, + undefined, ); expect(result.warnings).toContain(v5Warning); }); @@ -5431,7 +5443,7 @@ describe("company portability", () => { metadata, createdAt: "2026-05-04T12:00:00.000Z", }), - ])); + ]), undefined, undefined); }); it("does not export raw comment author user ids", async () => { @@ -5547,7 +5559,7 @@ describe("company portability", () => { metadata: null, createdAt: "2026-05-04T12:00:00.000Z", }), - ])); + ]), undefined, undefined); expect(result.warnings).toContain( "Comment on task pap-1 was imported as a system comment because no importing user was available.", ); diff --git a/server/src/__tests__/decision-effect-recovery-work.test.ts b/server/src/__tests__/decision-effect-recovery-work.test.ts index 8a7d4e300c8..fbaf7a40dbf 100644 --- a/server/src/__tests__/decision-effect-recovery-work.test.ts +++ b/server/src/__tests__/decision-effect-recovery-work.test.ts @@ -207,7 +207,15 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes expect(await service().recoverEffects(new Date(at.getTime() + 60_000))).toEqual({ resumed: 1 }); vi.stubEnv("PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS", "0"); const second = await create(); await claim(second.id, at); - expect(await service().recoverEffects(at)).toEqual({ resumed: 1 }); expect(await comments()).toHaveLength(2); + expect(await service().recoverEffects(at)).toEqual({ resumed: 1 }); + const saved = await comments(); + expect(saved).toHaveLength(2); + expect(saved.map(comment => comment.createdByRunId)).toEqual([runId, runId]); + expect(saved.map(comment => comment.clientRequestId).sort()).toEqual([ + `decision-effect:${row.id}:0`, `decision-effect:${second.id}:0`, + ].sort()); + expect(await service().recoverEffects(at)).toEqual({ resumed: 0 }); + expect(await comments()).toHaveLength(2); }); it("joins active foreground effects when their recovery deadline arrives", async () => { diff --git a/server/src/__tests__/decisions-service.test.ts b/server/src/__tests__/decisions-service.test.ts index b54247a2d56..a99f9ae81ab 100644 --- a/server/src/__tests__/decisions-service.test.ts +++ b/server/src/__tests__/decisions-service.test.ts @@ -413,6 +413,29 @@ describePg("decisionService", () => { expect((await db.select().from(issues).where(eq(issues.id, unreviewedChildId)))[0]?.status).toBe("todo"); }); + it.each(["canCommentOnOtherTasks", "canReceiveCommentsFromAgents"] as const)("rejects a cancellation reason when %s is revoked before the human decision", async permission => { + const peerId = randomUUID(); + const childId = randomUUID(); + await db.insert(agents).values({ id: peerId, companyId, name: "Recipient", adapterType: "codex_local" }); + await db.update(issues).set({ assigneeAgentId: peerId }).where(eq(issues.id, targetIssueId)); + await db.insert(issues).values({ id: childId, companyId, title: "Child", status: "todo", parentId: targetIssueId }); + const created = await service().create({ + companyId, actor: agentActor(), agentId, runId, title: "Cancel?", body: "Body", + options: [{ id: "yes", label: "Yes", effects: [{ type: "cancel_issue_tree", targetIssueId, + staleness: "strict", reasonComment: "Agent-authored cancellation reason" }] }], + }); + await db.update(agents).set({ permissions: { interactions: { [permission]: false } } }) + .where(eq(agents.id, permission === "canCommentOnOtherTasks" ? agentId : peerId)); + + const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() }); + + expect(result.executions[0]).toMatchObject({ status: "failed", + result: { message: expect.stringContaining(permission) } }); + expect(await db.select({ status: issues.status }).from(issues).where(inArray(issues.id, [targetIssueId, childId]))) + .toEqual([{ status: "todo" }, { status: "todo" }]); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0); + }); + it("bounds cyclic issue traversal for snapshots and cancel-tree execution", async () => { const childId = randomUUID(); await db.insert(issues).values({ id: childId, companyId, title: "Cycle child", status: "todo", priority: "medium", diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs index e927b4681ef..f01e0a0d701 100644 --- a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -9,7 +9,7 @@ function send(message) { function sendNestedHostRequest(originalRequest, invocationId) { const nestedId = `nested-${nextRequestId++}`; - const params = originalRequest.params?.params ?? {}; + const params = originalRequest.params?.params ?? originalRequest.params?.parameters ?? {}; const mode = params.mode; const requestedCompanyId = params.requestedCompanyId; const hostMethod = params.hostMethod || "companies.get"; @@ -98,13 +98,13 @@ rl.on("line", (line) => { id: message.id, result: { ok: true, - supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"], + supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation", "executeTool"], }, }); return; } - if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) { + if (["getData", "performAction", "resourceLifecycle", "workspaceOperation", "executeTool"].includes(method)) { sendNestedHostRequest(message, message.paperclipInvocation?.id); return; } diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 3aa93293e45..95352a968fe 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -392,24 +392,36 @@ function createRunContextDb( } return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }]; }; - const buildQuery = (selection: Record, chatBindingQuery = false, settledRecoveryQuery = false) => { + const buildQuery = (selection: Record, chatBindingQuery = false, settledRecoveryQuery = false, + policyTable?: "issues" | "agents") => { + let selectedIds: string[] = []; + const selectedRows = async () => { + if (policyTable) { + const getById = policyTable === "issues" ? mockIssueService.getById : mockAgentService.getById; + return (await Promise.all(selectedIds.map(id => getById(id)))) + .filter(row => row && selectedIds.includes(row.id)); + } + return rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery); + }; const whereResult = { - orderBy: vi.fn(async () => []), + orderBy: vi.fn((): unknown => policyTable ? whereResult : Promise.resolve([])), limit: vi.fn(() => ({ - then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await selectedRows()), for: vi.fn(() => ({ - then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await selectedRows()), })), })), for: vi.fn(() => ({ - then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + then: async (resolve: (rows: unknown[]) => unknown) => resolve(await selectedRows()), })), - then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)), + then: async (resolve: (rows: unknown[]) => unknown) => resolve(await selectedRows()), }; const query = { innerJoin: vi.fn(() => query), where: vi.fn((condition: SQL) => { if (chatBindingQuery) chatBindingQueries.push(new PgDialect().sqlToQuery(condition)); + if (policyTable) selectedIds = [...new Set(new PgDialect().sqlToQuery(condition).params + .filter((value): value is string => typeof value === "string" && value !== companyId))]; return whereResult; }), }; @@ -438,6 +450,12 @@ function createRunContextDb( if (getTableName(table) === "issue_thread_interactions") { return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) }; } + if (getTableName(table) === "issues" && "conversationAgentId" in selection) { + return buildQuery(selection, false, false, "issues"); + } + if (getTableName(table) === "agents" && Object.keys(selection).sort().join(",") === "companyId,id,permissions") { + return buildQuery(selection, false, false, "agents"); + } return buildQuery(selection, getTableName(table) === "chat_conversations", getTableName(table) === "issue_recovery_actions"); }), })), @@ -1254,6 +1272,7 @@ describe("agent issue mutation checkout ownership", () => { changedFiles: 3, }), }), + ownerAgentId, ); }); @@ -1283,6 +1302,7 @@ describe("agent issue mutation checkout ownership", () => { expect.objectContaining({ metadata: expect.objectContaining({ additions: 11, deletions: 3, changedFiles: 2 }), }), + ownerAgentId, ); }); @@ -1707,7 +1727,7 @@ describe("agent issue mutation checkout ownership", () => { expect.any(Object), ); expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); - expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }); + expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }, ownerAgentId); }); it("preserves board mutations on active checkouts", async () => { @@ -3482,6 +3502,7 @@ describe("agent issue mutation checkout ownership", () => { ancestryParentId?: string | null; watchdogRows?: Record[]; } = {}) { + const policyDb = createRunContextDb(); const watchedIssueId = options.watchedIssueId ?? issueId; const runRows = [{ id: watchdogRunId, @@ -3530,8 +3551,13 @@ describe("agent issue mutation checkout ownership", () => { return { transaction: async (callback: (tx: Record) => Promise) => callback({}), select: vi.fn((selection: Record = {}) => ({ - from: vi.fn((table: Parameters[0]) => getTableName(table) === "case_handoffs" - ? emptyCaseHandoffQuery() : buildQuery(selection)), + from: vi.fn((table: Parameters[0]) => { + if ((getTableName(table) === "issues" && "conversationAgentId" in selection) + || (getTableName(table) === "agents" && Object.keys(selection).sort().join(",") === "companyId,id,permissions")) { + return policyDb.select(selection).from(table); + } + return getTableName(table) === "case_handoffs" ? emptyCaseHandoffQuery() : buildQuery(selection); + }), })), }; } diff --git a/server/src/__tests__/issue-assignee-invokability-routes.test.ts b/server/src/__tests__/issue-assignee-invokability-routes.test.ts index 7493147146c..45f0b2e88c6 100644 --- a/server/src/__tests__/issue-assignee-invokability-routes.test.ts +++ b/server/src/__tests__/issue-assignee-invokability-routes.test.ts @@ -1,5 +1,6 @@ import express from "express"; import request from "supertest"; +import { getTableName } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; const AGENT_ACTOR_ID = "11111111-1111-4111-8111-111111111111"; @@ -156,15 +157,28 @@ function agentActor(): Actor { }; } -// Minimal chainable/thenable db stub: any query resolves to an empty row set. -// Run containment is mocked because this suite targets assignee invokability. function stubDb(): any { const query: any = {}; for (const method of ["select", "from", "where", "innerJoin", "leftJoin", "orderBy", "limit", "groupBy", "for"]) { query[method] = () => query; } query.then = (resolve: (rows: unknown[]) => unknown) => Promise.resolve(resolve([])); - return { select: () => query }; + return { select: () => ({ from: (table: Parameters[0]) => { + const name = getTableName(table); + if (name !== "issues" && name !== "agents") return query; + const rows = async () => name === "issues" + ? [await mockIssueService.getById()].filter(Boolean) + : Object.entries(agentStatusById).map(([id, status]) => ({ id, status, companyId: "company-1", permissions: null })); + const result = { + where: () => result, + orderBy: () => result, + limit: () => result, + for: () => result, + then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => + rows().then(resolve, reject), + }; + return result; + } }) }; } function createApp(actor: Actor) { @@ -255,7 +269,7 @@ describe("issue assignee invokability guard", () => { .patch(`/api/issues/${existing.id}`) .send({ assigneeAgentId: IDLE_AGENT_ID }); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalled(); }); @@ -269,7 +283,7 @@ describe("issue assignee invokability guard", () => { .patch(`/api/issues/${existing.id}`) .send({ assigneeAgentId: PAUSED_AGENT_ID }); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.update).toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index a874b1874ec..ed9451ad0c9 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -785,6 +785,7 @@ describe("issue attachment routes", () => { originalFilename: "clip.mp4", }, }), + null, ); }); @@ -878,6 +879,7 @@ describe("issue attachment routes", () => { originalFilename: "clip.webm", }, }), + null, ); }); }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 11c1d07efb0..95addbc4f5f 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; -import { getTableName } from "drizzle-orm"; +import { getTableName, type SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { HttpError, unprocessable } from "../errors.js"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js"; @@ -455,6 +456,20 @@ describe("issue comment reopen routes", () => { })); mockDbSelectFrom.mockImplementation((table) => { const name = getTableName(table as Parameters[0]); + if (name === "issues" || name === "agents") return { where: (condition: SQL) => { + const ids = [...new Set(new PgDialect().sqlToQuery(condition).params + .filter((value): value is string => typeof value === "string" && value !== "company-1"))]; + const rows = async () => name === "issues" + ? (await Promise.all(ids.map(id => mockIssueService.getById(id)))).filter(row => row && ids.includes(row.id)) + : ids.map(id => ({ id, companyId: "company-1", permissions: null })); + const query = { + orderBy: () => query, + for: () => query, + then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => + rows().then(resolve, reject), + }; + return query; + } }; if (name === "case_handoffs") return { where: vi.fn(() => ({ orderBy: vi.fn(() => ({ limit: vi.fn(async () => []) })), })) }; @@ -1231,6 +1246,7 @@ describe("issue comment reopen routes", () => { assigneeAgentId: otherAgentId, status: "todo", }), + mockTx, ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -2408,6 +2424,7 @@ describe("issue comment reopen routes", () => { actorAgentId: "22222222-2222-4222-8222-222222222222", actorUserId: null, }), + mockTx, ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index f131cb2c355..8fffd3761f1 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -1,5 +1,7 @@ import express from "express"; import request from "supertest"; +import { getTableName, type SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; @@ -51,7 +53,7 @@ const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ permissions: null, }]).then(onFulfilled, onRejected), }))); -const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere }))); +const mockDbSelectFrom = vi.hoisted(() => vi.fn((_table: unknown): { where: (...args: never[]) => unknown } => ({ where: mockDbSelectWhere }))); const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom }))); const mockDb = vi.hoisted(() => ({ select: mockDbSelect, @@ -221,7 +223,24 @@ describe("issue execution policy routes", () => { mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]); mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]); mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom })); - mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere })); + mockDbSelectFrom.mockImplementation((table) => { + const name = getTableName(table as Parameters[0]); + if (name !== "issues" && name !== "agents") return { where: mockDbSelectWhere }; + return { where: (condition: SQL) => { + const ids = [...new Set(new PgDialect().sqlToQuery(condition).params + .filter((value): value is string => typeof value === "string" && value !== "company-1"))]; + const rows = async () => name === "issues" + ? (await Promise.all(ids.map(id => mockIssueService.getById(id)))).filter(row => row && ids.includes(row.id)) + : ids.map(id => ({ id, companyId: "company-1", permissions: null })); + const query = { + orderBy: () => query, + for: () => query, + then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => + rows().then(resolve, reject), + }; + return query; + } }; + }); mockDbSelectWhere.mockImplementation(() => ({ for: () => ({ then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index fae6d1b2676..a12809a58bc 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -1,5 +1,6 @@ import express from "express"; import request from "supertest"; +import { getTableName } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockIssueService = vi.hoisted(() => ({ @@ -16,27 +17,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ - for: () => ({ - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => - Promise.resolve([{ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - agentId: "agent-1", - contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" }, - permissions: null, - }]).then(onFulfilled, onRejected), - }), - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => - Promise.resolve([{ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - agentId: "agent-1", - contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" }, - permissions: null, - }]).then(onFulfilled, onRejected), -}))); -const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere }))); +const mockDbSelectFrom = vi.hoisted(() => vi.fn()); const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom }))); const mockDb = vi.hoisted(() => ({ select: mockDbSelect, @@ -180,27 +161,25 @@ describe("issue telemetry routes", () => { ...patch, })); mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom })); - mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere })); - mockDbSelectWhere.mockImplementation(() => ({ - for: () => ({ - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => - Promise.resolve([{ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - agentId: "agent-1", - contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" }, - permissions: null, - }]).then(onFulfilled, onRejected), - }), - then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => - Promise.resolve([{ + mockDbSelectFrom.mockImplementation((table: Parameters[0]) => { + const rows = getTableName(table) === "issues" ? [makeIssue("todo")] + : getTableName(table) === "agents" ? [{ id: "agent-1", companyId: "company-1", permissions: null }] + : [{ id: "22222222-2222-4222-8222-222222222222", companyId: "company-1", agentId: "agent-1", contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" }, permissions: null, - }]).then(onFulfilled, onRejected), - })); + }]; + const query = { + where: () => query, + orderBy: () => query, + for: () => query, + then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject), + }; + return query; + }); // Keep cold route imports in setup rather than the HTTP assertion timeout. await loadAppModules(); }, 60_000); diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index bdb3a0f15ce..a3309bed6a5 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -2313,6 +2313,19 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { return { promise, release }; } + it.each(["legacy", "source agent", "different agent"] as const)("retires only proven fallback reviews attributed to %s", async attribution => { + const created = await seedReview(); + const creator = attribution === "legacy" ? null : attribution === "source agent" ? agentId : outsideAgentId; + await db.update(issueThreadInteractions).set({ createdByAgentId: creator }) + .where(eq(issueThreadInteractions.id, created.interaction.id)); + await start(); await flush(); + expect(await card(created.interaction.id)).toMatchObject({ + createdByAgentId: creator, + status: attribution === "different agent" ? "pending" : "cancelled", + }); + await quiet(); + }); + it("leaves genuine pending reviews and completed history quiet", async () => { const genuine = await seedReview({ genuine: true }); const settled = await seedReview(); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 3bb221462f6..22830944788 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -24,6 +24,7 @@ const apiPrefixes: Record = { "agents.ts": "/api", "agent-project-access.ts": "/api", "agent-permission-grants.ts": "/api", + "agent-interaction-permissions.ts": "/api", "agent-commentary.ts": "/api", "agent-chat-messages.ts": "/api", "agent-avatars.ts": "/api", diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts index 5b588aa8d21..5fe11276e1f 100644 --- a/server/src/__tests__/plugin-access-authorization-host-services.test.ts +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -1,7 +1,9 @@ +import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js"; import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, + agentWakeupRequests, agents, companies, companyMemberships, @@ -64,6 +66,21 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => await tempDb?.cleanup(); }); + it("keeps agent tool invocation origin when a plugin tries to invoke a protected peer", async () => { + const company = await createCompany(db, "PIO"); + const [sender, recipient] = await db.insert(agents).values([ + { companyId: company.id, name: "Sender", adapterType: "process" }, + { companyId: company.id, name: "Protected", adapterType: "process", permissions: { interactions: { canReceiveAssignmentsFromAgents: false } } }, + ]).returning(); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + try { + await expect(withPluginInteractionOrigin({ companyId: company.id, agentId: sender!.id, runId: randomUUID() }, () => + services.agents.invoke({ companyId: company.id, agentId: recipient!.id, prompt: "Start work" }))) + .rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents", actorAgentId: sender!.id } }); + expect(await db.select().from(agentWakeupRequests)).toEqual([]); + } finally { services.dispose(); } + }); + it("rejects grant writes for principals outside the requested company", async () => { const targetCompany = await createCompany(db, "PAX"); const otherCompany = await createCompany(db, "PAY"); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index fbc6bda25ec..f635ca129fd 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -1,3 +1,4 @@ +import { currentPluginInteractionOrigin } from "../services/plugin-interaction-context.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; @@ -380,13 +381,40 @@ describe("plugin-worker-manager stderr failure context", () => { }); expect(companiesGet).toHaveBeenCalledWith( { companyId: "company-a" }, - { invocationScope: { companyId: "company-a" }, traceparent: undefined, providerSpanBudget: { remaining: 256 } }, + { invocationScope: { companyId: "company-a", agentOrigin: { agentId: "agent-1", runId: "run-1" } }, traceparent: undefined, providerSpanBudget: { remaining: 256 } }, ); } finally { await handle.stop().catch(() => undefined); } }); + it("binds nested host operations to the initiating agent and refuses proactive fallback during its invocation", async () => { + const companiesGet = vi.fn(async () => { + await Promise.resolve(); + return currentPluginInteractionOrigin(); + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {}, + instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1, + proactiveCompanyScopes: ["company-a"], hostHandlers: { "companies.get": companiesGet as never }, + }); + try { + await handle.start(); + const input = { toolName: "probe", parameters: { mode: "echo", requestedCompanyId: "company-a" }, + runContext: { companyId: "company-a", agentId: "agent-a", runId: "run-a" } }; + await expect(handle.call("executeTool", input as HostToWorkerMethods["executeTool"][0])).resolves.toEqual({ + companyId: "company-a", agentId: "agent-a", runId: "run-a", + }); + expect(currentPluginInteractionOrigin()).toBeUndefined(); + const calls = companiesGet.mock.calls.length; + await expect(handle.call("executeTool", { ...input, parameters: { ...input.parameters, mode: "missing" } } as HostToWorkerMethods["executeTool"][0])) + .rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED }); + await expect(handle.call("executeTool", { ...input, parameters: { ...input.parameters, mode: "unknown" } } as HostToWorkerMethods["executeTool"][0])) + .rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED }); + expect(companiesGet).toHaveBeenCalledTimes(calls); + } finally { await handle.stop().catch(() => undefined); } + }); + it("passes echoed invocation scope to worker-to-host handlers", async () => { const companiesGet = vi.fn(async () => ({ id: "company-1" }) as Company); const handle = createPluginWorkerHandle("test.plugin", { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 8df2e140123..d70f3802a6c 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -239,6 +239,18 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups }; } + it("checks the author of an agent-edited routine revision when dispatching later work", async () => { + const f = await seedFixture(); + const editorId = randomUUID(); + await db.insert(agents).values({ id: editorId, companyId: f.companyId, name: "Editor", status: "active", adapterType: "process" }); + await f.svc.update(f.routine.id, { description: "Agent-authored instructions" }, { agentId: editorId }); + await db.update(agents).set({ permissions: { interactions: { canAssignTasksToAgents: false } } }).where(eq(agents.id, editorId)); + await expect(f.svc.runRoutine(f.routine.id, { source: "api" }, {})).rejects.toMatchObject({ status: 403, + details: expect.objectContaining({ permission: "canAssignTasksToAgents", actorAgentId: editorId }) }); + expect(await db.select().from(routineRuns).where(eq(routineRuns.routineId, f.routine.id))).toEqual([]); + expect(f.wakeups).toEqual([]); + }); + it("preserves creation ancestry through regular webhooks, child tasks, and standalone follow-ups", async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID().repeat(2)); const f = await seedFixture(); diff --git a/server/src/app.ts b/server/src/app.ts index df846ded332..ac2e0e23234 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -106,6 +106,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import { agentInteractionPermissionRoutes } from "./routes/agent-interaction-permissions.js"; import { agentPermissionGrantRoutes } from "./routes/agent-permission-grants.js"; import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js"; import type { SetupTokenSessionService } from "./services/setup-token-session.js"; @@ -797,6 +798,7 @@ export async function createApp( }), ); api.use(agentPermissionGrantRoutes(db)); + api.use(agentInteractionPermissionRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(agentProfileAvatarRoutes(db, opts.storageService)); api.use(projectToolRoutes(db)); diff --git a/server/src/lib/agent-interaction-permissions.ts b/server/src/lib/agent-interaction-permissions.ts new file mode 100644 index 00000000000..cbb34db39de --- /dev/null +++ b/server/src/lib/agent-interaction-permissions.ts @@ -0,0 +1,24 @@ +import { isDeepStrictEqual } from "node:util"; +import { forbidden } from "../errors.js"; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +const message = "Use PATCH /api/agents/:id/interaction-permissions to change interaction permissions"; + +export function preserveAgentInteractionPermissions(existing: unknown, incoming: unknown): Record { + const previous = record(existing), next = { ...record(incoming) }; + if (Object.hasOwn(next, "interactions") && !isDeepStrictEqual(next.interactions, previous.interactions)) { + throw forbidden(message, { code: "agent_interaction_permissions_separate_endpoint" }); + } + if (Object.hasOwn(previous, "interactions")) next.interactions = previous.interactions; + else delete next.interactions; + return next; +} + +export function assertNoInitialAgentInteractionPermissions(permissions: unknown) { + if (Object.hasOwn(record(permissions), "interactions")) { + throw forbidden(message, { code: "agent_interaction_permissions_separate_endpoint" }); + } +} diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index 9a075a860a9..b5e2b01b5d4 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -89,6 +89,9 @@ export type AgentConfigurationPatch = Partial; export interface CreateAgentOptions { + createdByAgentId?: string | null; + createdByAgentCompanyId?: string; + interactionTexts?: readonly string[]; initialBudgetPolicyUserId?: string; setupOnly?: boolean; setupAgentId?: string; diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts index 0af6a7ab986..de618971dea 100644 --- a/server/src/modules/agent-lifecycle/adapters/configuration.ts +++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts @@ -12,6 +12,7 @@ import type { LifecycleEffects } from "./effects.js"; export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) { if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction"); assertConfigurationFields(data); + options = await effects.lockConfigurationInteractions(tx, id, data, options); const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update"); if (!current) return null; if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) { diff --git a/server/src/modules/agent-lifecycle/adapters/effects.ts b/server/src/modules/agent-lifecycle/adapters/effects.ts index 1ccec84c599..486e17ea5f4 100644 --- a/server/src/modules/agent-lifecycle/adapters/effects.ts +++ b/server/src/modules/agent-lifecycle/adapters/effects.ts @@ -16,6 +16,7 @@ export interface LifecycleEffects { beforeConnectionEligibilityRestore?(db: Db, companyId: string, agentId: string): Promise; reportFailure(context: LifecycleFailure, error: unknown): void; deleteDependencies(db: Db, companyId: string, agentId: string): Promise; + lockConfigurationInteractions(db: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise; prepareConfiguration(db: Db, current: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise; completeConfiguration(db: Db, before: AgentConfigurationRecord, after: AgentConfigurationRecord, patch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]): Promise; transaction(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise): Promise; diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index 1cce960c54c..082716afd83 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -1,3 +1,4 @@ +import { preserveAgentInteractionPermissions } from "../../../lib/agent-interaction-permissions.js"; import type { LifecycleEffects } from "./effects.js"; import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; @@ -43,6 +44,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { const createdStatus=status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" : "paused"; const values: typeof agents.$inferInsert = { ...prepared, + ...(setup ? { permissions: preserveAgentInteractionPermissions(setup.permissions, prepared.permissions) } : {}), createdByUserId: setup?.createdByUserId ?? options?.createdByUserId ?? null, lifecycleState, lifecycleVersion: setup ? setup.lifecycleVersion + 1 : 1, diff --git a/server/src/modules/agent-lifecycle/index.ts b/server/src/modules/agent-lifecycle/index.ts index 017a40d68cd..a958e8e65b9 100644 --- a/server/src/modules/agent-lifecycle/index.ts +++ b/server/src/modules/agent-lifecycle/index.ts @@ -1,3 +1,5 @@ +import type { ActivityPublication } from "../../types/activity-publication.js"; +import { AgentLifecycleConflict } from "./domain/policy.js"; import { hireApprovalService as approvalRecords, type HireDecisionTarget } from "./adapters/approvals.js"; import { agents, companies } from "@paperclipai/db"; import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"; @@ -43,6 +45,24 @@ export function processAgentLifecycle(db: Db, id: string): Promise { return workers.get(db)?.process(id) ?? Promise.resolve(); } +export function initializeImportedAgentConfigurationInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[], + id: string, data: Pick) { + if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported configuration requires a database transaction"); + if (Object.keys(data).some(key => key !== "adapterConfig" && key !== "reportsTo")) throw new AgentLifecycleConflict("Only initial imported instructions and reporting links can be initialized"); + return updateAgentConfiguration(tx, effects, id, data, undefined, publications); +} + +export async function requestAgentHireInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[], + ...args: Parameters["create"]>) { + if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported hires require a database transaction"); + const records = agentRecords(tx, { ...effects, transaction: async (database, companyId, work) => { + const [company] = await database.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update"); + if (!company) throw new AgentLifecycleConflict("Import company is unavailable"); + return work(database, publications); + } }); + return records.create(...args); +} + export function createAgentLifecycle(db: Db, effects: LifecycleEffects) { assertRootDatabase(db); const records = agentRecords(db, effects); diff --git a/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts b/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts index 82741d5d548..c0ead3a6072 100644 --- a/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts @@ -11,6 +11,7 @@ import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/exter import { createQueuedCommentIssueLockWriter } from "./queued-comment-postgres.js"; import type { QueuedCommentQueuePostgresAdapterDeps } from "./queued-comment-postgres.js"; import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js"; +import { buildAgentMentionHref } from "@paperclipai/shared"; // Proves the same two properties the release-half adapter test proves for // this module's other transaction: the one company the caller names in @@ -138,6 +139,30 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { return id; } + it("checks the authenticated editor and changed mentions atomically before updating a queued comment", async () => { + const companyId = await seedCompany(); + const recipient = await seedAgent({ companyId }), sender = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: recipient }); + const commentId = await seedComment({ companyId, issueId, authorUserId: "user-1" }); + const wakeId = await seedDeferredWake({ companyId, agentId: recipient, issueId, commentIds: [commentId] }); + const issueLock = createQueuedCommentIssueLockWriter(db, noopDeps); + const input = { issue: { id: issueId, companyId, assigneeAgentId: recipient, executionRunId: null }, + actor: { actorType: "agent" as const, actorId: sender, agentId: sender, runId: null, agentApiKeyId: null }, queueId: wakeId }; + await db.update(agents).set({ permissions: { interactions: { canMentionAgents: false } } }).where(eq(agents.id, sender)); + await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.updateCommentBody({ issueId, commentId, + body: `[@Recipient](${buildAgentMentionHref(recipient)})`, updatedAt: new Date() }))).rejects.toMatchObject({ status: 403, + details: expect.objectContaining({ permission: "canMentionAgents", actorAgentId: sender }) }); + await db.update(agents).set({ permissions: { interactions: { canReceiveCommentsFromAgents: false } } }).where(eq(agents.id, recipient)); + await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.deleteComment({ issueId, commentId }))) + .rejects.toMatchObject({ status: 403, details: expect.objectContaining({ permission: "canReceiveCommentsFromAgents" }) }); + expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("queued message"); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0]?.payload) + .toMatchObject({ _paperclipWakeContext: { wakeCommentIds: [commentId] } }); + await issueLock.withLockedQueue({ ...input, actor: { ...input.actor, actorType: "user", actorId: "user-1", agentId: null } }, + async (_locked, tx) => tx.updateCommentBody({ issueId, commentId, body: "Human edit", updatedAt: new Date() })); + expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("Human edit"); + }); + it("scopes the wake lookup to its own company: a foreign-company issue context resolves not_pending and deletes nothing", async () => { const companyId = await seedCompany(); const otherCompanyId = await seedCompany(); diff --git a/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts b/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts index 8b4d6ebbd0c..de0e5674605 100644 --- a/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts +++ b/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts @@ -1,4 +1,5 @@ import { notifyStrandedAgentStatus } from "../../../services/live-run-recovery-notifications.js"; +import { assertAgentInteraction } from "../../../services/agent-interaction-policy.js"; import { notifyExecutionRunWork } from "../../../services/execution-control-notifications.js"; import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js"; import { notifyExternalAgentSettlement } from "../../external-agents/index.js"; @@ -26,6 +27,7 @@ import { parseObject, readNonEmptyString } from "../domain/values.js"; import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js"; import type { LockedQueuedCommentState, + QueuedCommentActor, QueuedCommentActivityLogInput, QueuedCommentActivityPublication, QueuedCommentIssueLockWriter, @@ -56,13 +58,18 @@ export type QueuedCommentQueuePostgresAdapterDeps = { syncCommentExternalObjectsSafely(commentId: string, tx: Db): Promise; }; -function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string): QueuedCommentQueueTransaction { +function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string, actor: QueuedCommentActor): QueuedCommentQueueTransaction { // Messages the active run took into its first turn leave the visible queue // but stay on the wake until that turn is accepted, so a run that fails // before then still leaves them queued. Queue edits keep them. let heldByFirstTurn: string[] = []; return { async updateCommentBody({ issueId, commentId, body, updatedAt }) { + const [existing] = await tx.select({ body: issueComments.body }).from(issueComments).where(and( + eq(issueComments.id, commentId), eq(issueComments.issueId, issueId), eq(issueComments.companyId, companyId))); + if (!existing) return false; + await assertAgentInteraction(tx, { companyId, actorAgentId: actor.actorType === "agent" ? actor.actorId : null, + operation: "comment", issueId, texts: body === existing.body ? [] : [body], lock: true }); await notifyStatusCardIssueChange(tx, companyId); const updated = await tx .update(issueComments) @@ -334,7 +341,7 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu .then((rows) => rows[0] ?? null) : null; - const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId); + const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId, input.actor); const wake = toWakeRow(wakeRow); const queueRun = queueRunRow ? toRunRow(queueRunRow) : null; const activeRun = activeRunRow ? toRunRow(activeRunRow) : null; @@ -349,6 +356,8 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu }); const locked: LockedQueuedCommentState = { wake, state, queueRun, activeRun, queue }; + await assertAgentInteraction(tx, { companyId, actorAgentId: input.actor.actorType === "agent" ? input.actor.actorId : null, + operation: "comment", issueId: input.issue.id, lock: true }); return fn(locked, transaction); }); }, diff --git a/server/src/routes/agent-interaction-permissions.ts b/server/src/routes/agent-interaction-permissions.ts new file mode 100644 index 00000000000..d52781eda0f --- /dev/null +++ b/server/src/routes/agent-interaction-permissions.ts @@ -0,0 +1,32 @@ +import { Router, type Request } from "express"; +import { eq } from "drizzle-orm"; +import { agents, type Db } from "@paperclipai/db"; +import { isUuidLike, updateAgentInteractionPermissionsSchema } from "@paperclipai/shared"; +import { badRequest, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { readAgentInteractionPermissions, updateAgentInteractionPermissions } from "../services/agent-interaction-permissions.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; + +export function agentInteractionPermissionRoutes(db: Db) { + const router = Router(); + async function target(req: Request) { + const id = req.params.id as string; + if (!isUuidLike(id)) throw badRequest("Agent ID must be a UUID"); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, id)); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + return agent; + } + router.get("/agents/:id/interaction-permissions", async (req, res) => { + assertAuthenticated(req); + const agent = await target(req); + res.json(await readAgentInteractionPermissions(db, req.actor, agent.companyId, agent.id)); + }); + router.patch("/agents/:id/interaction-permissions", validate(updateAgentInteractionPermissionsSchema), async (req, res) => { + assertAuthenticated(req); + assertBoard(req); + const agent = await target(req); + res.json(await updateAgentInteractionPermissions(db, req.actor, agent.companyId, agent.id, req.body)); + }); + return router; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b019d01c10b..f2094fed275 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -4679,6 +4679,8 @@ export function agentRoutes( { ...(setupAgentId ? { setupAgentId } : {}), createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + createdByAgentId: req.actor.type === "agent" ? req.actor.agentId : null, + interactionTexts: [JSON.stringify(req.body)], ...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}), runnerResolved: true, responsibleUserId: responsibleUserForAiRequest(req), @@ -4962,6 +4964,8 @@ export function agentRoutes( { ...(setupId ? { setupOnly: true } : {}), createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + createdByAgentId: req.actor.type === "agent" ? req.actor.agentId : null, + interactionTexts: [JSON.stringify(req.body)], ...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}), runnerResolved: true, responsibleUserId: responsibleUserForAiRequest(req), @@ -5066,7 +5070,7 @@ export function agentRoutes( at: new Date().toISOString(), } } : req.body; - const agent = await svc.updatePermissions(id, permissionsPatch); + const agent = await svc.updatePermissions(id, permissionsPatch, { actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null }); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; @@ -5517,7 +5521,12 @@ export function agentRoutes( res.json(completed); }); - router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { + router.patch("/agents/:id", (req, _res, next) => { + if (req.body?.permissions && typeof req.body.permissions === "object" && hasOwn(req.body.permissions, "interactions")) { + throw unprocessable("Use /api/agents/:id/interaction-permissions to change interaction permissions"); + } + next(); + }, validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 8f80323ec3b..007a3e804d5 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1171,6 +1171,8 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan const actor = getActorInfo(req); const result = await portability.importBundle(body, req.actor.type === "board" ? req.actor.userId : null, { mode: "agent_safe", + actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null, + actorAgentCompanyId: req.actor.type === "agent" ? req.actor.companyId : null, sourceCompanyId: companyId, pauseAutomations: body.pauseAutomations === true, // An agent importing new agents is hiring, so it needs agents:create diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8928c30958c..c4fc7b77e18 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,3 +1,5 @@ +import { assertAgentInteraction } from "../services/agent-interaction-policy.js"; +import { assertQueuedCommentInteractionDelivery, recheckQueuedCommentInteractionDelivery } from "../services/agent-interaction-delivery.js"; import { listStalledDependencies } from "../services/recovery/blocker-stall-wakeups.js"; import { confirmMcpAttachmentCleanup, recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "../services/public-mcp/upload-cleanup.js"; import { notifyDeferredExecutionWork } from "../services/deferred-execution-notifications.js"; @@ -5269,7 +5271,8 @@ export function issueRoutes( function issueWriteDenialCodeForDecision( decision: Awaited>, ): IssueWriteDenialCode { - if (decision.code) + if (decision.interactionDenial) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + if (decision.code === "RESPONSIBLE_USER_UNAUTHORIZED" || decision.code === "RESPONSIBLE_USER_UNAVAILABLE") return issueWriteDenialCodeForResponsibleUserDenial(decision.code); if ( decision.reason === "deny_low_trust_boundary" || @@ -5680,6 +5683,9 @@ export function issueRoutes( ) { if (!(await assertIssueReadAllowed(req, res, issue))) return false; if (req.actor.type !== "agent") return true; + await assertAgentInteraction(db, { companyId: issue.companyId, actorAgentId: req.actor.agentId, + operation: "comment", issueId: issue.id, + texts: [typeof req.body?.body === "string" ? req.body.body : "", typeof req.body?.comment === "string" ? req.body.comment : ""] }); if (req.actor.runId) await assertNoCaseHandoffMutation(db, req.actor.runId, issue.id); const actorAgentId = req.actor.agentId; if (!actorAgentId) { @@ -12207,11 +12213,13 @@ export function issueRoutes( ? await workProductsSvc.update( existingRunAttachmentProduct.id, createInput, + actor.agentId, ) : await workProductsSvc.createForIssue( issue.id, issue.companyId, createInput, + actor.agentId, ); if (!product) { res.status(422).json({ error: "Invalid work product payload" }); @@ -12605,7 +12613,7 @@ export function issueRoutes( const product = await workProductsSvc.update(id, { ...patch, ...(sourceTrust ? { sourceTrust } : {}), - }); + }, actor.agentId); if (!product) { res.status(404).json({ error: "Work product not found" }); return; @@ -12666,7 +12674,7 @@ export function issueRoutes( if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; - const removed = await workProductsSvc.remove(id); + const removed = await workProductsSvc.remove(id, req.actor.type === "agent" ? req.actor.agentId : null); if (!removed) { res.status(404).json({ error: "Work product not found" }); return; @@ -14751,7 +14759,9 @@ export function issueRoutes( ); return; } - const issueMutationAccess = await assertAgentIssueMutationAllowed( + const commentOnlyUpdate = Boolean(req.body.comment) && Object.keys(req.body).every(key => + ["comment", "commentClientRequestId", "attachmentIds", "onBehalfOfUserId"].includes(key)); + const issueMutationAccess = commentOnlyUpdate ? await assertAgentIssueCommentAllowed(req, res, existing) : await assertAgentIssueMutationAllowed( req, res, existing, @@ -14763,7 +14773,7 @@ export function issueRoutes( req.actor.type === "agent" ? issueWriteAuthorizationReason( req, - await decideIssueAccess(req, existing, "issue:mutate"), + await decideIssueAccess(req, existing, commentOnlyUpdate ? "issue:comment" : "issue:mutate"), ) : issueWriteAuthorizationReason(req, true); @@ -14797,6 +14807,17 @@ export function issueRoutes( onBehalfOfUserId: _requestedOnBehalfOfUserId, ...updateFields } = req.body; + if (commentBody && req.actor.type === "agent") { + await assertAgentInteraction(db, { companyId: existing.companyId, actorAgentId: req.actor.agentId, + operation: "comment", issueId: existing.id, texts: [commentBody] }); + } + await assertAgentInteraction(db, { companyId: existing.companyId, + actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null, operation: "mention", issueId: existing.id, + texts: [ + ...(updateFields.title !== undefined && updateFields.title !== existing.title ? [updateFields.title] : []), + ...(updateFields.description !== undefined && updateFields.description !== existing.description ? [updateFields.description ?? ""] : []), + ], + }); // Board users move tasks to any depth. An agent move into a child is // placed beside it, and an agent move of a task with children is skipped, // both with a notice instead of an error. @@ -15802,11 +15823,13 @@ export function issueRoutes( const commentWithAdapterOverrides = Boolean( commentBody && updateFields.assigneeAdapterOverrides !== undefined, ); - const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides + const agentCommentUpdate = Boolean(commentBody && actor.actorType === "agent"); + const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides || agentCommentUpdate ? await sourceTrustForActorWrite(existing, actor) : undefined; const shouldUseTransactionalIssueUpdate = Boolean(commentAttachmentIds?.length) || + agentCommentUpdate || commentWithAdapterOverrides || Boolean(decision) || shouldRelayStop || @@ -15815,6 +15838,10 @@ export function issueRoutes( try { if (shouldUseTransactionalIssueUpdate) { issue = await db.transaction(async (tx) => { + if (agentCommentUpdate) await assertAgentInteraction(tx, { + companyId: existing.companyId, actorAgentId: actor.agentId, + operation: "comment", issueId: id, texts: [commentBody], lock: true, + }); if ( reviewPolicySensitiveMutationRequested && !(await assertLockedReviewPolicyAllowsMutation(tx)) @@ -15822,7 +15849,7 @@ export function issueRoutes( return null; const updated = await updateIssue(tx); if (!updated) return null; - if (commentAttachmentIds?.length || commentWithAdapterOverrides) { + if (commentAttachmentIds?.length || commentWithAdapterOverrides || agentCommentUpdate) { // Adapter settings, reassignment, comment and upload binding commit together. // A failed comment or invalid receipt rolls back the issue update. transactionalComment = await svc.addComment( @@ -17120,7 +17147,7 @@ export function issueRoutes( if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; const attachments = await svc.listAttachments(id); - const issue = await svc.remove(id); + const issue = await svc.remove(id, req.actor.type === "agent" ? req.actor.agentId : null); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; @@ -17728,6 +17755,10 @@ export function issueRoutes( eq(agentWakeupRequests.id, req.body.queueId), eq(agentWakeupRequests.companyId, issue.companyId), )).then(rows => rows[0]); const response = responseWake ? await readQueuedInteractionResponse(db, issue.companyId, issue.id, responseWake.payload) : null; + if (responseWake) await recheckQueuedCommentInteractionDelivery(db, { + companyId: issue.companyId, issueId: issue.id, agentId: responseWake.agentId, + commentId, queueId: responseWake.id, + }); const steeringIdentity = await reserveSteeredIdentity(db, { companyId: issue.companyId, runId: req.body.targetRunId, @@ -17872,6 +17903,10 @@ export function issueRoutes( code: "queued_response_requires_fresh_session", }); } + await assertQueuedCommentInteractionDelivery(tx, { + companyId: issue.companyId, issueId: issue.id, agentId: locked.activeRun.agentId, + commentId, lock: true, + }); steeringDeliveryAttempted = true; const acknowledgement = (await storedSteeringAcknowledgement(tx, steeringIdentity ?? { @@ -21095,7 +21130,7 @@ export function issueRoutes( ); } - const removed = await svc.removeAttachment(attachmentId, { mcpCleanup }); + const removed = await svc.removeAttachment(attachmentId, { mcpCleanup, actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null }); if (!removed) { res.status(404).json({ error: "Attachment not found" }); return; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8b66811fe9d..0ede32643d6 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -54,6 +54,8 @@ import { updatePrimaryAgentSchema, updateAgentPermissionsSchema, updateAgentPermissionGrantSchema, + updateAgentInteractionPermissionsSchema, + agentInteractionPermissionsSchema, addAgentProjectAccessSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, @@ -3850,6 +3852,26 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); +const agentInteractionPermissionsResponse = z.object({ + agentId: z.string().uuid(), companyId: z.string().uuid(), overrides: agentInteractionPermissionsSchema, + effective: agentInteractionPermissionsSchema.required(), +}); +registry.registerPath({ + method: "get", path: "/api/agents/{id}/interaction-permissions", tags: ["agents"], + summary: "Read individual agent interaction restrictions", + description: "Requires agent configuration read access. Returns sparse saved overrides and effective restriction values; true does not grant authority denied by other policies.", + request: { params: z.object({ id: z.string().uuid() }) }, + responses: { 200: r.ok(agentInteractionPermissionsResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); +registry.registerPath({ + method: "patch", path: "/api/agents/{id}/interaction-permissions", tags: ["agents"], + summary: "Set individual agent interaction restrictions", + description: "Human permission managers only: requires board authentication and users:manage_permissions. A partial map sets true/false; null removes the override. Missing values preserve existing behavior. Does not change grants or start work. Every change is audited atomically.", + request: { params: z.object({ id: z.string().uuid() }), body: jsonBody(updateAgentInteractionPermissionsSchema) }, + responses: { 200: r.ok(agentInteractionPermissionsResponse), 400: r.badRequest, 401: r.unauthorized, + 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() }); registry.registerPath({ method: "get", diff --git a/server/src/services/agent-configuration.ts b/server/src/services/agent-configuration.ts index 0a2a0185bd4..947c8ab0c08 100644 --- a/server/src/services/agent-configuration.ts +++ b/server/src/services/agent-configuration.ts @@ -1,3 +1,7 @@ +import { isDeepStrictEqual } from "node:util"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; +import { preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js"; import { notifyMuseContactVerification } from "../modules/external-agents/index.js"; import { assertEphemeralAgentInstructions } from "./agent-instructions.js"; import { notifyStrandedAgentStatus } from "./live-run-recovery-notifications.js"; @@ -8,8 +12,8 @@ import { notifySkillVersionPinChange } from "./skill-version-retention-notificat import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js"; import { and, eq } from "drizzle-orm"; -import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db"; -import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { agents, assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db"; +import { normalizeAgentUrlKey, extractAgentMentionIds } from "@paperclipai/shared"; import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot, @@ -21,7 +25,24 @@ import { agentCredentialService } from "./agent-credentials.js"; import { clearPrimaryAgent } from "./primary-agent.js"; import { budgetServiceInTransaction } from "./budgets.js"; +export async function lockAgentConfigurationInteractions(tx: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions) { + const pluginOrigin = currentPluginInteractionOrigin(); + if (pluginOrigin) options = { ...options, recordRevision: { ...options?.recordRevision, + createdByAgentId: pluginOrigin.agentId, createdByUserId: null, source: options?.recordRevision?.source ?? "plugin_agent" } }; + const interactionActorId = pluginOrigin?.agentId ?? options?.recordRevision?.createdByAgentId; + if (interactionActorId) { + const [target] = await tx.select({ companyId: agents.companyId }).from(agents).where(eq(agents.id, id)); + if (!target) return options; + await assertAgentInteraction(tx, { companyId: target.companyId, actorAgentId: interactionActorId, + operation: "configure", targetAgentId: id, lockAgentIds: configurationText(data).flatMap(extractAgentMentionIds), lock: "update" }); + } + return options; +} + export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise { + await assertAgentInteraction(db, { companyId: existing.companyId, actorAgentId: options?.recordRevision?.createdByAgentId, + operation: "mention", texts: changedConfigurationText(data, existing) }); + if (data.permissions !== undefined) data = { ...data, permissions: preserveAgentInteractionPermissions(existing.permissions, data.permissions) }; if (options?.recordRevision?.createdByAgentId && data.budgetMonthlyCents !== undefined && data.budgetMonthlyCents !== existing.budgetMonthlyCents) { throw forbidden("Only board operators can change agent budgets"); } @@ -184,3 +205,20 @@ export async function completeAgentConfiguration(txDb: Db, existing: AgentConfig } } } + +function configurationText(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(configurationText); + if (value && typeof value === "object") return Object.values(value).flatMap(configurationText); + return []; +} + +function changedConfigurationText(next: unknown, previous: unknown): string[] { + if (isDeepStrictEqual(next, previous)) return []; + if (typeof next === "string") return [next]; + if (next && typeof next === "object") { + const prior = previous && typeof previous === "object" ? previous as Record : {}; + return Object.entries(next).flatMap(([key, value]) => changedConfigurationText(value, prior[key])); + } + return []; +} diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts index e6589547760..4581a423acb 100644 --- a/server/src/services/agent-file-store.ts +++ b/server/src/services/agent-file-store.ts @@ -1,3 +1,6 @@ +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { extractAgentMentionIds } from "@paperclipai/shared"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import { cachedAgentFileManifest, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js"; @@ -189,8 +192,10 @@ export async function isAgentFileSaveRecorded(tx: Tx, agent: Agent, relative: st export function agentFileStore(db: Db) { db = fileStorageDb(db); async function locked(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean, - fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise) { + fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise, texts: string[] = []) { return db.transaction(async tx => { + if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null, + operation: "configure", targetAgentId: agentId, lockAgentIds: texts.flatMap(extractAgentMentionIds), lock: "update" }); const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); if (!agent) throw notFound("Agent not found"); const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent); @@ -202,9 +207,10 @@ export function agentFileStore(db: Db) { }); } async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record) { - await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: actor.type === "board" ? "user" : "agent", - actorId: (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: actor.type === "agent" ? actor.agentId : null, - runId: actor.runId, responsibleUserId: actor.type === "board" ? actor.userId : actor.onBehalfOfUserId, + const origin = currentPluginInteractionOrigin(); + await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: origin ? "agent" : actor.type === "board" ? "user" : "agent", + actorId: origin?.agentId ?? (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: origin?.agentId ?? (actor.type === "agent" ? actor.agentId : null), + runId: origin ? origin.runId : actor.runId, responsibleUserId: origin ? null : actor.type === "board" ? actor.userId : actor.onBehalfOfUserId, action: "agent.files_updated", entityType: "agent", entityId: agent.id, details }); } return { @@ -236,6 +242,10 @@ export function agentFileStore(db: Db) { if (!await isAgentFileSaveRecorded(tx, agent, relative, incomingHash)) await audit(tx, agent, bound, { path: relative, contentHash: incomingHash }); return { contentHash: currentHash, changed: false }; } + if (input.bytes && input.bytes.length <= MAX_INSTRUCTION_BYTES) { + await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null, + operation: "mention", texts: [input.bytes.toString("utf8")] }); + } if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash }); if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted"); if (input.bytes !== null) { @@ -251,10 +261,24 @@ export function agentFileStore(db: Db) { else await materializeInstructionBytes(root, relative, input.bytes); await audit(tx, agent, bound, { path: relative, contentHash: incomingHash, created: previous === null && input.bytes !== null }); return { contentHash: incomingHash, changed: true }; - }), - apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) => - locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => { - const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir); + }, input.bytes && input.bytes.length <= MAX_INSTRUCTION_BYTES ? [input.bytes.toString("utf8")] : []), + apply: async (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) => { + const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir); + const [target] = await db.select().from(agents).where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId))); + if (!target) throw notFound("Agent not found"); + const requestedEntry = deriveBundleState(target).entryFile; + const changedMentions = new Map(); + if (actor.type === "agent" || currentPluginInteractionOrigin()) { + for (const [name, entry] of incoming.entries) { + if (entry.kind !== "file" || JSON.stringify(entry) === JSON.stringify(input.baseline.entries.get(name))) continue; + if ((await fs.stat(await assertInstructionPathSafe(input.sourceDir, name))).size > MAX_INSTRUCTION_BYTES) continue; + const bytes = (await inspectAgentFile(input.sourceDir, name))?.bytes; + const mentions = bytes ? extractAgentMentionIds(bytes.toString("utf8")) : []; + if (mentions.length) changedMentions.set(name, { hash: fileHash(bytes!), text: mentions.map(id => `[@agent](agent://${id})`).join(" ") }); + } + } + return locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => { + if (deriveBundleState(agent).entryFile !== requestedEntry) throw conflict("Agent instructions entry changed; retry the file synchronization"); const entryPath = deriveBundleState(agent).entryFile; if (incoming.entries.get(entryPath)?.kind !== "file") throw unprocessable("The configured instruction entry cannot be deleted"); if (!input.checkpoint || JSON.stringify(incoming.entries.get(entryPath)) !== JSON.stringify(input.baseline.entries.get(entryPath))) { @@ -266,6 +290,12 @@ export function agentFileStore(db: Db) { // over quota. Enforce limits on the incoming and resulting tree so a // run can delete files to recover instead of being locked out forever. const current = input.checkpoint ? checkpointSnapshot(await cachedAgentFileManifest(root)) : await snapshotAgentFiles(root, false); + const texts = [...changedMentions].flatMap(([name, value]) => { + const prior = current.entries.get(name); + return prior?.kind === "file" && prior.hash === value.hash ? [] : [value.text]; + }); + await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null, + operation: "mention", texts }); // Rebase only the run's changed paths onto the current tree. This makes // same-file edits/deletions last-sync-wins while untouched files retain // changes from other runs. Ancestors may need recreating after a writer @@ -313,8 +343,9 @@ export function agentFileStore(db: Db) { assertDirectorySize(total, finalEntries.size); await mergeDirectoryWithBaseline({ ...input, baseline: applyBaseline, targetDir: root, ...(input.checkpoint ? { snapshots: { source: incoming, current } } : {}) }); - await audit(tx, agent, bound, { sourceRunId: actor.runId, contract: AGENT_FILES_CONTRACT }); + await audit(tx, agent, bound, { sourceRunId: currentPluginInteractionOrigin()?.runId ?? actor.runId, contract: AGENT_FILES_CONTRACT }); return { storageWarning: storageWarning(total, finalEntries.size, fullFile) }; - }), + }, [...changedMentions.values()].map(value => value.text)); + }, }; } diff --git a/server/src/services/agent-hiring.ts b/server/src/services/agent-hiring.ts index af0cd70a1f9..0150e9df462 100644 --- a/server/src/services/agent-hiring.ts +++ b/server/src/services/agent-hiring.ts @@ -1,3 +1,5 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; +import { assertNoInitialAgentInteractionPermissions, preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js"; import { assertEphemeralAgentInstructions } from "./agent-instructions.js"; import { initializeDurableAgentFiles } from "./agent-file-durability.js"; import { notifyAttentionRetentionWork } from "./retention-work-notifications.js"; @@ -21,6 +23,9 @@ import { instanceSettingsService } from "./instance-settings.js"; import { assertProviderApiPlacementPolicy, isProviderApiSetupDraft, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js"; export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise { + assertNoInitialAgentInteractionPermissions(input.permissions); + await assertAgentInteraction(db, { companyId, actorAgentId: options?.createdByAgentId, actorCompanyId: options?.createdByAgentCompanyId, + operation: "mention", texts: [JSON.stringify(input), ...(options?.interactionTexts ?? [])], lock: "update" }); await notifyAttentionRetentionWork(db, { companyId }); const { runner, ...data } = input; const selection = options?.runnerResolved @@ -135,7 +140,7 @@ export async function prepareAgentHireApproval(txDb: Db, existing: AgentHireReco if (patch.permissions !== undefined) { // The pending-approval activation replays the original hire // request, so the new-agent creation default applies. - patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" }); + patch.permissions = normalizeAgentPermissions(preserveAgentInteractionPermissions(existing.permissions, patch.permissions), { context: "create" }); } if (hasApprovedAdapterConfig) { await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, { diff --git a/server/src/services/agent-instruction-revisions.ts b/server/src/services/agent-instruction-revisions.ts index a9d5707a8f4..babbbc11e4d 100644 --- a/server/src/services/agent-instruction-revisions.ts +++ b/server/src/services/agent-instruction-revisions.ts @@ -1,3 +1,6 @@ +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { extractAgentMentionIds } from "@paperclipai/shared"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js"; import { createHash } from "node:crypto"; import { adoptAgentFiles, agentFileToken, isAgentFileSaveRecorded } from "./agent-file-store.js"; @@ -164,13 +167,14 @@ export function agentInstructionRevisionService(db: Db) { return currentRow(target, state.entryFile, bytes, actor); } function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision { + const origin = currentPluginInteractionOrigin(); return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"), contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length, parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api", - actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, - actorUserId: actor?.type === "board" ? actor.userId ?? null : null, - responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null, - sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) }; + actorAgentId: origin?.agentId ?? (actor?.type === "agent" ? actor.agentId ?? null : null), + actorUserId: origin ? null : actor?.type === "board" ? actor.userId ?? null : null, + responsibleUserId: origin ? null : actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null, + sourceRunId: origin ? origin.runId ?? null : actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) }; } async function getRevision( tx: Tx, @@ -224,6 +228,11 @@ export function agentInstructionRevisionService(db: Db) { instructionPath(input.entryFile); const bytes = "content" in input ? instructionBytes(input.content) : null; const result = await db.transaction(async (tx) => { + const restoredInput = "restoreRevisionId" in input + ? await getRevision(tx, input, input.entryFile, input.restoreRevisionId) : null; + const submittedContent = (bytes ?? Buffer.from(restoredInput!.contentBase64, "base64")).toString("utf8"); + await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null, + operation: "configure", targetAgentId: input.agentId, lockAgentIds: extractAgentMentionIds(submittedContent), lock: "update" }); const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor); const bound = actor.type === "plugin" ? await authorizePluginReset(tx, actor, input, state.agent) @@ -250,10 +259,15 @@ export function agentInstructionRevisionService(db: Db) { ) : null; const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64"); + if (current?.contentBase64 !== candidate.toString("base64")) { + await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null, + operation: "mention", texts: [submittedContent] }); + } + const origin = currentPluginInteractionOrigin(); const recordSave = (row: Revision) => tx.insert(activityLog).values({ companyId: input.companyId, - actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent", - actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!, - agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId, + actorType: origin ? "agent" : bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent", + actorId: origin?.agentId ?? (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!, + agentId: origin?.agentId ?? (bound.type === "agent" ? bound.agentId : null), runId: row.sourceRunId, responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId, details: { path: input.entryFile, contentHash: row.contentHash, created: current === null, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } }); const bindEntry = async () => { diff --git a/server/src/services/agent-interaction-delivery.test.ts b/server/src/services/agent-interaction-delivery.test.ts new file mode 100644 index 00000000000..f291bfcb21a --- /dev/null +++ b/server/src/services/agent-interaction-delivery.test.ts @@ -0,0 +1,365 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { activityLog, agents, agentWakeupRequests, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { filterRunMessageInteractionDelivery, hasAutomaticInteractionDeliveryHold, preparedRunInteractionDeliveryDenied, recheckQueuedCommentInteractionDelivery, + recheckQueuedRunInteractions, withInteractionDeliveryOrigin } from "./agent-interaction-delivery.js"; +import { createHeartbeatQueue } from "./heartbeat/queue.js"; +import { createHeartbeatQueueTestDependencies } from "../__tests__/helpers/heartbeat-queue.js"; +import { withPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { recoveryService } from "./recovery/service.js"; +import { createAdmissionTransactionScope, createWakeAdmissionWriter } from "../modules/wake-queue/adapters/postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("pending agent interaction delivery", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("interaction-delivery-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const [company] = await db.insert(companies).values({ name: "Delivery", issuePrefix: `D${randomUUID().slice(0, 5)}` }).returning(); + const [sender, recipient] = await db.insert(agents).values([ + { companyId: company!.id, name: "Sender", adapterType: "process" }, + { companyId: company!.id, name: "Recipient", adapterType: "process" }, + ]).returning(); + const [issue] = await db.insert(issues).values({ companyId: company!.id, title: "Work", status: "todo", assigneeAgentId: recipient!.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company!.id, agentId: recipient!.id, status: "queued", + invocationSource: "assignment", contextSnapshot: { issueId: issue!.id }, nativeIssueId: issue!.id }).returning(); + return { companyId: company!.id, sender: sender!.id, recipient: recipient!.id, issueId: issue!.id, run: run! }; + } + type Fixture = Awaited>; + const restriction = (id: string, interactions: Record) => db.update(agents) + .set({ permissions: { interactions } }).where(eq(agents.id, id)); + const readRun = async (id: string) => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]!; + async function wake(f: Fixture, input: { commentIds?: string[]; actorType?: string | null; actorId?: string | null } = {}) { + const context = { issueId: f.issueId, ...(input.commentIds ? { wakeCommentIds: input.commentIds, wakeCommentId: input.commentIds.at(-1) } : {}) }; + const [receipt] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.recipient, + source: "assignment", reason: input.commentIds ? "issue_commented" : "issue_assigned", runId: f.run.id, + requestedByActorType: input.actorType === undefined ? "agent" : input.actorType, + requestedByActorId: input.actorId === undefined ? f.sender : input.actorId, + payload: { issueId: f.issueId, _paperclipWakeContext: context }, + }).returning(); + await db.update(heartbeatRuns).set({ wakeupRequestId: receipt!.id, contextSnapshot: context }).where(eq(heartbeatRuns.id, f.run.id)); + return receipt!; + } + async function comment(f: Fixture, kind: "agent" | "user") { + const [value] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, body: `${kind} input`, + authorType: kind, ...(kind === "agent" ? { authorAgentId: f.sender } : { authorUserId: "human" }), + }).returning(); + return value!.id; + } + + it("keeps old queues unchanged when settings are absent", async () => { + const f = await seed(); await wake(f, { actorType: null, actorId: null }); + const before = await readRun(f.run.id); + expect(await recheckQueuedRunInteractions(db, before)).toEqual({ run: before, denied: false, denials: {} }); + expect(await readRun(f.run.id)).toEqual(before); + }); + + it("preserves an unrestricted old queue's rendered continuation and timestamps", async () => { + const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] }); + const run = await readRun(f.run.id); + await db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, + executionContinuation: { messages: [{ id: message, body: "saved input" }] }, paperclipWake: { retained: true }, + } }).where(eq(heartbeatRuns.id, run.id)); + const before = await readRun(run.id); + const wakeBefore = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]; + expect((await recheckQueuedRunInteractions(db, before)).denied).toBe(false); + expect(await readRun(run.id)).toEqual(before); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]).toEqual(wakeBefore); + }); + + it.each(["incoming", "outgoing"])("refuses a previously accepted assignment after %s revocation", async direction => { + const f = await seed(); const receipt = await wake(f); + await restriction(direction === "incoming" ? f.recipient : f.sender, + direction === "incoming" ? { canReceiveAssignmentsFromAgents: false } : { canAssignTasksToAgents: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denied).toBe(true); + expect(result.denials.assignment).toMatchObject({ code: "agent_interaction_denied", direction }); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]!.status).toBe("skipped"); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.assigneeAgentId).toBe(f.recipient); + await restriction(f.sender, {}); await restriction(f.recipient, {}); + expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true); + }); + + it("checks each author's permissions inside a mixed human and agent batch", async () => { + const f = await seed(); const agentComment = await comment(f, "agent"), humanComment = await comment(f, "user"); + const receipt = await wake(f, { commentIds: [agentComment, humanComment], actorType: "user", actorId: "human" }); + await restriction(f.recipient, { canReceiveCommentsFromAgents: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denied).toBe(false); + expect(result.run.contextSnapshot?.wakeCommentIds).toEqual([humanComment]); + expect(result.denials[agentComment]).toMatchObject({ permission: "canReceiveCommentsFromAgents" }); + const savedWake = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]!; + expect(savedWake.status).toBe("queued"); + expect((savedWake.payload?._paperclipWakeContext as Record).wakeCommentIds).toEqual([humanComment]); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(2); + }); + + it.each(["queued", "prepared"] as const)("rechecks every assignment in a %s run with an allowed coalesced comment", async boundary => { + for (const assignmentFirst of [true, false]) for (const direction of ["outgoing", "incoming"]) { + const f = await seed(); + const messageId = await comment(f, "user"); + const primary = await wake(f, assignmentFirst ? {} : { commentIds: [messageId], actorType: "user", actorId: "human" }); + const primaryOrigin = { actorType: primary.requestedByActorType, actorId: primary.requestedByActorId }; + await db.update(agentWakeupRequests).set({ payload: { ...primary.payload, interactionOrigin: primaryOrigin } }) + .where(eq(agentWakeupRequests.id, primary.id)); + const reason = assignmentFirst ? "issue_commented" : "issue_assigned"; + const context = { issueId: f.issueId, wakeReason: reason, ...(assignmentFirst ? { wakeCommentId: messageId, wakeCommentIds: [messageId] } : {}) }; + const actorType = assignmentFirst ? "user" : "agent", actorId = assignmentFirst ? "human" : f.sender; + const payload = withInteractionDeliveryOrigin({ issueId: f.issueId, _paperclipWakeContext: context }, actorType, actorId); + await db.transaction(tx => createWakeAdmissionWriter().coalesceIntoActiveExecutionRun( + createAdmissionTransactionScope(f.companyId, tx as unknown as Db), { + companyId: f.companyId, agentId: f.recipient, activeExecutionRunId: f.run.id, activeExecutionRunStatus: "queued", + mergedContextSnapshot: { ...context, wakeCommentId: messageId, wakeCommentIds: [messageId] }, + source: "assignment", triggerDetail: "system", payload, requestedByActorType: actorType, + requestedByActorId: actorId, idempotencyKey: null, + }, + )); + const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, f.run.id)); + expect(receipts).toHaveLength(2); + expect(receipts.find(row => row.id === primary.id)?.payload?.interactionOrigin).toEqual(primaryOrigin); + const assignment = receipts.find(row => row.requestedByActorType === "agent")!; + expect(assignment.payload?.interactionOrigin).toEqual({ actorType: "agent", actorId: f.sender }); + if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false); + else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false); + const permission = direction === "outgoing" ? "canAssignTasksToAgents" : "canReceiveAssignmentsFromAgents"; + await restriction(direction === "outgoing" ? f.sender : f.recipient, { [permission]: false }); + if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true); + else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + const denied = await readRun(f.run.id); + expect(denied.resultJson?.interactionDeliveryDenied).toBe(true); + expect((denied.resultJson?.interactionPermissionDenials as Record).assignment) + .toMatchObject({ actorAgentId: f.sender, permission }); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, assignment.id)))[0]) + .toMatchObject({ status: "skipped", error: "agent_interaction_delivery_denied", requestedByActorId: f.sender }); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.assigneeAgentId).toBe(f.recipient); + expect(await db.select().from(issueComments).where(eq(issueComments.id, messageId))).toHaveLength(1); + const retained = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))) + .filter(row => row.status === "deferred_issue_execution"); + expect(retained).toHaveLength(1); + expect(retained[0]).toMatchObject({ requestedByActorType: "user", requestedByActorId: "human", runId: null }); + await restriction(f.sender, {}); await restriction(f.recipient, {}); + if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true); + else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + } + }); + + it("does not apply assignment restrictions to a comment-only pending run", async () => { + const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] }); + await restriction(f.sender, { canAssignTasksToAgents: false }); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false); + await db.update(heartbeatRuns).set({ wakeupRequestId: null, contextSnapshot: { + issueId: f.issueId, wakeReason: "issue_execution_same_name", wakeCommentId: message, wakeCommentIds: [message], + } }).where(eq(heartbeatRuns.id, f.run.id)); + await db.delete(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)); + expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false); + }); + + it.each(["queued", "prepared"] as const)("recovers an older %s assignment's audit origin when its wake is missing and comments were added", async boundary => { + const f = await seed(); const messageId = await comment(f, "user"); + await db.insert(activityLog).values({ companyId: f.companyId, entityType: "issue", entityId: f.issueId, + actorType: "agent", actorId: f.sender, agentId: f.sender, action: "issue.updated", + createdAt: new Date(f.run.createdAt.getTime() - 1), details: { assigneeAgentId: f.recipient } }); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, wakeReason: "issue_assigned", + wakeCommentId: messageId, wakeCommentIds: [messageId] } }).where(eq(heartbeatRuns.id, f.run.id)); + await restriction(f.sender, { canAssignTasksToAgents: false }); + if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true); + else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + expect(((await readRun(f.run.id)).resultJson?.interactionPermissionDenials as Record).assignment) + .toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" }); + await restriction(f.sender, {}); + if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true); + else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + }); + + it("makes refused steering permanent while leaving a running task running", async () => { + const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] }); + await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.run.id)); + await restriction(f.sender, { canCommentOnOtherTasks: false }); + const input = { companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, commentId: message, queueId: receipt.id }; + await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.recipient }, () => + recheckQueuedCommentInteractionDelivery(db, input))).rejects.toMatchObject({ status: 403 }); + expect((await readRun(f.run.id)).status).toBe("running"); + await restriction(f.sender, {}); + await expect(recheckQueuedCommentInteractionDelivery(db, input)).rejects.toMatchObject({ status: 403 }); + }); + + it("refuses unknown older assignment provenance only for an explicitly restricted recipient", async () => { + const f = await seed(); await wake(f, { actorType: null, actorId: null }); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denials.assignment).toMatchObject({ code: "agent_interaction_provenance_unavailable" }); + const human = await seed(); await wake(human, { actorType: "user", actorId: "human" }); + await restriction(human.recipient, { canReceiveAssignmentsFromAgents: false }); + expect((await recheckQueuedRunInteractions(db, await readRun(human.run.id))).denied).toBe(false); + }); + + it.each(["wake", "run"])("recovers old assignment provenance within the same millisecond as its %s receipt", async source => { + const f = await seed(); + await db.insert(activityLog).values({ companyId: f.companyId, entityType: "issue", entityId: f.issueId, + actorType: "agent", actorId: f.sender, agentId: f.sender, action: "issue.updated", + details: { assigneeAgentId: f.recipient }, createdAt: sql`'2026-11-09T12:00:00.000100Z'::timestamptz` }); + if (source === "wake") { + const receipt = await wake(f, { actorType: null, actorId: null }); + await db.update(agentWakeupRequests).set({ requestedAt: sql`'2026-11-09T12:00:00.000900Z'::timestamptz` }) + .where(eq(agentWakeupRequests.id, receipt.id)); + } else { + await db.update(heartbeatRuns).set({ createdAt: sql`'2026-11-09T12:00:00.000900Z'::timestamptz` }) + .where(eq(heartbeatRuns.id, f.run.id)); + } + await restriction(f.sender, { canAssignTasksToAgents: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denials.assignment).toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" }); + }); + + it.each(["canCommentOnOtherTasks", "canMentionAgents"])("checks %s for saved parent reports through a system dispatch envelope", async permission => { + const f = await seed(); const receipt = await wake(f, { actorType: "system", actorId: "native-status-committer" }); + const payload = withInteractionDeliveryOrigin(receipt.payload, "system", "native-status-wake-dispatch", { + actorType: "agent", actorId: f.sender, operation: "comment", + }); + payload.childIssueSummaries = [{ summary: `Report for [@recipient](agent://${f.recipient})` }]; + await db.update(agentWakeupRequests).set({ payload, reason: "issue_children_completed" }).where(eq(agentWakeupRequests.id, receipt.id)); + await restriction(f.sender, { [permission]: false }); + expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denials.assignment) + .toMatchObject({ actorAgentId: f.sender, operation: permission === "canMentionAgents" ? "mention" : "comment", permission }); + }); + + it("recovers an older automatic retry's initiating agent from its predecessor", async () => { + const f = await seed(); await wake(f); + await db.update(heartbeatRuns).set({ status: "failed", errorCode: "controller_lease_lost" }).where(eq(heartbeatRuns.id, f.run.id)); + const [retry] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.recipient, status: "queued", + retryOfRunId: f.run.id, invocationSource: "automation", contextSnapshot: { issueId: f.issueId }, nativeIssueId: f.issueId }).returning(); + await restriction(f.sender, { canAssignTasksToAgents: false }); + expect((await recheckQueuedRunInteractions(db, retry!)).denials.assignment) + .toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" }); + }); + + it("preserves a server-authorized import routine's source company at delivery", async () => { + const source = await seed(), target = await seed(); + const receipt = await wake(target, { actorType: "agent", actorId: source.sender }); + await db.update(agentWakeupRequests).set({ payload: withInteractionDeliveryOrigin(receipt.payload, + "agent", source.sender, undefined, source.companyId) }).where(eq(agentWakeupRequests.id, receipt.id)); + expect((await recheckQueuedRunInteractions(db, await readRun(target.run.id))).denied).toBe(false); + await restriction(source.sender, { canAssignTasksToAgents: false }); + expect((await recheckQueuedRunInteractions(db, await readRun(target.run.id))).denials.assignment) + .toMatchObject({ actorAgentId: source.sender, permission: "canAssignTasksToAgents" }); + }); + + it("uses an originating agent run even when a decision effect is presented as a human comment", async () => { + const f = await seed(); + const [origin] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.sender, status: "succeeded" }).returning(); + const [message] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, + authorUserId: "human", authorType: "user", createdByRunId: origin!.id, body: "Agent-originated effect" }).returning(); + await wake(f, { commentIds: [message!.id], actorType: "user", actorId: "human" }); + await restriction(f.sender, { canCommentOnOtherTasks: false }); + const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + expect(result.denied).toBe(true); + expect(result.denials[message!.id]).toMatchObject({ actorAgentId: f.sender, permission: "canCommentOnOtherTasks" }); + }); + + it("rechecks rendered pending input after preparation and persists unread restart denial", async () => { + const f = await seed(); const message = await comment(f, "agent"); await wake(f, { commentIds: [message] }); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false); + await restriction(f.recipient, { canReceiveCommentsFromAgents: false }); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + await restriction(f.recipient, {}); + expect(await filterRunMessageInteractionDelivery(db, { companyId: f.companyId, runId: f.run.id, issueId: f.issueId, commentIds: [message] })).toEqual(new Set()); + }); + + it("retains allowed human input once when a mixed rendered startup must be refused", async () => { + const f = await seed(); const agentComment = await comment(f, "agent"), humanComment = await comment(f, "user"); + await wake(f, { commentIds: [agentComment, humanComment] }); + await restriction(f.recipient, { canReceiveCommentsFromAgents: false }); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true); + const retained = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))) + .filter(row => row.status === "deferred_issue_execution"); + expect(retained).toHaveLength(1); + expect(retained[0]).toMatchObject({ requestedByActorType: "user", requestedByActorId: "human", runId: null }); + expect((retained[0]!.payload?._paperclipWakeContext as Record).wakeCommentIds).toEqual([humanComment]); + }); + + it("does not revoke recovery of an already accepted provider turn", async () => { + const f = await seed(); await wake(f); + await db.update(heartbeatRuns).set({ status: "running", startedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id)); + await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, agentId: f.recipient, runId: f.run.id, + seq: 1, eventType: "turn.accepted", stream: "system", level: "info" }); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false); + expect((await readRun(f.run.id)).status).toBe("running"); + }); + + it("cancels refused queued runs through the public queue claim without dispatch", async () => { + const f = await seed(); await wake(f); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + const callbacks = createHeartbeatQueueTestDependencies(db); + callbacks.setRunStatus.mockImplementation(async (id, status, values) => + (await db.update(heartbeatRuns).set({ ...values, status }).where(eq(heartbeatRuns.id, id)).returning())[0] ?? null); + callbacks.setWakeupStatus.mockImplementation(async (id, status, values) => { + if (id) await db.update(agentWakeupRequests).set({ ...values, status }).where(eq(agentWakeupRequests.id, id)); + }); + const queue = createHeartbeatQueue(db, callbacks); + expect(await queue.claimQueuedRun(await readRun(f.run.id))).toBeNull(); + expect(callbacks.setRunStatus).toHaveBeenCalledWith(f.run.id, "cancelled", expect.objectContaining({ errorCode: "agent_interaction_delivery_denied" })); + expect(callbacks.executeRun).not.toHaveBeenCalled(); + expect((await readRun(f.run.id)).status).toBe("cancelled"); + expect(callbacks.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), + expect.objectContaining({ suppressImmediateRecovery: true })); + }); + + it.each(["todo", "in_progress"])("leaves a refused %s assignment held across recovery and permission resets", async status => { + const f = await seed(); await wake(f); + await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false }); + await recheckQueuedRunInteractions(db, await readRun(f.run.id)); + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "agent_interaction_delivery_denied", finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, f.run.id)); + await db.update(issues).set({ status }).where(eq(issues.id, f.issueId)); + const [before] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + const enqueueWakeup = vi.fn(async () => { throw new Error("Policy denial must not be automatically retried"); }); + const recovery = recoveryService(db, { enqueueWakeup }); + await restriction(f.recipient, {}); + for (let attempt = 0; attempt < 2; attempt++) { + expect(await recovery.reconcileStrandedAssignedIssues({ companyId: f.companyId, issueId: f.issueId })) + .toMatchObject({ skipped: 1, dispatchRequeued: 0, continuationRequeued: 0, escalated: 0 }); + } + expect(enqueueWakeup).not.toHaveBeenCalled(); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(before); + const callbacks = createHeartbeatQueueTestDependencies(db); + callbacks.getAgent.mockImplementation(async id => (await db.select().from(agents).where(eq(agents.id, id)))[0] ?? null); + expect(await createHeartbeatQueue(db, callbacks).enqueueWakeup(f.recipient, { + source: "assignment", reason: "issue_assignment_recovery", requestedByActorType: "system", + requestedByActorId: "recovery", contextSnapshot: { issueId: f.issueId }, + })).toBeNull(); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1); + expect(await hasAutomaticInteractionDeliveryHold(db, { + companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, payload: {}, context: {}, actorType: "user", actorId: "human", + })).toBe(false); + const humanMessage = await comment(f, "user"); + expect(await hasAutomaticInteractionDeliveryHold(db, { + companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, payload: {}, context: { wakeCommentId: humanMessage }, actorType: "system", + })).toBe(false); + }); +}); + +describe("wake provenance", () => { + it("replaces any payload origin with the authenticated server actor", () => { + expect(withInteractionDeliveryOrigin({ interactionOrigin: { actorType: "user", actorId: "spoof" } }, "agent", "sender")) + .toEqual({ interactionOrigin: { actorType: "agent", actorId: "sender" } }); + expect(withInteractionDeliveryOrigin({ interactionOrigin: { actorType: "user" } })).toEqual({}); + }); + it("captures the host's invoking agent despite a plugin's user presentation", () => { + expect(withPluginInteractionOrigin({ companyId: "company", agentId: "invoker" }, () => + withInteractionDeliveryOrigin({}, "user", "human"))) + .toEqual({ interactionOrigin: { actorType: "agent", actorId: "invoker", actorCompanyId: "company" } }); + }); +}); diff --git a/server/src/services/agent-interaction-delivery.ts b/server/src/services/agent-interaction-delivery.ts new file mode 100644 index 00000000000..2572346d844 --- /dev/null +++ b/server/src/services/agent-interaction-delivery.ts @@ -0,0 +1,430 @@ +import { randomUUID } from "node:crypto"; +import { and, asc, desc, eq, lte, or, sql } from "drizzle-orm"; +import { activityLog, agents, agentWakeupRequests, caseHandoffs, heartbeatRunEvents, heartbeatRuns, issueComments, issueThreadInteractions, issues, statusDecisions, type Db } from "@paperclipai/db"; +import { forbidden } from "../errors.js"; +import { agentInteractionDenial, type AgentInteractionDenial, type AgentInteractionInput } from "./agent-interaction-policy.js"; +import { withQueuedCommentIdsInRunContext, withQueuedCommentIdsInWakePayload } from "./issue-queued-comment-queue.js"; +import { notifyDeferredExecutionWork } from "./deferred-execution-notifications.js"; +import { currentPluginInteractionOrigin, withPluginInteractionOrigin } from "./plugin-interaction-context.js"; + +export const INTERACTION_DELIVERY_DENIED = "agent_interaction_delivery_denied"; +const DENIALS_KEY = "interactionPermissionDenials"; +const ORIGIN_KEY = "interactionOrigin"; +type Executor = Db | Parameters[0]>[0]; +type Wake = typeof agentWakeupRequests.$inferSelect; +type Run = typeof heartbeatRuns.$inferSelect; +type DeliveryDenial = AgentInteractionDenial | { + code: "agent_interaction_provenance_unavailable"; + operation: "assign" | "comment"; + targetAgentId: string; + issueId: string | null; +}; + +const record = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const string = (value: unknown) => typeof value === "string" && value.length ? value : null; +const ids = (value: unknown): string[] => Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") : []; + +export function agentInteractionDeliveryDenial(db: Executor, input: AgentInteractionInput) { + return withPluginInteractionOrigin(undefined, () => agentInteractionDenial(db, input)); +} + +export function agentWakeInteractionTexts(value: unknown): string[] { + const payload = record(value); + return [payload.continuationSummary, payload.retrySummary, payload.unblockAction, + ...(Array.isArray(payload.childIssueSummaries) ? payload.childIssueSummaries.map(entry => record(entry).summary) : []), + ].filter((entry): entry is string => typeof entry === "string"); +} + +export function isInteractionDeliveryDeniedRun(run: { errorCode?: string | null } | null | undefined) { + return run?.errorCode === INTERACTION_DELIVERY_DENIED; +} + +export async function hasAutomaticInteractionDeliveryHold(db: Executor, input: { + companyId: string; issueId: string; agentId: string; payload: unknown; context: unknown; + actorType?: string; actorId?: string | null; +}) { + const origin = record(record(input.payload)[ORIGIN_KEY]); + const actorType = origin.actorType === "agent" ? "agent" : input.actorType; + if (actorType === "user" || actorType === "agent" || commentIds(input.context).length || commentIds(input.payload).length) return false; + const [latest] = await db.select({ errorCode: heartbeatRuns.errorCode }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId), + or(eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.issueId, input.issueId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${input.issueId}`), + )).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1); + return isInteractionDeliveryDeniedRun(latest); +} + +export function withInteractionDeliveryOrigin(payload: unknown, actorType?: string, actorId?: string | null, savedOrigin?: unknown, actorCompanyId?: string) { + const next = { ...record(payload) }; + delete next[ORIGIN_KEY]; + const saved = record(savedOrigin); + if (saved.actorType === "agent" && string(saved.actorId)) { + actorType = "agent"; actorId = string(saved.actorId); actorCompanyId = string(saved.actorCompanyId) ?? actorCompanyId; + } + const pluginOrigin = currentPluginInteractionOrigin(); + if (pluginOrigin && !string(saved.actorId)) { actorType = "agent"; actorId = pluginOrigin.agentId; actorCompanyId = pluginOrigin.companyId; } + if (actorType && actorId) next[ORIGIN_KEY] = { actorType, actorId, + ...(actorCompanyId ? { actorCompanyId } : {}), + ...(saved.operation === "comment" ? { operation: "comment" } : {}), + }; + return next; +} + +function commentIds(context: unknown): string[] { + const value = record(context); + return [...new Set([...ids(value.wakeCommentIds), string(value.wakeCommentId), string(value.commentId)] + .filter((id): id is string => Boolean(id)))]; +} + +function requiresWakeOriginCheck(wake: Wake): boolean { + const payload = record(wake.payload); + const context = record(payload._paperclipWakeContext); + const reason = string(context.wakeReason) ?? wake.reason; + const operation = record(payload[ORIGIN_KEY]).operation; + if (operation === "assign" || operation === "comment") return true; + if (["issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned"].includes(reason ?? "")) return false; + if (reason && reason !== "issue_execution_same_name") return true; + return commentIds(payload).length === 0 && commentIds(context).length === 0; +} + +async function recheckAssignmentWakes(db: Executor, run: Run, wakes: Wake[], issueId: string | null, hasComments: boolean) { + const reason = string(record(run.contextSnapshot).wakeReason); + const runHasIndependentOrigin = Boolean(run.retryOfRunId || reason && + !["issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned", "issue_execution_same_name"].includes(reason)); + const candidates: Array = wakes.length + ? wakes.filter(requiresWakeOriginCheck) : hasComments && !runHasIndependentOrigin ? [] : [undefined]; + let denial: unknown; + for (const wake of candidates) { + const current = record(record(wake ? wake.payload : run.resultJson)[DENIALS_KEY]).assignment + ?? await assignmentDenial(db, run, wake, issueId); + if (!current) continue; + denial ??= current; + if (wake) await db.update(agentWakeupRequests).set({ status: "skipped", finishedAt: new Date(), + error: INTERACTION_DELIVERY_DENIED, updatedAt: new Date(), + payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{interactionPermissionDenials}', + coalesce(${agentWakeupRequests.payload}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify({ assignment: current })}::jsonb)`, + }).where(eq(agentWakeupRequests.id, wake.id)); + } + return denial; +} + +async function unknownOriginDenial(db: Executor, companyId: string, targetAgentId: string, + operation: "assign" | "comment", issueId: string | null): Promise { + const [target] = await db.select({ permissions: agents.permissions }).from(agents) + .where(and(eq(agents.id, targetAgentId), eq(agents.companyId, companyId))); + const key = operation === "comment" ? "canReceiveCommentsFromAgents" : "canReceiveAssignmentsFromAgents"; + return record(record(target?.permissions).interactions)[key] === false + ? { code: "agent_interaction_provenance_unavailable", operation, targetAgentId, issueId } : null; +} + +export async function queuedCommentInteractionDenial(db: Executor, input: { + companyId: string; issueId: string; agentId: string; commentId: string; lock?: boolean; +}): Promise { + const [comment] = await db.select().from(issueComments).where(and( + eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), + eq(issueComments.id, input.commentId))); + let actorAgentId = comment?.authorAgentId ?? comment?.derivedAuthorAgentId; + if (!actorAgentId && comment?.createdByRunId) { + const [run] = await db.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, comment.createdByRunId), eq(heartbeatRuns.companyId, input.companyId))); + actorAgentId = run?.agentId; + } + if (actorAgentId) return agentInteractionDeliveryDenial(db, { + companyId: input.companyId, actorAgentId, operation: "comment", issueId: input.issueId, + targetAgentId: input.agentId, texts: [comment!.body], delivery: true, lock: input.lock, + }); + if (comment?.authorUserId || comment?.authorType === "system") return null; + const [interaction] = comment ? [] : await db.select().from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.issueId), + eq(issueThreadInteractions.id, input.commentId))); + if (interaction?.resolvedByAgentId) return agentInteractionDeliveryDenial(db, { + companyId: input.companyId, actorAgentId: interaction.resolvedByAgentId, operation: "comment", + issueId: input.issueId, targetAgentId: input.agentId, delivery: true, lock: input.lock, + }); + if (interaction?.resolvedByUserId) return null; + return unknownOriginDenial(db, input.companyId, input.agentId, "comment", input.issueId); +} + +export async function filterQueuedInteractionComments(db: Executor, input: { + companyId: string; issueId: string; agentId: string; commentIds: string[]; + wake?: Wake | null; lock?: boolean; +}): Promise<{ allowedIds: string[]; denials: Record }> { + const previous = record(record(input.wake?.payload)[DENIALS_KEY]); + const denials = { ...previous }; + const allowedIds: string[] = []; + for (const commentId of input.commentIds) { + const denial = previous[commentId] ?? await queuedCommentInteractionDenial(db, { ...input, commentId }); + if (denial) denials[commentId] = denial; + else allowedIds.push(commentId); + } + if (input.wake && allowedIds.length !== input.commentIds.length) { + const payload = { ...withQueuedCommentIdsInWakePayload(input.wake.payload, allowedIds), [DENIALS_KEY]: denials }; + await db.update(agentWakeupRequests).set({ payload, updatedAt: new Date(), + ...(allowedIds.length ? {} : { status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: new Date() }), + }).where(eq(agentWakeupRequests.id, input.wake.id)); + } + return { allowedIds, denials }; +} + +async function assignmentDenial(db: Executor, run: Run, wake: Wake | undefined, issueId: string | null, depth = 0): Promise { + const origin = record(record(wake?.payload)[ORIGIN_KEY]); + const reason = wake?.reason ?? string(record(run.contextSnapshot).wakeReason); + let operation: "assign" | "comment" = origin.operation === "comment" ? "comment" : "assign"; + const capturedAgent = origin.actorType === "agent" ? string(origin.actorId) : null; + let actorType = capturedAgent ? "agent" : wake?.requestedByActorType; + let actorId = capturedAgent ?? wake?.requestedByActorId; + if (wake && (actorType === "system" || !actorType)) { + const [handoff] = await db.select({ actorType: caseHandoffs.actorType, actorId: caseHandoffs.actorId }) + .from(caseHandoffs).where(and(eq(caseHandoffs.companyId, run.companyId), eq(caseHandoffs.wakeupRequestId, wake.id), + eq(caseHandoffs.toAgentId, run.agentId))); + if (handoff) { actorType = handoff.actorType; actorId = handoff.actorId; } + } + if (wake && (actorType === "system" || !actorType) && reason !== "issue_blockers_resolved") { + const payload = record(wake.payload), context = record(payload._paperclipWakeContext); + const decisionId = string(payload.nativeDecisionId) ?? string(payload.nativeReviewDecisionId) + ?? string(payload.nativeChildCompletionDecisionId) ?? string(context.nativeReviewDecisionId) + ?? string(context.nativeChildCompletionDecisionId); + if (decisionId && /^[0-9a-f-]{36}$/i.test(decisionId)) { + const [source] = await db.select({ agentId: heartbeatRuns.agentId }).from(statusDecisions) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, statusDecisions.runId), eq(heartbeatRuns.companyId, statusDecisions.companyId))) + .where(and(eq(statusDecisions.id, decisionId), eq(statusDecisions.companyId, run.companyId))); + if (source) { actorType = "agent"; actorId = source.agentId; } + if (reason === "issue_children_completed") operation = "comment"; + } + } + if (actorType === "user") return null; + if (actorType === "agent" && actorId) { + const payload = record(wake?.payload); + const texts = agentWakeInteractionTexts(payload); + const reviewId = string(payload.nativeReviewInteractionId); + if (reviewId && /^[0-9a-f-]{36}$/i.test(reviewId)) { + const [review] = await db.select({ summary: issueThreadInteractions.summary, payload: issueThreadInteractions.payload }) + .from(issueThreadInteractions).where(and(eq(issueThreadInteractions.companyId, run.companyId), eq(issueThreadInteractions.id, reviewId))); + for (const value of [review?.summary, record(review?.payload).prompt, record(review?.payload).detailsMarkdown]) { + if (typeof value === "string") texts.push(value); + } + } + return agentInteractionDeliveryDenial(db, { + companyId: run.companyId, actorAgentId: actorId, operation, targetAgentId: run.agentId, + actorCompanyId: string(origin.actorCompanyId) ?? undefined, + issueId, texts, delivery: true, lock: true, + }); + } + if (run.retryOfRunId && (actorType === "system" || !actorType)) { + if (depth >= 16) return unknownOriginDenial(db, run.companyId, run.agentId, operation, issueId); + const [source] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, run.retryOfRunId), + eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.agentId, run.agentId))); + if (source) { + const [sourceWake] = source.wakeupRequestId ? await db.select().from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.id, source.wakeupRequestId), eq(agentWakeupRequests.companyId, run.companyId))) : []; + const savedDenial = record(record(source.resultJson)[DENIALS_KEY]).assignment; + return savedDenial as DeliveryDenial | undefined ?? assignmentDenial(db, source, sourceWake, issueId, depth + 1); + } + } + if (actorType === "system" && wake?.requestedByActorId?.startsWith("native-status") && reason !== "issue_blockers_resolved") { + return unknownOriginDenial(db, run.companyId, run.agentId, reason === "issue_children_completed" ? "comment" : operation, issueId); + } + if (actorType === "system" && reason !== "issue_assigned" && reason !== "issue_created") return null; + if (wake?.source !== "assignment" && run.invocationSource !== "assignment") return null; + if (issueId) { + if (!actorType || actorType === "system") { + const requestedAt = wake + ? sql`(select ${agentWakeupRequests.requestedAt} from ${agentWakeupRequests} where ${agentWakeupRequests.id} = ${wake.id} and ${agentWakeupRequests.companyId} = ${run.companyId})` + : sql`(select ${heartbeatRuns.createdAt} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${run.id} and ${heartbeatRuns.companyId} = ${run.companyId})`; + const [assignment] = await db.select({ actorType: activityLog.actorType, actorId: activityLog.actorId }) + .from(activityLog).where(and(eq(activityLog.companyId, run.companyId), eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issueId), lte(activityLog.createdAt, requestedAt), + or(eq(activityLog.action, "issue.created"), eq(activityLog.action, "issue.updated")), + sql`${activityLog.details}->>'assigneeAgentId' = ${run.agentId}`)) + .orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(1); + if (assignment?.actorType === "agent") return agentInteractionDeliveryDenial(db, { + companyId: run.companyId, actorAgentId: assignment.actorId, operation: "assign", + targetAgentId: run.agentId, issueId, delivery: true, lock: true, + }); + if (assignment?.actorType === "user") return null; + } + const [issue] = await db.select({ createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId }) + .from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))); + if (reason === "issue_created" && issue?.createdByAgentId) return agentInteractionDeliveryDenial(db, { + companyId: run.companyId, actorAgentId: issue.createdByAgentId, operation: "assign", + targetAgentId: run.agentId, issueId, delivery: true, lock: true, + }); + if (reason === "issue_created" && issue?.createdByUserId) return null; + } + return unknownOriginDenial(db, run.companyId, run.agentId, "assign", issueId); +} + +export async function recheckQueuedRunInteractions(db: Db, run: Run): Promise<{ + run: Run; denied: boolean; denials: Record; +}> { + if (run.status !== "queued") return { run, denied: false, denials: {} }; + const issueId = run.nativeIssueId ?? run.issueId ?? string(record(run.contextSnapshot).issueId); + return db.transaction(async tx => { + if (issueId) await tx.select({ id: issues.id }).from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).for("update"); + const wakes = await tx.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId), + or(eq(agentWakeupRequests.runId, run.id), run.wakeupRequestId ? eq(agentWakeupRequests.id, run.wakeupRequestId) : undefined), + )).orderBy(asc(agentWakeupRequests.id)).for("update"); + const [current] = await tx.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId))).for("update"); + if (!current || current.status !== "queued") return { run: current ?? run, denied: false, denials: {} }; + if (record(current.resultJson).interactionDeliveryDenied === true) { + return { run: current, denied: true, denials: record(record(current.resultJson)[DENIALS_KEY]) }; + } + const allIds = [...new Set([...commentIds(current.contextSnapshot), ...wakes.flatMap(wake => + [...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])])]; + const denials: Record = {}; + const allowed = new Set(allIds); + if (issueId && allIds.length) { + for (const wake of wakes) { + const wakeIds = [...new Set([...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])]; + const checked = await filterQueuedInteractionComments(tx, { + companyId: run.companyId, issueId, agentId: run.agentId, commentIds: wakeIds, wake, lock: true, + }); + Object.assign(denials, checked.denials); + for (const id of wakeIds) if (!checked.allowedIds.includes(id)) allowed.delete(id); + } + const remaining = await filterQueuedInteractionComments(tx, { + companyId: run.companyId, issueId, agentId: run.agentId, commentIds: [...allowed], lock: true, + }); + Object.assign(denials, remaining.denials); + for (const id of [...allowed]) if (!remaining.allowedIds.includes(id)) allowed.delete(id); + } + const assignment = await recheckAssignmentWakes(tx, current, wakes, issueId, allIds.length > 0); + if (assignment) denials.assignment = assignment; + if (!Object.keys(denials).length) return { run: current, denied: false, denials }; + if (!denials.assignment && allowed.size === allIds.length) return { run: current, denied: false, denials }; + const denied = Boolean(denials.assignment) || (allIds.length > 0 && allowed.size === 0); + const primary = wakes.find(wake => wake.id === current.wakeupRequestId); + if (!denied && primary && allIds.length) await tx.update(agentWakeupRequests).set({ + status: "queued", finishedAt: null, error: null, + payload: { ...withQueuedCommentIdsInWakePayload(primary.payload, [...allowed]), [DENIALS_KEY]: denials }, + updatedAt: new Date(), + }).where(eq(agentWakeupRequests.id, primary.id)); + const contextSnapshot = allIds.length + ? withQueuedCommentIdsInRunContext(current.contextSnapshot, [...allowed]) : record(current.contextSnapshot); + if (allIds.length) delete contextSnapshot.executionContinuation; + const [updated] = await tx.update(heartbeatRuns).set({ contextSnapshot, + resultJson: { ...record(current.resultJson), [DENIALS_KEY]: denials, ...(denied ? { interactionDeliveryDenied: true } : {}) }, updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, current.id)).returning(); + if (assignment && issueId && allowed.size) await retainAllowedMessages(tx, current, issueId, [...allowed]); + return { run: updated ?? current, denied, denials }; + }); +} + +export async function assertQueuedCommentInteractionDelivery(db: Executor, input: { + companyId: string; issueId: string; agentId: string; commentId: string; lock?: boolean; +}) { + const denial = await queuedCommentInteractionDenial(db, input); + if (denial) throw forbidden("Queued agent input is no longer permitted.", { ...denial, deliveryCode: INTERACTION_DELIVERY_DENIED }); +} + +export async function recheckQueuedCommentInteractionDelivery(db: Db, input: { + companyId: string; issueId: string; agentId: string; commentId: string; queueId: string; +}) { + const denial = await db.transaction(async tx => { + await tx.select({ id: issues.id }).from(issues).where(and( + eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))).for("update"); + const [wake] = await tx.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.id, input.queueId), eq(agentWakeupRequests.companyId, input.companyId), + eq(agentWakeupRequests.agentId, input.agentId))).for("update"); + if (!wake || string(record(wake.payload).issueId) !== input.issueId) return null; + const previous = record(record(wake.payload)[DENIALS_KEY])[input.commentId]; + if (previous) return previous; + const pendingIds = [...new Set([...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])]; + if (!pendingIds.includes(input.commentId)) return null; + const checked = await filterQueuedInteractionComments(tx, { ...input, commentIds: pendingIds, wake, lock: true }); + return checked.denials[input.commentId] ?? null; + }); + if (denial) throw forbidden("Queued agent input is no longer permitted.", { + ...record(denial), deliveryCode: INTERACTION_DELIVERY_DENIED, + }); +} + +export async function filterRunMessageInteractionDelivery(db: Executor, input: { + companyId: string; runId: string; issueId: string; commentIds: string[]; +}): Promise> { + if (!input.commentIds.length) return new Set(); + const [run] = await db.select({ agentId: heartbeatRuns.agentId, resultJson: heartbeatRuns.resultJson }) + .from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))); + if (!run) return new Set(); + const previous = record(record(run.resultJson)[DENIALS_KEY]); + const allowed = new Set(); + const denied: Record = {}; + for (const commentId of input.commentIds) { + const denial = previous[commentId] ?? await queuedCommentInteractionDenial(db, { ...input, agentId: run.agentId, commentId }); + if (denial) denied[commentId] = denial; + else allowed.add(commentId); + } + if (Object.keys(denied).some(id => !previous[id])) await db.update(heartbeatRuns).set({ + resultJson: sql`jsonb_set(coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb), '{interactionPermissionDenials}', + coalesce(${heartbeatRuns.resultJson}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify(denied)}::jsonb)`, + updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, input.runId)); + return allowed; +} + +export async function preparedRunInteractionDeliveryDenied(db: Db, run: Run): Promise { + if (run.processPid) return false; + const [accepted] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and( + eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id), + or(eq(heartbeatRunEvents.eventType, "turn.accepted"), eq(heartbeatRunEvents.eventType, "turn.started")), + )).limit(1); + if (accepted) return false; + const issueId = run.nativeIssueId ?? run.issueId ?? string(record(run.contextSnapshot).issueId); + const inputIds = commentIds(run.contextSnapshot); + const allowed = issueId && inputIds.length ? await filterRunMessageInteractionDelivery(db, + { companyId: run.companyId, runId: run.id, issueId, commentIds: inputIds }) : new Set(); + const wakes = await db.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId), + or(eq(agentWakeupRequests.runId, run.id), run.wakeupRequestId ? eq(agentWakeupRequests.id, run.wakeupRequestId) : undefined), + )).orderBy(asc(agentWakeupRequests.id)); + const denial = await recheckAssignmentWakes(db, run, wakes, issueId, inputIds.length > 0); + if (!denial && allowed.size === inputIds.length) return false; + if (issueId && allowed.size) await db.transaction(tx => retainAllowedMessages(tx, run, issueId, [...allowed])); + if (denial) await db.update(heartbeatRuns).set({ + resultJson: sql`jsonb_set(coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb), '{interactionPermissionDenials}', + coalesce(${heartbeatRuns.resultJson}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify({ assignment: denial })}::jsonb) + || '{"interactionDeliveryDenied": true}'::jsonb`, updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, run.id)); + return true; +} + +async function retainAllowedMessages(tx: Executor, run: Run, issueId: string, allowedIds: string[]) { + await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).for("update"); + const [current] = await tx.select({ resultJson: heartbeatRuns.resultJson }).from(heartbeatRuns) + .where(eq(heartbeatRuns.id, run.id)).for("update"); + if (!current) return; + const retained = record(record(current.resultJson).interactionRetainedMessages); + for (const commentId of allowedIds) { + if (retained[commentId]) continue; + const [comment] = await tx.select().from(issueComments).where(and(eq(issueComments.id, commentId), + eq(issueComments.companyId, run.companyId), eq(issueComments.issueId, issueId))); + if (!comment) continue; + let actorAgentId = comment.authorAgentId ?? comment.derivedAuthorAgentId; + if (!actorAgentId && comment.createdByRunId) { + actorAgentId = await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, comment.createdByRunId), eq(heartbeatRuns.companyId, run.companyId))) + .then(rows => rows[0]?.agentId ?? null); + } + const actorType = actorAgentId ? "agent" : comment.authorUserId ? "user" : "system"; + const actorId = actorAgentId ?? comment.authorUserId; + const context = { issueId, taskId: issueId, wakeReason: "issue_commented", source: "interaction.permission_recheck", + wakeCommentId: commentId, wakeCommentIds: [commentId] }; + const payload = withPluginInteractionOrigin(undefined, () => + withInteractionDeliveryOrigin({ issueId, commentId, _paperclipWakeContext: context }, actorType, actorId)); + const wakeId = randomUUID(); + await notifyDeferredExecutionWork(tx, { companyId: run.companyId, requestId: wakeId }); + const [wake] = await tx.insert(agentWakeupRequests).values({ + id: wakeId, companyId: run.companyId, agentId: run.agentId, source: "assignment", triggerDetail: "system", + reason: "issue_commented", status: "deferred_issue_execution", requestedByActorType: actorType, + requestedByActorId: actorId, payload, idempotencyKey: `interaction-retained:${run.id}:${commentId}`, + }).returning({ id: agentWakeupRequests.id }); + if (wake) retained[commentId] = wake.id; + } + await tx.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) + || ${JSON.stringify({ interactionRetainedMessages: retained })}::jsonb`, updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id)); +} diff --git a/server/src/services/agent-interaction-permissions.ts b/server/src/services/agent-interaction-permissions.ts new file mode 100644 index 00000000000..1e733253fc6 --- /dev/null +++ b/server/src/services/agent-interaction-permissions.ts @@ -0,0 +1,62 @@ +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { and, eq } from "drizzle-orm"; +import { agents, type Db } from "@paperclipai/db"; +import { + agentInteractionPermissionsSchema, resolveAgentInteractionPermissions, updateAgentInteractionPermissionsSchema, + type AgentInteractionPermissionsPatch, type AgentInteractionPermissionsState, +} from "@paperclipai/shared"; +import { conflict, forbidden, notFound } from "../errors.js"; +import { accessService } from "./access.js"; +import type { AuthorizationActor } from "./authorization.js"; +import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; + +function state(agent: { id: string; companyId: string; permissions: Record }): AgentInteractionPermissionsState { + const parsed = agentInteractionPermissionsSchema.safeParse(agent.permissions.interactions ?? {}); + if (!parsed.success) throw conflict("Stored interaction permissions are invalid", { code: "invalid_agent_interaction_permissions" }); + return { agentId: agent.id, companyId: agent.companyId, overrides: parsed.data, + effective: resolveAgentInteractionPermissions(parsed.data) }; +} + +export async function readAgentInteractionPermissions(db: Db, actor: AuthorizationActor, companyId: string, agentId: string) { + const decision = await accessService(db).decide({ actor, action: "agent_config:read", + resource: { type: "agent", companyId, agentId }, scope: { agentId, targetAgentId: agentId } }); + if (!decision.allowed) throw forbidden(decision.explanation); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions }) + .from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))); + if (!agent) throw notFound("Agent not found"); + return state(agent); +} + +export async function updateAgentInteractionPermissions(db: Db, actor: AuthorizationActor, companyId: string, + agentId: string, input: AgentInteractionPermissionsPatch) { + if (actor.type !== "board" || currentPluginInteractionOrigin()) throw forbidden("Only human permission managers can change interaction permissions"); + const patch = updateAgentInteractionPermissionsSchema.parse(input); + const publications: ActivityPublication[] = []; + const result = await db.transaction(async tx => { + const transactionDb = tx as unknown as Db; + const decision = await accessService(transactionDb).decide({ actor, action: "users:manage_permissions", + resource: { type: "company", companyId } }); + if (!decision.allowed) throw forbidden(decision.explanation); + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).for("update"); + if (!agent) throw notFound("Agent not found"); + if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval"); + const before = state(agent).overrides; + const overrides = { ...before }; + for (const key of Object.keys(patch) as Array) { + const value = patch[key]; + if (value === null) delete overrides[key]; + else if (value !== undefined) overrides[key] = value; + } + const permissions = { ...agent.permissions }; + if (Object.keys(overrides).length) permissions.interactions = overrides; + else delete permissions.interactions; + await tx.update(agents).set({ permissions, updatedAt: new Date() }).where(eq(agents.id, agentId)); + await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board", + action: "agent.interaction_permissions_updated", entityType: "agent", entityId: agentId, + details: { before, after: overrides } }, publications); + return state({ ...agent, permissions }); + }); + for (const publication of publications) publishActivity(publication); + return result; +} diff --git a/server/src/services/agent-interaction-policy.ts b/server/src/services/agent-interaction-policy.ts new file mode 100644 index 00000000000..072443389c8 --- /dev/null +++ b/server/src/services/agent-interaction-policy.ts @@ -0,0 +1,138 @@ +import { agents, issues, type Db } from "@paperclipai/db"; +import { extractAgentMentionIds, isUuidLike, type AgentInteractionPermissionKey } from "@paperclipai/shared"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { forbidden, notFound } from "../errors.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; + +type DbTransaction = Parameters[0]>[0]; + +export type AgentInteractionOperation = "assign" | "comment" | "edit" | "configure" | "mention"; + +export interface AgentInteractionInput { + companyId: string; + actorAgentId?: string | null; + actorCompanyId?: string; + operation: AgentInteractionOperation; + issueId?: string | null; + targetAgentId?: string | null; + assigneeUserId?: string | null; + parentIssueId?: string | null; + texts?: readonly string[]; + lock?: boolean | "update"; + lockAgentIds?: readonly string[]; + delivery?: boolean; +} + +export interface AgentInteractionDenial { + code: "agent_interaction_denied"; + operation: AgentInteractionOperation; + permission: AgentInteractionPermissionKey; + direction: "incoming" | "outgoing"; + policyAgentId: string; + actorAgentId: string; + targetAgentId: string | null; + issueId: string | null; +} + +export async function agentInteractionDenial( + db: Db | DbTransaction, + input: AgentInteractionInput, +): Promise { + const origin = currentPluginInteractionOrigin(); + const actorCompanyId = input.actorCompanyId ?? input.companyId; + if (origin && origin.companyId !== actorCompanyId) throw notFound("Company resource not found"); + const actorAgentId = origin?.agentId ?? input.actorAgentId; + if (!actorAgentId) return null; + + const issueIds = [...new Set([input.issueId, input.parentIssueId].filter((id): id is string => Boolean(id)))].sort(); + const taskQuery = issueIds.length ? db.select({ + id: issues.id, assigneeAgentId: issues.assigneeAgentId, conversationAgentId: issues.conversationAgentId, + }).from(issues).where(and(eq(issues.companyId, input.companyId), inArray(issues.id, issueIds))).orderBy(issues.id) : null; + const tasks = taskQuery ? await (input.lock ? taskQuery.for("update") : taskQuery) : []; + if (tasks.length !== issueIds.length) throw notFound("Task not found"); + const issue = tasks.find(task => task.id === input.issueId); + const parent = tasks.find(task => task.id === input.parentIssueId); + const owner = (task: typeof issue) => task?.conversationAgentId ?? task?.assigneeAgentId ?? null; + const mentions = [...new Set((input.texts ?? []).flatMap(extractAgentMentionIds).filter(isUuidLike))]; + const agentIds = [...new Set([ + actorAgentId, input.targetAgentId, owner(issue), owner(parent), ...mentions, ...(input.lockAgentIds ?? []).filter(isUuidLike), + ].filter((id): id is string => Boolean(id)))].sort(); + const agentQuery = db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions }).from(agents) + .where(or(and(eq(agents.companyId, input.companyId), inArray(agents.id, agentIds)), + and(eq(agents.companyId, actorCompanyId), eq(agents.id, actorAgentId)))).orderBy(agents.id); + const rows = await (input.lock ? agentQuery.for(input.lock === "update" ? "no key update" : "share") : agentQuery); + const byId = new Map(rows.map(row => [row.id, row.permissions])); + if (!rows.some(row => row.id === actorAgentId && row.companyId === actorCompanyId) + || (input.targetAgentId && !rows.some(row => row.id === input.targetAgentId && row.companyId === input.companyId))) { + throw notFound("Agent not found"); + } + + function denied( + permission: AgentInteractionPermissionKey, + policyAgentId: string, + direction: AgentInteractionDenial["direction"], + targetAgentId: string | null, + operation = input.operation, + issueId = input.issueId ?? null, + ): AgentInteractionDenial | null { + const permissions = byId.get(policyAgentId); + const interactions = permissions && typeof permissions === "object" + ? (permissions as Record).interactions : null; + if (!interactions || typeof interactions !== "object" || + (interactions as Record)[permission] !== false) return null; + return { code: "agent_interaction_denied", operation, permission, direction, + policyAgentId, actorAgentId: actorAgentId!, targetAgentId, issueId }; + } + + function peer( + targetAgentId: string | null, + outgoing: AgentInteractionPermissionKey, + incoming: AgentInteractionPermissionKey, + operation = input.operation, + issueId = input.issueId ?? null, + ) { + if (targetAgentId === actorAgentId) return null; + return denied(outgoing, actorAgentId!, "outgoing", targetAgentId, operation, issueId) + ?? (targetAgentId ? denied(incoming, targetAgentId, "incoming", targetAgentId, operation, issueId) : null); + } + + let result: AgentInteractionDenial | null = null; + switch (input.operation) { + case "assign": + if (!input.delivery) { + if (issue) result = peer(owner(issue), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit"); + if (!result && parent) result = peer(owner(parent), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit", parent.id); + } + if (!result && input.targetAgentId) result = peer(input.targetAgentId, + "canAssignTasksToAgents", "canReceiveAssignmentsFromAgents"); + if (!result && input.assigneeUserId) result = denied("canAssignTasksToHumans", actorAgentId, "outgoing", null); + break; + case "comment": + result = peer(input.delivery && input.targetAgentId ? input.targetAgentId : owner(issue), + "canCommentOnOtherTasks", "canReceiveCommentsFromAgents"); + break; + case "edit": + result = peer(owner(issue), "canEditOtherTasks", "canReceiveTaskEditsFromAgents"); + if (!result && parent) result = peer(owner(parent), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit", parent.id); + break; + case "configure": + if (input.targetAgentId) result = peer(input.targetAgentId, + "canConfigureOtherAgents", "canReceiveConfigurationChangesFromAgents"); + break; + case "mention": + if (input.targetAgentId) result = peer(input.targetAgentId, "canMentionAgents", "canReceiveMentionsFromAgents"); + break; + } + if (result) return result; + for (const target of mentions) { + if (!byId.has(target)) continue; + result = peer(target, "canMentionAgents", "canReceiveMentionsFromAgents", "mention"); + if (result) return result; + } + return null; +} + +export async function assertAgentInteraction(db: Db | DbTransaction, input: AgentInteractionInput): Promise { + const denial = await agentInteractionDenial(db, input); + if (denial) throw forbidden(`Agent interaction is disabled by ${denial.permission}. A human permission manager can change this setting.`, denial); +} diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts index f24dc30810a..619491c22d1 100644 --- a/server/src/services/agent-lifecycle.ts +++ b/server/src/services/agent-lifecycle.ts @@ -19,7 +19,7 @@ import { budgetServiceInTransaction, policyBlocks, type BudgetServiceHooks } fro import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js"; import { clearPrimaryAgent } from "./primary-agent.js"; import { deleteAgentDependencies } from "./agent-deletion.js"; -import { prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js"; +import { lockAgentConfigurationInteractions, prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js"; import { prepareAgentHire, initializeAgentHire, prepareAgentHireApproval, completeAgentHireApproval } from "./agent-hiring.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { fenceActiveCompanyAgentStopsInTransaction as fenceActiveCompanyStops } from "../modules/agent-lifecycle/index.js"; @@ -86,6 +86,7 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li recordCreation: (db, companyId, agentId) => recordResourceCreationEvent(db, companyId, "agent", agentId), recordStatus: recordAgentStatusEvent, clearPrimary: clearPrimaryAgent, + lockConfigurationInteractions: lockAgentConfigurationInteractions, prepareConfiguration: prepareAgentConfiguration, completeConfiguration: completeAgentConfiguration, prepareHire: prepareAgentHire, @@ -99,6 +100,16 @@ export function createAgentLifecycle(db: Db, hooks: BudgetServiceHooks = {}) { return lifecycle.createAgentLifecycle(db, createAgentLifecycleEffects(hooks)); } +export function initializeImportedAgentConfigurationInTransaction(tx: Db, publications: ActivityPublication[], + id: string, data: Parameters[4]) { + return lifecycle.initializeImportedAgentConfigurationInTransaction(tx, createAgentLifecycleEffects(), publications, id, data); +} + +export function requestAgentHireInTransaction(tx: Db, publications: ActivityPublication[], + ...args: Parameters["requestHire"]>) { + return lifecycle.requestAgentHireInTransaction(tx, createAgentLifecycleEffects(), publications, ...args); +} + function trackedDriver(driver: lifecycle.LifecycleDriver): lifecycle.LifecycleDriver { return { ...driver, runHost: agent => trackIdleWork(driver.runHost(agent)), runPlugin: (agent, pluginId) => trackIdleWork(driver.runPlugin(agent, pluginId)) }; diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 8c660e74792..6f2a0d11009 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -1,3 +1,4 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { notifyAttentionRetentionWork } from "./retention-work-notifications.js"; import { type BudgetServiceHooks } from "./budgets.js"; @@ -8,6 +9,7 @@ import { isUuidLike, normalizeAgentApiKeyScope, normalizeAgentUrlKey, type Agent import { conflict, notFound, unprocessable } from "../errors.js"; +import { preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js"; import { normalizeAgentPermissions } from "../lib/agent-permissions.js"; import { @@ -42,7 +44,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { getById, - updatePermissions: async (id: string, permissions: Record & { canCreateAgents: boolean }) => { + updatePermissions: async (id: string, permissions: Record & { canCreateAgents: boolean }, options?: { actorAgentId?: string | null }) => { const existing = await getById(id); if (!existing) return null; if (existing.status === "pending_approval") { @@ -54,11 +56,17 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { } const updated = await db.transaction(async tx => { - await notifyAttentionRetentionWork(tx, { companyId: existing.companyId }); + await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId: options?.actorAgentId, + operation: "configure", targetAgentId: id, lock: "update" }); + const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("update"); + if (!current) return null; + if (current.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval"); + const protectedPermissions = preserveAgentInteractionPermissions(current.permissions, permissions); + await notifyAttentionRetentionWork(tx, { companyId: current.companyId }); return tx .update(agents) .set({ - permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, { role: existing.role }), + permissions: normalizeAgentPermissions({ ...current.permissions, ...protectedPermissions }, { role: current.role }), updatedAt: new Date(), }) .where(eq(agents.id, id)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index deee9cabd05..553a1cd52d0 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1,5 +1,7 @@ import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; +import { agentInteractionDenial, type AgentInteractionDenial, type AgentInteractionOperation } from "./agent-interaction-policy.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; import { withHumanDirectedWork } from "./human-directed-work.js"; import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js"; import { canonicalExecutionUuid } from "./execution-identity.js"; @@ -135,7 +137,8 @@ export type AuthorizationDecision = { action: AuthorizationAction; explanation: string; inboxPolicyMode?: InboxAgentPolicyMode | "grant_override"; - code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE"; + code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE" | "agent_interaction_denied"; + interactionDenial?: AgentInteractionDenial; reason: | "allow_low_trust_boundary" | "allow_local_board" @@ -164,6 +167,7 @@ export type AuthorizationDecision = { | "deny_missing_consent" | "deny_no_grant" | "deny_policy_restricted" + | "deny_agent_interaction" | "deny_low_trust_boundary" | "deny_issue_private" | "deny_project_private" @@ -762,6 +766,7 @@ function scopeBoolean(scope: Record | null | undefined, key: st export function authorizationDeniedDetails(decision: AuthorizationDecision) { return { + ...decision.interactionDenial, ...(decision.code ? { code: decision.code } : {}), reason: decision.reason, }; @@ -2861,10 +2866,33 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA action: AuthorizationAction; resource: AuthorizationResource; scope?: Record | null; - }): Promise { + }, options?: { interactionPolicy: "defer_to_mutation" }): Promise { const agentDecision = await measureWorkspaceAuthorization("authorization.base", () => decideBase(input)); const responsibleDecision = await measureWorkspaceAuthorization("authorization.responsible_user", () => applyResponsibleUserIntersection(input, agentDecision)); const intersectedDecision = await measureWorkspaceAuthorization("authorization.authorizing_user", () => applyAuthorizingUserIntersection(input, responsibleDecision)); + const interactionActorId = currentPluginInteractionOrigin()?.agentId + ?? (input.actor.type === "agent" ? input.actor.agentId : undefined); + if (intersectedDecision.allowed && interactionActorId && options?.interactionPolicy !== "defer_to_mutation") { + const operation: AgentInteractionOperation | null = input.action === "tasks:assign" && input.resource.type === "issue" ? "assign" + : input.action === "issue:comment" && input.resource.type === "issue" ? "comment" + : (input.action === "issue:mutate" || input.action === "project:write_workspace") && input.resource.type === "issue" ? "edit" + : input.action === "agent_config:update" || input.action === "agent_instructions:update" ? "configure" + : null; + if (operation) { + const resource = input.resource; + const denial = await agentInteractionDenial(db, { + companyId: resource.companyId, actorAgentId: interactionActorId, operation, + ...(resource.type === "issue" ? { + issueId: resource.issueId, parentIssueId: operation === "assign" && !resource.issueId ? resource.parentIssueId : undefined, + targetAgentId: operation === "assign" ? resource.assigneeAgentId : undefined, + assigneeUserId: operation === "assign" ? resource.assigneeUserId : undefined, + } : resource.type === "agent" ? { targetAgentId: resource.agentId } : {}), + }); + if (denial) return { allowed: false, action: input.action, reason: "deny_agent_interaction", + code: denial.code, interactionDenial: denial, + explanation: `Agent interaction is disabled by ${denial.permission}. A human permission manager can change this setting.` }; + } + } if ( input.action === "project:read" && input.resource.type === "project" diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index fac7ac6faf5..a832ac57eb0 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -810,7 +810,7 @@ function isBuiltInAgentMarkerConflict(error: unknown): boolean { export function builtInAgentService(db: Db) { const agentSvc = agentService(db); - const agentSvcLifecycle = createAgentLifecycle(db); + const agentSvcLifecycle = () => createAgentLifecycle(db); const accessSvc = accessService(db); const approvalSvc = approvalService(db); const instructionsSvc = agentInstructionsService(db); @@ -1412,7 +1412,7 @@ export function builtInAgentService(db: Db) { async function ensureBuiltInAgentAssignable(agent: Agent) { if (agent.status !== "paused" || ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return agent; - const resumed = await agentSvcLifecycle.resumeAgent(agent.id); + const resumed = await agentSvcLifecycle().resumeAgent(agent.id); if (!resumed) throw notFound("Built-in agent not found"); return resumed as Agent; } @@ -1672,7 +1672,7 @@ export function builtInAgentService(db: Db) { const [keep, ...duplicates] = markedRows; for (const duplicate of duplicates) { const openApproval = await approvalSvc.findOpenHireApprovalForAgent(companyId, duplicate.id); - await agentSvcLifecycle.terminateAgent(duplicate.id); + await agentSvcLifecycle().terminateAgent(duplicate.id); if (openApproval) { await approvalSvc.cancel( openApproval.id, @@ -1816,7 +1816,7 @@ export function builtInAgentService(db: Db) { : null; let created: Agent; try { - created = await agentSvcLifecycle.requestHire(companyId, { + created = await agentSvcLifecycle().requestHire(companyId, { ...definitionPatch(definition, resolvedInput), status: definition.defaultStatus ?? "idle", pauseReason: definition.defaultStatus === "paused" @@ -1928,7 +1928,7 @@ export function builtInAgentService(db: Db) { : null; let pending: Agent; try { - pending = await agentSvcLifecycle.requestHire(companyId, { + pending = await agentSvcLifecycle().requestHire(companyId, { ...definitionPatch(definition, input), status: "pending_approval", reportsTo, diff --git a/server/src/services/case-handoffs.ts b/server/src/services/case-handoffs.ts index a3690516ec0..4a356573009 100644 --- a/server/src/services/case-handoffs.ts +++ b/server/src/services/case-handoffs.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { and, desc, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, caseAccessSources, caseEvents, caseHandoffs, caseIssueLinks, cases, chatConversations, companies, heartbeatRuns, issues, projects } from "@paperclipai/db"; @@ -9,7 +10,7 @@ import { withExternalAdmissionGuard, type ExternalAdmissionTransaction } from ". import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js"; import { assertAssignableAgent } from "./agent-assignability.js"; import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; -import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { authorizationDeniedDetails, authorizationService, type AuthorizationActor } from "./authorization.js"; import { assertActorCanReadCase, assertCaseTrustedForExecution, canActorReceiveCase, retainCaseAccessSource } from "./case-access.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -90,7 +91,7 @@ async function authorizeTask(tx: Db, actor: AuthorizationActor, companyId: strin const assignment = await authorizationService(tx).decide({ actor, action: "tasks:assign", resource, scope: { issueId: issue?.id ?? null, projectId, parentIssueId, assigneeAgentId: toAgentId, assigneeUserId: null, } }); - if (!assignment.allowed) throw forbidden(assignment.explanation); + if (!assignment.allowed) throw forbidden(assignment.explanation, authorizationDeniedDetails(assignment)); if (options.validateRecipient !== false) { try { await assertAssignableAgent(tx, companyId, toAgentId, { kind: "work" }); @@ -113,7 +114,7 @@ async function authorizeTerminalTask(tx: Db, actor: AuthorizationActor, issue: I throw forbidden("Agents may complete only their own case work"); } const mutation = await access.decide({ actor, action: "issue:mutate", resource }); - if (!mutation.allowed) throw forbidden(mutation.explanation); + if (!mutation.allowed) throw forbidden(mutation.explanation, authorizationDeniedDetails(mutation)); } async function authorizeHandoffControl(tx: Db, actor: AuthorizationActor, row: CaseHandoffRow, issue: IssueRow, validateRecipient = true) { @@ -405,7 +406,7 @@ export function caseHandoffService(db: Db) { type: "issue", companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status, } }); - if (!mutation.allowed) throw forbidden(mutation.explanation); + if (!mutation.allowed) throw forbidden(mutation.explanation, authorizationDeniedDetails(mutation)); } } // Lock task before case, matching task mutation admission and dispatch. @@ -446,6 +447,9 @@ export function caseHandoffService(db: Db) { await authorizeTask(tx, actor, companyId, issue, issue?.projectId ?? projectId, input.toAgentId, { invalidRecipientAsUnprocessable: true, ...(issue ? {} : { parentIssueId: parent?.id ?? null }), }); + await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null, + operation: "assign", issueId: issue?.id, parentIssueId: parent?.id, targetAgentId: input.toAgentId, + texts: [JSON.stringify(input)], lock: true }); if (caseRow && (await tx.select({ id: caseHandoffs.id }).from(caseHandoffs).where(and(eq(caseHandoffs.caseId, caseRow.id), inArray(caseHandoffs.state, PENDING))).limit(1)).length) throw conflict("A handoff is already pending for this case"); if (issue && (await tx.select({ id: cases.id }).from(cases).where(and(eq(cases.workIssueId, issue.id), caseRow ? sql`${cases.id} <> ${caseRow.id}` : sql`true`)).limit(1)).length) throw conflict("Task is already the primary task of another case"); const id = randomUUID(); @@ -489,7 +493,7 @@ export function caseHandoffService(db: Db) { const saved = await documentService(tx).upsertIssueDocument({ issueId: issue.id, ...initial, format: "markdown", baseRevisionId: null, createdByAgentId: actor.type === "agent" ? actor.agentId : null, createdByUserId: actor.type === "board" ? actor.userId ?? null : null, createdByRunId: source?.id ?? null, - sourceTrust, lockedDocumentStrategy: "conflict" }); + sourceTrust, lockedDocumentStrategy: "conflict" }, { initialTaskCreation: !targetId }); publications.push((await persistActivity(tx, { companyId, ...who, agentId: actor.type === "agent" ? actor.agentId : null, runId: source?.id ?? null, issueId: issue.id, action: "issue.document_created", entityType: "issue", entityId: issue.id, details: { key: saved.document.key, documentId: saved.document.id, title: saved.document.title, diff --git a/server/src/services/change-consent-gate.ts b/server/src/services/change-consent-gate.ts index 4569a0f8b38..ff33de12791 100644 --- a/server/src/services/change-consent-gate.ts +++ b/server/src/services/change-consent-gate.ts @@ -19,6 +19,13 @@ export function agentProfileChangeTargetKey(agentId: string) { return `agent:${agentId}:profile`; } +/** Canonical and legacy consent targets that deliver a peer configuration proposal. */ +export function agentConfigurationChangeTargetId(key: string): string | null { + return /^agent:([^:]+):(?:instructions|profile)$/.exec(key)?.[1] + ?? /^reflection-coach:agent-(?:instructions|description):([^:]+)$/.exec(key)?.[1] + ?? null; +} + export function skillChangeTargetKey(skillId: string) { return `skill:${skillId}`; } diff --git a/server/src/services/chat-interaction-arbitration.test.ts b/server/src/services/chat-interaction-arbitration.test.ts index 3e32fb04317..0943ddd50b2 100644 --- a/server/src/services/chat-interaction-arbitration.test.ts +++ b/server/src/services/chat-interaction-arbitration.test.ts @@ -91,7 +91,7 @@ describe("provider-owned interaction arbitration", () => { return { companyId, agentId, otherAgentId, issueId, runId, interactionId }; } - it("does not suppress a chat final for a system or board completion review", async () => { + it("does not suppress a chat final for a system, board, or originating agent completion review", async () => { const binding = await seed(); expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false); await db @@ -99,13 +99,18 @@ describe("provider-owned interaction arbitration", () => { .set({ createdByUserId: "board-user" }) .where(eq(issueThreadInteractions.id, binding.interactionId)); expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false); + await db + .update(issueThreadInteractions) + .set({ createdByUserId: null, createdByAgentId: binding.agentId }) + .where(eq(issueThreadInteractions.id, binding.interactionId)); + expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false); }); it("reserves the response slot only for a pending interaction authored by the source run agent", async () => { const binding = await seed(); await db .update(issueThreadInteractions) - .set({ createdByAgentId: binding.otherAgentId }) + .set({ createdByAgentId: binding.otherAgentId, payload: { version: 1, prompt: "Confirm the requested action" } }) .where(eq(issueThreadInteractions.id, binding.interactionId)); expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false); await db diff --git a/server/src/services/chat-interaction-arbitration.ts b/server/src/services/chat-interaction-arbitration.ts index 98b709e15a5..6dc06877aa9 100644 --- a/server/src/services/chat-interaction-arbitration.ts +++ b/server/src/services/chat-interaction-arbitration.ts @@ -51,6 +51,9 @@ export async function hasChatRunOwnedProviderInteraction( and( inArray(issueThreadInteractions.kind, ["ask_user_questions", "request_confirmation"]), eq(issueThreadInteractions.status, "pending"), + sql`NOT (${issueThreadInteractions.kind} = 'request_confirmation' + AND coalesce(${issueThreadInteractions.payload}->'target'->>'type', '') = 'custom' + AND coalesce(${issueThreadInteractions.payload}->'target'->>'key', '') = 'native_completion_review')`, exists( db .select({ id: heartbeatRuns.id }) diff --git a/server/src/services/chat-interaction-publications.ts b/server/src/services/chat-interaction-publications.ts index 37e0199a5bf..853bb3ff220 100644 --- a/server/src/services/chat-interaction-publications.ts +++ b/server/src/services/chat-interaction-publications.ts @@ -253,6 +253,9 @@ export async function enqueueIssueInteractionChatPublications( interaction: IssueThreadInteraction, ) { if (interaction.status !== "pending") return []; + if (interaction.kind === "request_confirmation" + && interaction.payload.target?.type === "custom" + && interaction.payload.target.key === "native_completion_review") return []; // An endpoint is one immutable provider bot identity. Never externalize a // user/system-authored interaction, or let one agent speak through another // agent's endpoint. If the interaction names a source run, verify that run's diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 71c35f34052..c44aa8ee998 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,6 +1,9 @@ +import { publishActivity, type ActivityPublication } from "./activity-log.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { notifyAttentionRetentionWork } from "./retention-work-notifications.js"; import { notifyIssueRecovery } from "./issue-recovery-notifications.js"; -import { createAgentLifecycle } from "./agent-lifecycle.js"; +import { createAgentLifecycle, requestAgentHireInTransaction, initializeImportedAgentConfigurationInTransaction, scheduleAgentLifecycle } from "./agent-lifecycle.js"; import { agentAppearanceSchema, agentHarnessType, paperclipRunnerProfileForHarness, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; import { createHash, randomUUID } from "node:crypto"; @@ -10,6 +13,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { and, eq, inArray } from "drizzle-orm"; import { + companies as companiesTable, builtInManagedResources, issueRelations, principalPermissionGrants, @@ -761,6 +765,10 @@ type ImportPlanInternal = { type ImportMode = "board_full" | "agent_safe"; type ImportBehaviorOptions = { + actorAgentId?: string | null; + actorAgentCompanyId?: string | null; + interactionTransaction?: boolean; + interactionPublications?: ActivityPublication[]; mode?: ImportMode; sourceCompanyId?: string | null; pauseAutomations?: boolean; @@ -3575,7 +3583,7 @@ export function parseGitHubSourceUrl(rawUrl: string) { export function companyPortabilityService(db: Db, storage?: StorageService) { const companies = companyService(db); const agents = agentService(db); - const agentsLifecycle = createAgentLifecycle(db); + const agentsLifecycle = () => createAgentLifecycle(db); const assetRecords = assetService(db); const instructions = agentInstructionsService(db); const access = accessService(db); @@ -4268,6 +4276,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { }, ) as Record; const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record; + if (Object.hasOwn(agent.permissions ?? {}, "interactions")) { + delete portablePermissions.interactions; + warnings.push(`Agent ${slug} interaction restrictions are not copied by company packages; configure them through the interaction-permissions CLI after import.`); + } // A CEO's recorded hiring denial is a board choice, so it survives the // round trip even though export otherwise drops false values. const exportedHiringDenial = agent.role === "ceo" ? ceoHiringDenial(agent.permissions) : null; @@ -5272,6 +5284,23 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // the declared count is the only signal that distinguishes it from a // deliberately small bundle. Reject the fragment rather than importing it. assertInlineSourceComplete(input.source); + const origin = currentPluginInteractionOrigin(); + const actorAgentId = origin?.agentId ?? options?.actorAgentId; + const actorAgentCompanyId = origin?.companyId ?? options?.actorAgentCompanyId ?? options?.sourceCompanyId; + if (actorAgentId && !options?.interactionTransaction) { + const publications: ActivityPublication[] = []; + const result = await db.transaction(async tx => { + const companyIds = [actorAgentCompanyId, input.target.mode === "existing_company" ? input.target.companyId : null] + .filter((id): id is string => Boolean(id)); + await tx.select({ id: companiesTable.id }).from(companiesTable).where(inArray(companiesTable.id, companyIds)).orderBy(companiesTable.id).for("no key update"); + return companyPortabilityService(tx as unknown as Db, storage).importBundle(input, actorUserId, { + ...options, actorAgentId, actorAgentCompanyId, interactionTransaction: true, interactionPublications: publications, + }); + }); + for (const publication of publications) publishActivity(publication); + for (const agent of result.agents) if (agent.action === "created" && agent.id) scheduleAgentLifecycle(db, agent.id); + return result; + } // The importing board user brings a CEO's recorded hiring denial with the // package; create and configuration writes never take one from input. // Like the permissions toggle, the denial also removes an explicit @@ -5306,7 +5335,32 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { throw unprocessable("Safe import routes only allow create or skip actions."); } + const updateImportedAgent = (...args: Parameters) => options?.interactionTransaction + ? initializeImportedAgentConfigurationInTransaction(db, options.interactionPublications ?? [], args[0], args[1]) + : agents.update(...args); const sourceManifest = plan.source.manifest; + if (actorAgentId && actorAgentCompanyId) { + const texts: string[] = []; + const paths = new Set(); + if (plan.include.issues) for (const issue of sourceManifest.issues) { + texts.push(JSON.stringify(issue)); + paths.add(issue.path); + for (const document of issue.documents ?? []) paths.add(document.path); + } + if (plan.include.agents) for (const entry of plan.preview.plan.agentPlans) { + if (entry.action === "skip") continue; + const agent = plan.selectedAgents.find(agent => agent.slug === entry.slug); + if (!agent) continue; + texts.push(JSON.stringify(agent), JSON.stringify(input.adapterOverrides?.[entry.slug] ?? {})); + paths.add(agent.path); + for (const path of Object.keys(plan.source.files)) if (path.startsWith(`agents/${entry.slug}/`)) paths.add(path); + } + for (const path of paths) { + const text = readPortableTextFile(plan.source.files, path); + if (text !== null) texts.push(text); + } + await assertAgentInteraction(db, { companyId: actorAgentCompanyId, actorAgentId, operation: "mention", texts, lock: "update" }); + } const pauseAutomations = options?.pauseAutomations === true; const importedAutomationPausedAt = pauseAutomations ? new Date() : null; const warnings = [...plan.preview.warnings]; @@ -5725,8 +5779,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : {}; if (planAgent.action === "update" && planAgent.existingAgentId) { - if (pauseAutomations) await agentsLifecycle.pauseAgent(planAgent.existingAgentId, "import"); - let updated = await agents.update(planAgent.existingAgentId, { + if (pauseAutomations) await agentsLifecycle().pauseAgent(planAgent.existingAgentId, "import"); + let updated = await updateImportedAgent(planAgent.existingAgentId, { ...patch, }); if (updated) updated = await applyImportedCeoHiringDenial(targetCompany.id, updated, manifestAgent.permissions) ?? updated; @@ -5746,7 +5800,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { clearLegacyPromptTemplate: true, replaceExisting: true, }); - updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated; + updated = await updateImportedAgent(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated; } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } @@ -5774,12 +5828,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - let created = await agentsLifecycle.requestHire(targetCompany.id, { + const requestHire = options?.interactionTransaction + ? (...args: Parameters["requestHire"]>) => requestAgentHireInTransaction(db, options.interactionPublications ?? [], ...args) + : (...args: Parameters["requestHire"]>) => agentsLifecycle().requestHire(...args); + let created = await requestHire(targetCompany.id, { ...patch, runner: agentRunner(patch.adapterType), ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", - }, { createdByUserId: actorUserId }); + }, { createdByUserId: actorUserId, createdByAgentId: actorAgentId, createdByAgentCompanyId: actorAgentCompanyId ?? undefined }); created = await applyImportedCeoHiringDenial(targetCompany.id, created, manifestAgent.permissions) ?? created; await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( @@ -5795,7 +5852,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { clearLegacyPromptTemplate: true, replaceExisting: true, }); - created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created; + created = await updateImportedAgent(created.id, { adapterConfig: materialized.adapterConfig }) ?? created; } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } @@ -5845,7 +5902,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : null); if (!managerId || managerId === agentId) continue; try { - await agents.update(agentId, { reportsTo: managerId }); + await updateImportedAgent(agentId, { reportsTo: managerId }); } catch { const managerRef = managerSlug @@ -6115,7 +6172,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : "skip_missed", variables: routineDefinition.variables ?? [], }, { - agentId: null, + agentId: actorAgentId ?? null, + agentCompanyId: actorAgentCompanyId ?? undefined, userId: actorUserId ?? null, }); resultRoutines.push({ @@ -6137,7 +6195,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { cronExpression: trigger.cronExpression!, timezone: trigger.timezone!, }, { - agentId: null, + agentId: actorAgentId ?? null, + agentCompanyId: actorAgentCompanyId ?? undefined, userId: actorUserId ?? null, }); continue; @@ -6153,7 +6212,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : "bearer", replayWindowSec: trigger.replayWindowSec ?? 300, }, { - agentId: null, + agentId: actorAgentId ?? null, + agentCompanyId: actorAgentCompanyId ?? undefined, userId: actorUserId ?? null, }); continue; @@ -6163,7 +6223,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { label: trigger.label, enabled: trigger.enabled, }, { - agentId: null, + agentId: actorAgentId ?? null, + agentCompanyId: actorAgentCompanyId ?? undefined, userId: actorUserId ?? null, }); } @@ -6435,7 +6496,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // statements, turning what used to be one round-trip per row into a // handful per table. Empty buffers are skipped so an issues-free import // (e.g. routines only) issues no writes at all. - if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows); + if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows, actorAgentId, actorAgentCompanyId); // Imported issues are historical work, not new inbox items. Seed a // per-user inbox archive for the importing board user so a large import // (a real 1,418-task company shipped every task to the inbox) does not @@ -6449,9 +6510,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { actorUserId, ); } - if (commentRows.length > 0) await issues.addImportedComments(commentRows); + if (commentRows.length > 0) await issues.addImportedComments(commentRows, actorAgentId, actorAgentCompanyId); if (documentRows.length > 0) await documentsSvc.createIssueDocumentsForImport(documentRows); - if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows); + if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows, actorAgentId, actorAgentCompanyId); if (attachmentRows.length > 0) await issues.addImportedAttachments(attachmentRows); if (blockedByBySlug.size > 0) { diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index b095f184f2b..035f8d7a13c 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -1,8 +1,9 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { and, eq, inArray, or } from "drizzle-orm"; import { isUuidLike } from "@paperclipai/shared"; import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolApplications, toolCatalogEntries, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, type Db } from "@paperclipai/db"; import { forbidden } from "../errors.js"; -import { authorizationService, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js"; +import { authorizationService, authorizationDeniedDetails, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js"; import { captureRunIdentity, runIdentityTaskFields, type RunIdentityCapture } from "./run-identity.js"; import type { LogActivityInput } from "./activity-log.js"; import type { ConnectionManagementAction, ConnectionManagementResource } from "./connection-management-policy.js"; @@ -43,6 +44,7 @@ export function responsibleConnectionUser(actor: AuthorizationActor): string | n /** Installation changes require the same agent-edit authority at every entry point. */ export async function assertAgentConnectionInstallAccess(db: Db, actor: AuthorizationActor, companyId: string, agentIds: string[]) { const authorization = authorizationService(db); + await assertAgentToolAssignmentInteractions(db, actor.type === "agent" ? actor.agentId : null, companyId, agentIds); for (const agentId of new Set(agentIds)) { const [agent] = await db.select({ id: agents.id }).from(agents) .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).limit(1); @@ -50,7 +52,16 @@ export async function assertAgentConnectionInstallAccess(db: Db, actor: Authoriz const decision = await authorization.decide({ actor, action: "agent_config:update", resource: { type: "agent", companyId, agentId }, scope: { targetAgentId: agentId }, }); - if (!decision.allowed) throw forbidden(`You cannot edit agent ${agentId}, so you cannot change its connection installs`); + if (!decision.allowed) throw forbidden(`You cannot edit agent ${agentId}, so you cannot change its connection installs`, authorizationDeniedDetails(decision)); + } +} + +export async function assertAgentToolAssignmentInteractions(db: Db, actorAgentId: string | null | undefined, + companyId: string, agentIds: readonly string[], lock = typeof (db as unknown as { nestedIndex?: number }).nestedIndex === "number") { + const targets = [...new Set(agentIds)].sort(); + for (const [index, targetAgentId] of targets.entries()) { + await assertAgentInteraction(db, { companyId, actorAgentId, operation: "configure", targetAgentId, + lock: index === 0 && lock ? "update" : false, lockAgentIds: index === 0 ? targets : undefined }); } } @@ -275,14 +286,17 @@ export function connectionManagementService(db: Db) { if (targetAgentId) { const [target] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, targetAgentId), eq(agents.companyId, resource.companyId))); if (!target) throw forbidden("Target agent is outside this company"); + if (targetAgentId === resource.targetAgentId && (prepared.options.mutation ?? action !== "connections:read")) { + await assertAgentToolAssignmentInteractions(db, actor.type === "agent" ? actor.agentId : null, resource.companyId, [targetAgentId]); + } // Explicitly enforce both principals even when unrelated authorization is in shadow mode. // Existing human routes retain their domain-specific target authority. // Delegation additionally checks both the acting agent and its human. const principals: AuthorizationActor[] = actor.type === "agent" || resource.selfServiceAgentInstalls ? [actor] : []; if (actor.type === "agent") principals.push({ type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true }); for (const principal of principals) { - const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } }); - if (!targetDecision.allowed) throw forbidden(targetDecision.explanation); + const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } }, { interactionPolicy: "defer_to_mutation" }); + if (!targetDecision.allowed) throw forbidden(targetDecision.explanation, authorizationDeniedDetails(targetDecision)); } } return decision; diff --git a/server/src/services/decisions.ts b/server/src/services/decisions.ts index 8a84743fcef..8ad49f6a22a 100644 --- a/server/src/services/decisions.ts +++ b/server/src/services/decisions.ts @@ -1,3 +1,4 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { decisionTargetService, notifyDecisionTargetWork } from "./decision-targets.js"; import { randomUUID } from "node:crypto"; import { and, asc, count, desc, eq, gte, inArray, sql } from "drizzle-orm"; @@ -475,6 +476,27 @@ export function decisionService(db: Db, options: DecisionServiceOptions) { const deniedOrigin = originAccess.find((access) => !access.allowed); if (!userAccess.allowed || deniedOrigin) return finish("failed", "deny_decision_intersection", { reason: "deny_decision_intersection", userReason: userAccess.reason, originReason: deniedOrigin?.reason ?? null }); + const assignment = effect.type === "assign_issue" ? effect : effect.type === "create_issue" ? effect.draft : null; + await assertAgentInteraction(tx, { + companyId: decision.companyId, actorAgentId: decision.originAgentId, + operation: assignment ? "assign" : effect.type === "comment_on_issue" ? "comment" : "edit", + issueId: effect.type === "create_issue" ? undefined : target.id, + targetAgentId: assignment?.assigneeAgentId, assigneeUserId: assignment?.assigneeUserId, + parentIssueId: effect.type === "create_issue" ? effect.draft.parentId ?? target.id : undefined, + texts: [interpolate(JSON.stringify(effect), decision.inputValues ?? {})], lock: true, + }); + const effectComment = effect.type === "cancel_issue_tree" ? effect.reasonComment + : "comment" in effect ? effect.comment : undefined; + if (effectComment) await assertAgentInteraction(tx, { + companyId: decision.companyId, actorAgentId: decision.originAgentId, + operation: "comment", issueId: target.id, texts: [interpolate(effectComment, decision.inputValues ?? {})], lock: true, + }); + if (effect.type === "cancel_issue_tree") { + for (const child of referencedIssues.filter(item => item.id !== target.id)) { + await assertAgentInteraction(tx, { companyId: decision.companyId, actorAgentId: decision.originAgentId, + operation: "edit", issueId: child.id, lock: true }); + } + } if (effect.staleness === "strict") { const staleReference = referencedIssues.some((item) => directReferencedIds.has(item.id) && snapshots[item.id]?.updatedAt !== item.updatedAt.toISOString()); @@ -486,7 +508,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) { const values = decision.inputValues ?? {}; let result: Record; if (effect.type === "comment_on_issue") { - const comment = await svc.addComment(target.id, interpolate(effect.bodyMarkdown, values), { userId: decidedByUserId }, undefined, tx); + const comment = await svc.addComment(target.id, interpolate(effect.bodyMarkdown, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx); result = { commentId: comment.id }; } else if (effect.type === "update_issue_status") { const updated = await svc.update( @@ -496,7 +518,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) { postCommitActivityPublications, postCommitIssueActions, ); - if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx); + if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx); result = { issueId: updated?.id, status: updated?.status }; } else if (effect.type === "assign_issue") { const updated = await svc.update( @@ -510,7 +532,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) { postCommitActivityPublications, postCommitIssueActions, ); - if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx); + if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx); result = { issueId: updated?.id }; } else if (effect.type === "resolve_blocker") { const current = await tx.select({ id: issueRelations.issueId }).from(issueRelations).where(and(eq(issueRelations.companyId, decision.companyId), eq(issueRelations.relatedIssueId, target.id), eq(issueRelations.type, "blocks"))); @@ -543,7 +565,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) { postCommitIssueActions, ); } - await svc.addComment(target.id, interpolate(effect.reasonComment, values), { userId: decidedByUserId }, undefined, tx); + await svc.addComment(target.id, interpolate(effect.reasonComment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx); result = { cancelledIssueIds: cancelled }; } const activity = await effectAudit(tx as unknown as Db, decision, execution.id, effect, "executed", decidedByUserId, originResponsibleUserId, result); diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index 8199a6ef41c..c596cc23527 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -1,4 +1,5 @@ import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import type { Db } from "@paperclipai/db"; import { documentAnnotationAnchorSnapshots, @@ -423,6 +424,8 @@ export function documentAnnotationService(db: Db) { `); const doc = await getIssueDocument(issueId, key, tx); if (!doc) throw notFound("Document not found"); + await assertAgentInteraction(tx, { companyId: doc.companyId, actorAgentId: actor.agentId, + operation: "comment", issueId, texts: [input.body], lock: true }); if ( input.baseRevisionId !== doc.latestRevisionId || input.baseRevisionNumber !== doc.latestRevisionNumber @@ -682,6 +685,8 @@ export function documentAnnotationService(db: Db) { ) => db.transaction(async (tx) => { const thread = await getThreadForIssue(issueId, key, threadId, tx); if (!thread) throw notFound("Annotation thread not found"); + await assertAgentInteraction(tx, { companyId: thread.companyId, actorAgentId: actor.agentId, + operation: "comment", issueId, texts: [input.body], lock: true }); const now = new Date(); const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx); const [comment] = await tx @@ -857,6 +862,8 @@ export function documentAnnotationService(db: Db) { ) => db.transaction(async (tx) => { const thread = await getThreadForIssue(issueId, key, threadId, tx); if (!thread) throw notFound("Annotation thread not found"); + await assertAgentInteraction(tx, { companyId: thread.companyId, actorAgentId: actor.agentId, + operation: "edit", issueId, lock: true }); if (!input.status || input.status === thread.status) return thread; const now = new Date(); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index fb8da036dad..b5c4fafa07b 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -1,4 +1,5 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { notifyIssueRecovery } from "./issue-recovery-notifications.js"; import { randomUUID } from "node:crypto"; import { and, asc, desc, eq, sql } from "drizzle-orm"; @@ -228,7 +229,7 @@ export function documentService(db: Db) { createdByRunId?: string | null; sourceTrust?: typeof documents.$inferInsert.sourceTrust; lockedDocumentStrategy?: "conflict" | "create_new_document"; - }) => { + }, options: { initialTaskCreation?: boolean } = {}) => { const key = normalizeDocumentKey(input.key); const issue = await db .select({ id: issues.id, companyId: issues.companyId }) @@ -270,6 +271,16 @@ export function documentService(db: Db) { .where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key))) .then((rows) => rows[0] ?? null); + await assertAgentInteraction(tx, { + companyId: issue.companyId, actorAgentId: input.createdByAgentId, + operation: options.initialTaskCreation && !existing ? "mention" : "edit", issueId: issue.id, + texts: [ + ...(!existing || input.body !== existing.latestBody ? [input.body] : []), + ...(input.title !== undefined && input.title !== existing?.title ? [input.title ?? ""] : []), + input.changeSummary ?? "", + ], lock: true, + }); + if (existing) { if (existing.lockedAt) { if (input.lockedDocumentStrategy === "create_new_document") { @@ -649,6 +660,10 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!revision) throw notFound("Document revision not found"); + await assertAgentInteraction(tx, { + companyId: existing.companyId, actorAgentId: input.createdByAgentId, + operation: "edit", issueId: input.issueId, texts: [revision.body, revision.title ?? ""], lock: true, + }); if (existing.latestRevisionId === revision.id) { throw conflict("Selected revision is already the latest revision", { currentRevisionId: existing.latestRevisionId, @@ -728,6 +743,10 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) throw notFound("Document not found"); + await assertAgentInteraction(tx, { + companyId: existing.companyId, actorAgentId: input.lockedByAgentId, + operation: "edit", issueId: input.issueId, lock: true, + }); if (existing.lockedAt) { return { changed: false as const, @@ -765,7 +784,7 @@ export function documentService(db: Db) { }); }, - unlockIssueDocument: async (issueId: string, rawKey: string) => { + unlockIssueDocument: async (issueId: string, rawKey: string, actorAgentId?: string | null) => { const key = normalizeDocumentKey(rawKey); return db.transaction(async (tx) => { const existing = await tx @@ -776,6 +795,9 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) throw notFound("Document not found"); + await assertAgentInteraction(tx, { + companyId: existing.companyId, actorAgentId, operation: "edit", issueId, lock: true, + }); if (!existing.lockedAt) { return { changed: false as const, @@ -813,7 +835,7 @@ export function documentService(db: Db) { }); }, - deleteIssueDocument: async (issueId: string, rawKey: string) => { + deleteIssueDocument: async (issueId: string, rawKey: string, actorAgentId?: string | null) => { const key = normalizeDocumentKey(rawKey); return db.transaction(async (tx) => { const existing = await tx @@ -824,6 +846,9 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) return null; + await assertAgentInteraction(tx, { + companyId: existing.companyId, actorAgentId, operation: "edit", issueId, lock: true, + }); if (existing.lockedAt) { throw conflict("Document is locked", { key: existing.key, diff --git a/server/src/services/heartbeat/queue.ts b/server/src/services/heartbeat/queue.ts index 8f9f8395991..a99654a7ba1 100644 --- a/server/src/services/heartbeat/queue.ts +++ b/server/src/services/heartbeat/queue.ts @@ -3,6 +3,7 @@ import { verifyBlockerStallWake } from "../recovery/blocker-stall-wakeups.js"; import { CONTEXT_COMPACTION_WAKE_REASON, ContextCompactionError, isContextCompactionRun, readContextCompactionRequest } from "../context-compaction.js"; import { isProcessRunAdmissionClosed } from "./run-admission.js"; import { deliveredQueuedCommentIds } from "../queued-comment-delivery.js"; +import { hasAutomaticInteractionDeliveryHold, INTERACTION_DELIVERY_DENIED, recheckQueuedRunInteractions, withInteractionDeliveryOrigin } from "../agent-interaction-delivery.js"; import { deferredWorkIntentWait } from "../work-intent-outbox.js"; import { notifyIssueRecovery } from "../issue-recovery-notifications.js"; import { createHeartbeatTimerPolicy } from "./timer-policy.js"; @@ -860,6 +861,23 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende deferredCancellations?: Array<() => Promise>, ) { if (run.status !== "queued") return run; + const interactionDelivery = await recheckQueuedRunInteractions(db, run); + if (interactionDelivery.run.status !== "queued") return null; + run = { ...run, contextSnapshot: interactionDelivery.run.contextSnapshot, + resultJson: interactionDelivery.run.resultJson, updatedAt: interactionDelivery.run.updatedAt }; + if (interactionDelivery.denied) { + const cancelled = await setRunStatus(run.id, "cancelled", { + finishedAt: new Date(), error: "Queued agent input is no longer permitted.", + errorCode: INTERACTION_DELIVERY_DENIED, + }); + if (cancelled) { + await setWakeupStatus(run.wakeupRequestId, "skipped", { finishedAt: new Date(), error: INTERACTION_DELIVERY_DENIED }); + await appendRunEvent(cancelled, { eventType: "lifecycle", stream: "system", level: "warn", + message: "Queued agent input was refused after its interaction permission changed.", payload: { code: INTERACTION_DELIVERY_DENIED, denials: interactionDelivery.denials } }); + await releaseIssueExecutionAndPromote(cancelled, { suppressImmediateRecovery: true, deferredPostCommitEffects }); + } + return null; + } const [caseHandoffReceipt] = run.wakeupRequestId ? await db.select().from(caseHandoffs).where(and( eq(caseHandoffs.wakeupRequestId, run.wakeupRequestId), eq(caseHandoffs.companyId, run.companyId), )).limit(1) : []; @@ -1917,7 +1935,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende reason: wake.reason ?? undefined, payload, contextSnapshot: context, requestedByActorType: wake.requestedByActorType as WakeupOptions["requestedByActorType"], requestedByActorId: wake.requestedByActorId ?? undefined, - }, wake.id).catch(err => { + }, wake.id, wake.payload?.interactionOrigin).catch(err => { if (err instanceof PersistedExecutionWaitRefusal) return; scope.onError?.(err); logger.warn({ err, requestId: wake.id }, "a message saved during a pause could not resume"); @@ -2332,7 +2350,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende return promise; } - async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string) { + async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string, savedInteractionOrigin?: unknown) { const source = opts.source ?? "on_demand"; const triggerDetail = opts.triggerDetail ?? null; const contextSnapshot: Record = { @@ -2384,7 +2402,10 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende // Keep each request's own server-derived origin, including coalesced wakes. // A run's merged context cannot establish which caller authored one receipt. // Overwrite caller-supplied nested context rather than trusting it. - payload = { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } }; + payload = withInteractionDeliveryOrigin( + { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } }, + opts.requestedByActorType, opts.requestedByActorId, savedInteractionOrigin, opts.requestedByActorCompanyId, + ); let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; if (executionReconciliationWake && !issueId) return null; @@ -2415,6 +2436,10 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende let agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); + if (issueId && await hasAutomaticInteractionDeliveryHold(db, { + companyId: agent.companyId, issueId, agentId, payload, context: enrichedContextSnapshot, + actorType: opts.requestedByActorType, actorId: opts.requestedByActorId, + })) return null; if (issueId) { const [pending] = await db.select().from(caseHandoffs).where(and(eq(caseHandoffs.companyId, agent.companyId), eq(caseHandoffs.issueId, issueId), inArray(caseHandoffs.state, ["awaiting_release", "pending_dispatch"]))).limit(1); @@ -5243,7 +5268,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende statusDecisionSource: "native_status_decision", nativeStatusWakeIntentId: candidate.id, }, - }); + }, undefined, candidate.payload?.interactionOrigin); const delivered = await db .select() diff --git a/server/src/services/heartbeat/run-preparation.ts b/server/src/services/heartbeat/run-preparation.ts index 6c6fe418cab..dfec84316ce 100644 --- a/server/src/services/heartbeat/run-preparation.ts +++ b/server/src/services/heartbeat/run-preparation.ts @@ -828,6 +828,7 @@ export interface WakeupOptions { idempotencyKey?: string | null; requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; + requestedByActorCompanyId?: string; contextSnapshot?: Record; issueStateGuard?: { statuses: string[]; diff --git a/server/src/services/heartbeat/runtime-execution.ts b/server/src/services/heartbeat/runtime-execution.ts index 86f529253cd..7880cff8c51 100644 --- a/server/src/services/heartbeat/runtime-execution.ts +++ b/server/src/services/heartbeat/runtime-execution.ts @@ -8,6 +8,7 @@ import { NativeControllerRecoveryWaitingError } from "../native-runtime/native-c import { describeFailedAttachment } from "../native-runtime/remote-warm-session-recovery.js"; import { usesNativeUsageDelivery } from "../native-runtime/native-usage-accounting.js"; +import { INTERACTION_DELIVERY_DENIED, preparedRunInteractionDeliveryDenied } from "../agent-interaction-delivery.js"; import { DELIVERY_QUEUES } from "../delivery-work-notifications.js"; import { readRemoteWorkspaceAuthority, saveRemoteWorkspace } from "../remote-workspaces.js"; import { environmentService } from "../environments.js"; @@ -250,7 +251,13 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe const { nativeInstructionWorkingCopy, collectStoppedInstructions, nativeInstructionReservation, releaseInstructionCopy } = input.instructions; const { providerTraceCapture, nativeRunnerPreparationSpans, attestedQuestionResponseAtMs, attemptStartedAtMs, environmentAcquireStartedAtMs, environmentRealizeEndedAtMs } = input.trace; const { executionControl, executionPhaseContext, dispatchResolvedInteractionContinuationWithAtomicGate } = input.control; - const dispatchWithTiming: HeartbeatRuntimeExecutionInput["control"]["dispatchResolvedInteractionContinuationWithAtomicGate"] = dispatch => { + const dispatchWithTiming: HeartbeatRuntimeExecutionInput["control"]["dispatchResolvedInteractionContinuationWithAtomicGate"] = async dispatch => { + if (await preparedRunInteractionDeliveryDenied(db, run)) { + await input.services.cancelRunInternal(run.id, "Queued agent input is no longer permitted.", { + errorCode: INTERACTION_DELIVERY_DENIED, suppressImmediateRecovery: true, + }); + return { dispatched: false }; + } const providerDispatch = AsyncLocalStorage.bind(dispatch); return measureRunPhase("execution.dispatch_admission", () => dispatchResolvedInteractionContinuationWithAtomicGate(providerDispatch)); }; diff --git a/server/src/services/issue-assignment-wakeup.ts b/server/src/services/issue-assignment-wakeup.ts index 96ec5d96318..3081d377d4a 100644 --- a/server/src/services/issue-assignment-wakeup.ts +++ b/server/src/services/issue-assignment-wakeup.ts @@ -16,6 +16,7 @@ export interface IssueAssignmentWakeupDeps { allowRunCoalescing?: boolean; requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; + requestedByActorCompanyId?: string; contextSnapshot?: Record; durableChatRequest?: DurableChatWakeupRequest; }, @@ -30,6 +31,7 @@ export function queueIssueAssignmentWakeup(input: { contextSource: string; requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; + requestedByActorCompanyId?: string; taskKey?: string | null; /** Latest issue comment that caused this wakeup. Included in both payload * and context so the heartbeat can build the exact turn that was requested. */ @@ -58,6 +60,7 @@ export function queueIssueAssignmentWakeup(input: { }, requestedByActorType: input.requestedByActorType, requestedByActorId: input.requestedByActorId ?? null, + ...(input.requestedByActorCompanyId ? { requestedByActorCompanyId: input.requestedByActorCompanyId } : {}), ...(input.durableChatRequest ? { durableChatRequest: input.durableChatRequest } : {}), diff --git a/server/src/services/issue-thread-interactions.test.ts b/server/src/services/issue-thread-interactions.test.ts index 5ad905a8a10..988a89def4a 100644 --- a/server/src/services/issue-thread-interactions.test.ts +++ b/server/src/services/issue-thread-interactions.test.ts @@ -23,6 +23,9 @@ function createSelectChain(rows: SelectRow[] | ((table: unknown) => SelectRow[]) where() { return query; }, + orderBy() { + return query; + }, for() { return query; }, @@ -64,7 +67,16 @@ function createFakeDb(args: { select: vi.fn(() => { selectCallCount += 1; const rows = selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? []); - return createSelectChain(table => getTableName(table as never) === "issues" ? [{ status: "in_progress" }] : rows); + return createSelectChain(table => { + if (getTableName(table as never) === "issues") return [{ + id: interactionRow.issueId, companyId: interactionRow.companyId, status: "in_progress", + assigneeAgentId: interactionRow.createdByAgentId, conversationAgentId: null, + }]; + if (getTableName(table as never) === "agents") return [{ + id: interactionRow.createdByAgentId, companyId: interactionRow.companyId, permissions: {}, + }]; + return rows; + }); }), update: vi.fn((table: unknown) => ({ set(values: Record) { diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index c75d546bb95..1e67b712880 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1,3 +1,5 @@ +import { agentConfigurationChangeTargetId } from "./change-consent-gate.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { resolveNewTaskParent } from "./issue-hierarchy.js"; import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js"; @@ -177,7 +179,11 @@ type InteractionActor = { relayedHumanUserId?: string | null; }; -async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor) { +async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor, texts: string[] = []) { + await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId, + operation: "edit", issueId: issue.id, lock: true }); + await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId, + operation: "comment", issueId: issue.id, texts, lock: true }); if (!actor.agentId || !actor.runId) return; // Keep the same issue -> run lock order as task mutation and checkout. await tx.select({ id: issues.id }).from(issues) @@ -2433,7 +2439,7 @@ export function issueThreadInteractionService( const now = new Date(); const postCommitActivityPublications: ActivityPublication[] = []; const result = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor, [JSON.stringify(args.input)]); await notifyChatConfirmationResolution(tx, args.current); if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current); else await notifyIssueReviewState(tx, args.current); @@ -2732,7 +2738,7 @@ export function issueThreadInteractionService( const now = new Date(); const updated = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor, [JSON.stringify(args.input)]); await notifyChatConfirmationResolution(tx, args.current); if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current); else await notifyIssueReviewState(tx, args.current); @@ -3639,7 +3645,7 @@ export function issueThreadInteractionService( replacementInteractionId: string; }> = []; for (const row of rows) { - if (!row.createdByAgentId) continue; + if (!row.createdByAgentId || isNativeCompletionReview(row)) continue; const groupKey = `${row.companyId}:${row.issueId}:${row.kind}:${row.createdByAgentId}`; const newest = newestByGroup.get(groupKey); if (!newest) { @@ -3934,7 +3940,22 @@ export function issueThreadInteractionService( // Idempotent reuse above stays allowed so retries of a pre-close // create keep returning the (by now expired) original. const result = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); + await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId, + operation: "mention", issueId: issue.id, texts: [JSON.stringify(normalizedData)], lock: true }); + const configurationTarget = normalizedData.kind === "request_confirmation" || normalizedData.kind === "request_checkbox_confirmation" + ? normalizedData.payload.target : null; + const configurationAgentId = configurationTarget?.type === "custom" + ? agentConfigurationChangeTargetId(configurationTarget.key) : null; + if (configurationAgentId) await assertAgentInteraction(tx, { + companyId: issue.companyId, actorAgentId: actor.agentId, operation: "configure", + targetAgentId: configurationAgentId, lock: true, + }); + if (normalizedData.addresseeAgentId || normalizedData.addresseeUserId) { + await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId, + operation: "assign", targetAgentId: normalizedData.addresseeAgentId, + assigneeUserId: normalizedData.addresseeUserId, lock: true }); + } const [issueRow] = await tx .select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, identifier: issues.identifier }) .from(issues) @@ -4192,6 +4213,8 @@ export function issueThreadInteractionService( eq(issueThreadInteractions.status, "pending"), ne(issueThreadInteractions.id, row.id), sql`${issueThreadInteractions.payload}->'qualityReview' IS NULL`, + sql`not coalesce(${issueThreadInteractions.payload}->'target'->>'type' = 'custom' + and ${issueThreadInteractions.payload}->'target'->>'key' = 'native_completion_review', false)`, ); if (data.kind === "request_confirmation") { // The issue lock above excludes concurrent interaction creation. @@ -4462,7 +4485,7 @@ export function issueThreadInteractionService( await db.transaction(async (tx) => { await notifyChatConfirmationResolution(tx, current); await mutationOptions.beforeResolveInTransaction?.(tx); - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); if (sourceConversation) { await lockAcceptedChatHandoffSource(tx, { companyId: issue.companyId, conversationId: sourceConversation.id, @@ -4658,7 +4681,7 @@ export function issueThreadInteractionService( const data = submitIssueThreadInteractionVerdictsSchema.parse(input); const submission = await db.transaction(async (tx) => { await mutationOptions.beforeResolveInTransaction?.(tx); - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); // Match completion/confirmation ordering: issue -> interaction -> output. // A pending multi-item review can contain already-approved output slots, // so a replay must not lock those outputs ahead of task completion. @@ -4818,7 +4841,7 @@ export function issueThreadInteractionService( const [updated] = await db.transaction(async (tx) => { await notifyChatConfirmationResolution(tx, current); await mutationOptions.beforeResolveInTransaction?.(tx); - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id }); const rows = await tx.update(issueThreadInteractions) .set({ @@ -5456,7 +5479,7 @@ export function issueThreadInteractionService( // review queue while the card is still pending, and an executable // request must not outlive a withdrawn card. const updated = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); await notifyChatConfirmationResolution(tx, current); if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current); else await notifyIssueReviewState(tx, current); @@ -5594,7 +5617,7 @@ export function issueThreadInteractionService( } if (!historicalAnswer) throw interactionIssueClosedError(); } - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); await notifyChatRunMilestones(tx, current); await mutationOptions.beforeResolveInTransaction?.(tx); const resolvedAt = new Date(); @@ -5719,7 +5742,7 @@ export function issueThreadInteractionService( const reason = data.reason?.trim() || null; const now = new Date(); const updated = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); await notifyChatConfirmationResolution(tx, current); if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current); else await notifyIssueReviewState(tx, current); @@ -5819,7 +5842,7 @@ export function issueThreadInteractionService( const reason = data.reason?.trim() || null; const updated = await db.transaction(async (tx) => { - await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); + await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]); await notifyChatRunMilestones(tx, current); const resolvedAt = new Date(); const [row] = await tx diff --git a/server/src/services/issue-title.ts b/server/src/services/issue-title.ts index 35e30bbf32c..9d5105cc97f 100644 --- a/server/src/services/issue-title.ts +++ b/server/src/services/issue-title.ts @@ -1,4 +1,5 @@ import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { notifyAttentionRetentionWork } from "./retention-work-notifications.js"; import { and, eq } from "drizzle-orm"; import { heartbeatRuns, issues, type Db } from "@paperclipai/db"; @@ -79,6 +80,8 @@ export async function setIssueTitle( return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null }; } await notifyStatusCardIssueChange(tx, companyId); + await assertAgentInteraction(tx, { companyId, actorAgentId: actor.actorType === "agent" ? actor.actorId : null, + operation: "edit", issueId, texts: title === issue.title ? [] : [title], lock: true }); await notifyAttentionRetentionWork(tx, { companyId }); await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate); await issueReferenceService(tx).syncIssue(issueId, tx); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index bf009635f5b..a24ce2e3c2a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,4 +1,6 @@ import { recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "./public-mcp/upload-cleanup.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; import type { IssueDisplayStatus } from "@paperclipai/shared"; import { issueDisplayStatusSql } from "./issue-display-status.js"; import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js"; @@ -9747,18 +9749,19 @@ export function issueService(db: Db) { }; }, - createChild: async (parentIssueId: string, data: IssueChildCreateInput) => { - const parent = await db + createChild: async (parentIssueId: string, data: IssueChildCreateInput) => db.transaction(async childTx => { + const childDb = childTx as unknown as Db; + const parent = await childDb .select() .from(issues) .where(eq(issues.id, parentIssueId)) .then((rows) => rows[0] ?? null); if (!parent) throw notFound("Parent issue not found"); - await assertExecutionTaskParent(db, parent.companyId, parent.id); + await assertExecutionTaskParent(childDb, parent.companyId, parent.id); const idempotencyKey = data.idempotencyKey?.trim(); if (idempotencyKey) { - const existingChild = await db + const existingChild = await childDb .select({ issue: issues }) .from(issueCreateIdempotencyKeys) .innerJoin(issues, eq(issueCreateIdempotencyKeys.issueId, issues.id)) @@ -9780,11 +9783,11 @@ export function issueService(db: Db) { await data.assertCanReuseIssue?.(existingChild); assertQualityReviewPolicyReuse(data.executionPolicy, existingChild.executionPolicy); data.onDeduplicated?.("idempotency_key"); - const [enriched] = await withIssueLabels(db, [existingChild]); + const [enriched] = await withIssueLabels(childDb, [existingChild]); const [withRelations] = await withIssueRelationSummaries( parent.companyId, [enriched], - db, + childDb, ); return { issue: withParentAdjustment(withRelations, null), @@ -9796,10 +9799,10 @@ export function issueService(db: Db) { // Under the agent depth limit a child of a child lands beside it; count // the requester's own fan-out either way. const { effectiveParentId } = confirmPlacement( - await resolveNewTaskParent(db, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"), + await resolveNewTaskParent(childDb, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"), data.expectedParentId, ); - const [{ childCount }] = await db + const [{ childCount }] = await childDb .select({ childCount: sql`count(*)::int` }) .from(issues) .where( @@ -9858,8 +9861,9 @@ export function issueService(db: Db) { issueData.executionWorkspaceSettings as Record, ) : null; - let child = await issueService(db).create(parent.companyId, { + let child = await issueService(childDb).create(parent.companyId, { ...issueData, + createdByAgentId: issueData.createdByAgentId ?? actorAgentId, parentId: parent.id, projectId: childProjectId, projectWorkspaceId: @@ -9892,9 +9896,11 @@ export function issueService(db: Db) { }); if (blockParentUntilDone) { - await db.transaction(async tx => { + await childDb.transaction(async tx => { await tx.select({ id: issues.id }).from(issues).where(and( eq(issues.companyId, parent.companyId), eq(issues.id, parent.id))).for("update"); + await assertAgentInteraction(tx, { companyId: parent.companyId, actorAgentId: actorAgentId ?? issueData.createdByAgentId, + operation: "edit", issueId: parent.id, lock: true }); const existingBlockers = await tx .select({ blockerIssueId: issueRelations.issueId }) .from(issueRelations) @@ -9922,7 +9928,7 @@ export function issueService(db: Db) { [child] = await withIssueRelationSummaries( parent.companyId, [child], - db, + childDb, ); child = withParentAdjustment(child, parentAdjusted ?? null); } @@ -9931,7 +9937,7 @@ export function issueService(db: Db) { issue: child, parentBlockerAdded: Boolean(blockParentUntilDone), }; - }, + }), decomposeAcceptedPlan: async ( sourceIssueId: string, @@ -10303,6 +10309,9 @@ export function issueService(db: Db) { data: IssueCreateInput, dbOrTx: Db | DbTransaction = db, ) => { + const pluginOrigin = currentPluginInteractionOrigin(); + if (pluginOrigin) data = { ...data, createdByAgentId: pluginOrigin.agentId, + createdByUserId: null, actorRunId: pluginOrigin.runId ?? data.actorRunId }; const { workspaceSelection, workspaceSelectionSource = "explicit", workspaceSelectionActor, initialPlan, @@ -10365,7 +10374,14 @@ export function issueService(db: Db) { throw unprocessable("in_progress issues require an assignee"); } const persist = async (tx: DbTransaction) => { + await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update"); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`); + await assertAgentInteraction(tx as unknown as Db, { + companyId, actorAgentId: issueData.createdByAgentId, + operation: "assign", targetAgentId: issueData.assigneeAgentId, + assigneeUserId: issueData.assigneeUserId, parentIssueId: requestedParentId, + texts: [resolvedTitle, issueData.description ?? "", initialPlan ?? ""], lock: true, + }); const parentResolution = confirmPlacement( await resolveNewTaskParent(tx, companyId, requestedParentId, parentDepthPolicy), expectedParentId, @@ -10377,6 +10393,10 @@ export function issueService(db: Db) { } parentAdjustment = parentResolution.adjustment; issueData.parentId = parentResolution.effectiveParentId; + if (issueData.parentId && issueData.parentId !== requestedParentId) { + await assertAgentInteraction(tx, { companyId, actorAgentId: issueData.createdByAgentId, + operation: "edit", issueId: issueData.parentId, lock: true }); + } await assertParentInCompany(tx, companyId, issueData.parentId); await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId); if (issueData.conversationAgentId && issueData.conversationUserId) { @@ -10965,7 +10985,7 @@ export function issueService(db: Db) { createdByAgentId: issueData.createdByAgentId, createdByUserId: issueData.createdByUserId, createdByRunId: actorRunId, sourceTrust: issue.sourceTrust, - }); + }, { initialTaskCreation: true }); } const [enriched] = await withIssueLabels(tx, [issue]); const [withRelations] = await withIssueRelationSummaries( @@ -11005,12 +11025,21 @@ export function issueService(db: Db) { importIssues: async ( companyId: string, rows: ImportIssueRow[], + actorAgentId?: string | null, + actorCompanyId?: string | null, ): Promise => { if (rows.length === 0) return; const isolatedWorkspacesEnabled = ( await instanceSettings.getExperimental() ).enableIsolatedWorkspaces; await db.transaction(async (tx) => { + await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update"); + const newIssueIds = new Set(rows.map(row => row.id)); + for (const row of rows) await assertAgentInteraction(tx, { + companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined, operation: "assign", targetAgentId: row.assigneeAgentId, + parentIssueId: row.parentId && !newIssueIds.has(row.parentId) ? row.parentId : undefined, + texts: [row.title, row.description ?? "", row.monitorNotes ?? ""], lock: true, + }); // Self-correcting counter: seed from max(issue_number) so a drifted // company counter cannot mint colliding identifiers, then reserve the // whole range in one bump instead of one-per-issue. @@ -11210,11 +11239,15 @@ export function issueService(db: Db) { */ addImportedComments: async ( rows: ImportIssueCommentRow[], + actorAgentId?: string | null, + actorCompanyId?: string | null, ): Promise => { if (rows.length === 0) return; const censorUsernameInLogs = (await instanceSettings.getGeneral()) .censorUsernameInLogs; await db.transaction(async (tx) => { + for (const row of rows) await assertAgentInteraction(tx, { companyId: row.companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined, + operation: "mention", issueId: row.issueId, texts: [row.body], lock: true }); for (const companyId of new Set(rows.map(row => row.companyId))) await notifyStatusCardIssueChange(tx, companyId); const commentRows = rows.map((row) => { const createdAt = row.createdAt ? new Date(row.createdAt) : null; @@ -11702,6 +11735,39 @@ export function issueService(db: Db) { .for("update") .then((rows: Array) => rows[0] ?? null); if (!receiptExisting) return null; + if (Object.keys(issueData).length || nextLabelIds !== undefined || blockedByIssueIds !== undefined) await assertAgentInteraction(tx as unknown as Db, { + companyId: receiptExisting.companyId, actorAgentId, operation: "edit", issueId: id, + texts: [ + ...(issueData.title !== undefined && issueData.title !== receiptExisting.title ? [issueData.title] : []), + ...(issueData.description !== undefined && issueData.description !== receiptExisting.description ? [issueData.description ?? ""] : []), + ], lock: true, + }); + if (patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined) { + await assertAgentInteraction(tx as unknown as Db, { + companyId: receiptExisting.companyId, actorAgentId, operation: "assign", issueId: id, + targetAgentId: patch.assigneeAgentId === undefined ? receiptExisting.assigneeAgentId : patch.assigneeAgentId, + assigneeUserId: patch.assigneeUserId === undefined ? receiptExisting.assigneeUserId : patch.assigneeUserId, + lock: true, + }); + } + if (issueData.executionState !== undefined) { + const previousParticipant = parseObject(parseObject(receiptExisting.executionState).currentParticipant); + const nextParticipant = parseObject(parseObject(issueData.executionState).currentParticipant); + if ((nextParticipant.agentId || nextParticipant.userId) + && (nextParticipant.agentId !== previousParticipant.agentId || nextParticipant.userId !== previousParticipant.userId + || parseObject(issueData.executionState).status !== parseObject(receiptExisting.executionState).status)) { + await assertAgentInteraction(tx, { companyId: receiptExisting.companyId, actorAgentId, operation: "assign", + targetAgentId: typeof nextParticipant.agentId === "string" ? nextParticipant.agentId : null, + assigneeUserId: typeof nextParticipant.userId === "string" ? nextParticipant.userId : null, lock: true }); + } + } + if (issueData.parentId !== undefined && issueData.parentId !== receiptExisting.parentId) { + for (const parentId of new Set([receiptExisting.parentId, issueData.parentId, privacySourceParentId])) { + if (parentId) await assertAgentInteraction(tx as unknown as Db, { + companyId: receiptExisting.companyId, actorAgentId, operation: "edit", issueId: parentId, lock: true, + }); + } + } // Only a run the caller did not check is protected. A lock released in // between (for example by the route's own confirmed Stop) is fine. if (expectedExecutionLock && ( @@ -12348,8 +12414,11 @@ export function issueService(db: Db) { return cleared; }, - remove: (id: string) => + remove: (id: string, actorAgentId?: string | null) => db.transaction(async (tx) => { + const [task] = await tx.select({ companyId: issues.companyId }).from(issues).where(eq(issues.id, id)); + if (task) await assertAgentInteraction(tx, { companyId: task.companyId, actorAgentId, + operation: "edit", issueId: id, lock: true }); const attachmentAssetIds = await tx .select({ assetId: issueAttachments.assetId }) .from(issueAttachments) @@ -12513,6 +12582,10 @@ export function issueService(db: Db) { ) : isNull(issues.executionRunId); const updated = await db.transaction(async tx => { + await assertAgentInteraction(tx as unknown as Db, { + companyId: issueCompany.companyId, actorAgentId: agentId, operation: "assign", + issueId: id, targetAgentId: agentId, lock: true, + }); await notifyStatusCardIssueChange(tx, issueCompany.companyId); await notifyIssueAttentionRetentionWork(tx, { companyId: issueCompany.companyId, issueId: id }); await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors); @@ -13171,7 +13244,7 @@ export function issueService(db: Db) { ); }, - removeComment: async (commentId: string) => { + removeComment: async (commentId: string, actorAgentId?: string | null) => { const currentUserRedactionOptions = { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }; @@ -13181,6 +13254,7 @@ export function issueService(db: Db) { const [source] = await tx.select({ companyId: issueComments.companyId, issueId: issueComments.issueId }) .from(issueComments).where(eq(issueComments.id, commentId)); if (!source) return null; + await assertAgentInteraction(tx as unknown as Db, { ...source, actorAgentId, operation: "comment", lock: true }); await notifyExternalConversationSettlement(tx, source); await notifyStatusCardIssueChange(tx, source.companyId); const [comment] = await tx @@ -13221,6 +13295,9 @@ export function issueService(db: Db) { .from(issueComments).where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt))) .for("update"); if (!source) return null; + await assertAgentInteraction(tx as unknown as Db, { + ...source, actorAgentId: actor.actorType === "agent" ? actor.agentId : null, operation: "comment", lock: true, + }); await notifyExternalConversationSettlement(tx, source); await notifyStatusCardIssueChange(tx, source.companyId); await notifyChatInboundWakeNotices(tx, source); @@ -13291,6 +13368,10 @@ export function issueService(db: Db) { }, dbOrTx: any = db, ): Promise { + const pluginOrigin = currentPluginInteractionOrigin(); + if (pluginOrigin) { + actor = { ...actor, agentId: pluginOrigin.agentId, runId: pluginOrigin.runId ?? undefined, userId: undefined }; + } if (dbOrTx === db) { const append = () => measureRunTransaction(db, "response", async (tx) => { @@ -13326,6 +13407,10 @@ export function issueService(db: Db) { if (issue.conversationAgentId && actor.userId && !(await instanceSettingsService(dbOrTx).getExperimental()).enableAgentChat) { throw unprocessable("Agent Chat is disabled in Experimental settings"); } + await assertAgentInteraction(dbOrTx, { + companyId: issue.companyId, actorAgentId: actor.agentId, operation: "comment", + issueId, texts: [body], lock: true, + }); const currentUserRedactionOptions = { // Keep every read on the caller's transaction connection. Re-entering // the outer pool here can deadlock when concurrent transactions fill @@ -13498,7 +13583,7 @@ export function issueService(db: Db) { : (options?.metadata ?? null), ); let comment: typeof issueComments.$inferSelect | null = null; - if (createdByRunId && !options?.proactiveChatMessage) { + if (createdByRunId && !options?.proactiveChatMessage && !(actor.userId && options?.clientRequestId)) { const existing = await dbOrTx .select() .from(issueComments) @@ -13978,6 +14063,10 @@ export function issueService(db: Db) { .for("update"); if (!lockedIssue) throw notFound("Issue not found"); } + await assertAgentInteraction(tx as unknown as Db, { + companyId: issue.companyId, actorAgentId: input.createdByAgentId, + operation: "edit", issueId: issue.id, lock: true, + }); const registeredRun = input.createdByRunId && isUuidLike(input.createdByRunId) ? await tx @@ -14239,7 +14328,7 @@ export function issueService(db: Db) { .where(eq(issueAttachments.id, id)) .then((rows) => rows[0] ?? null), - removeAttachment: async (id: string, options?: { mcpCleanup?: McpAttachmentCleanup | null }) => + removeAttachment: async (id: string, options?: { mcpCleanup?: McpAttachmentCleanup | null; actorAgentId?: string | null }) => db.transaction(async (tx) => { const existing = await tx .select({ @@ -14266,6 +14355,8 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) return null; + await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId: options?.actorAgentId, + operation: "edit", issueId: existing.issueId, lock: true }); await notifyIssueRecovery(tx, { companyId: existing.companyId }); await tx.delete(issueAttachments).where(eq(issueAttachments.id, id)); await tx.delete(assets).where(eq(assets.id, existing.assetId)); diff --git a/server/src/services/native-runtime/automatic-completion-reviews.ts b/server/src/services/native-runtime/automatic-completion-reviews.ts index 42be62845cc..b0093e5097d 100644 --- a/server/src/services/native-runtime/automatic-completion-reviews.ts +++ b/server/src/services/native-runtime/automatic-completion-reviews.ts @@ -3,9 +3,10 @@ import type { AutomaticCompletionReviewScope } from "../automatic-completion-rev import { DELIVERY_QUEUES } from "../delivery-work-notifications.js"; import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js"; import { registerScopedDeadlineWork } from "../scoped-deadline-work.js"; -import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import { completionContracts, + heartbeatRuns, issueThreadInteractions, issues, nativeRunFinalizations, @@ -52,6 +53,11 @@ function findAutomaticReviewCandidates(db: Db, options: { issueId?: string; comp eq(statusDecisions.runId, issueThreadInteractions.sourceRunId), ), ) + .innerJoin( + heartbeatRuns, + and(eq(heartbeatRuns.id, issueThreadInteractions.sourceRunId), + eq(heartbeatRuns.companyId, issueThreadInteractions.companyId)), + ) .innerJoin( workAssessments, and( @@ -72,7 +78,8 @@ function findAutomaticReviewCandidates(db: Db, options: { issueId?: string; comp and( eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"), - isNull(issueThreadInteractions.createdByAgentId), + or(isNull(issueThreadInteractions.createdByAgentId), + eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)), isNull(issueThreadInteractions.createdByUserId), eq(statusDecisions.applicationState, "applied"), eq(statusDecisions.toStatus, "in_review"), @@ -162,7 +169,10 @@ async function dismissCandidates(db: Db, candidates: Awaited { expect(gate).toMatchObject({ kind: "request_confirmation", status: "pending", - createdByAgentId: null, + createdByAgentId: fixture.agentId, createdByUserId: null, }); await db @@ -3629,11 +3629,19 @@ describe("native external-chat response wait", () => { } finally { await coordinator.stop(); vi.useRealTimers(); } }); - it.each(["telegram", "discord"] as const)( - "presents the exact %s response without resolving a pre-existing native completion review", - async (provider) => { + it.each([ + ["telegram", "agent"], + ["discord", "agent"], + ["telegram", "legacy_system"], + ] as const)( + "presents the exact %s response without resolving a pre-existing native completion review with %s provenance", + async (provider, provenance) => { const fixture = await seedWaitTurn(provider); const gate = await seedPriorCompletionReview(fixture); + if (provenance === "legacy_system") { + await db.update(issueThreadInteractions).set({ createdByAgentId: null }) + .where(eq(issueThreadInteractions.id, gate.id)); + } const selectedComment = await issueService(db).addComment( fixture.issueId, "Prepared exactly these files for this chat response.", @@ -3796,9 +3804,14 @@ describe("native external-chat response wait", () => { approvalId: approval!.id, }); } else if (kind === "agent_gate") { + const [otherAgent] = await db.insert(agents).values({ + companyId: fixture.companyId, + name: "Unrelated review author", + adapterType: "process", + }).returning(); await db .update(issueThreadInteractions) - .set({ createdByAgentId: fixture.agentId }) + .set({ createdByAgentId: otherAgent!.id }) .where(eq(issueThreadInteractions.id, gate.id)); } else if (kind === "new_attention") { await db.insert(issueThreadInteractions).values({ diff --git a/server/src/services/native-runtime/native-chat-review-presentation.ts b/server/src/services/native-runtime/native-chat-review-presentation.ts index 641c37c3b29..4d8434ccfe7 100644 --- a/server/src/services/native-runtime/native-chat-review-presentation.ts +++ b/server/src/services/native-runtime/native-chat-review-presentation.ts @@ -181,7 +181,6 @@ async function reviewPresentationEvidence( if ( !gate || gate.kind !== "request_confirmation" || - gate.createdByAgentId || gate.createdByUserId || !gate.sourceRunId || gate.sourceRunId === run.id || @@ -197,8 +196,16 @@ async function reviewPresentationEvidence( return null; const [origin, competingInteraction, competingApproval] = await Promise.all([ db - .select({ id: statusDecisions.id, decisionJson: statusDecisions.decisionJson }) + .select({ id: statusDecisions.id, decisionJson: statusDecisions.decisionJson, agentId: heartbeatRuns.agentId }) .from(statusDecisions) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, statusDecisions.runId), + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.nativeIssueId, input.issueId), + ), + ) .innerJoin( statusDecisionEffects, and( @@ -255,7 +262,8 @@ async function reviewPresentationEvidence( .limit(1) .then((rows) => rows[0]), ]); - if (!origin || competingInteraction || competingApproval) return null; + if (!origin || competingInteraction || competingApproval + || (gate.createdByAgentId !== null && gate.createdByAgentId !== origin.agentId)) return null; const reviewEffects = Array.isArray(origin.decisionJson.effects) ? origin.decisionJson.effects : []; const matchesReviewRequest = reviewEffects.some((value) => { const effect = record(value); diff --git a/server/src/services/native-runtime/native-routine-status.test.ts b/server/src/services/native-runtime/native-routine-status.test.ts index 415622ea2c3..8e74e967754 100644 --- a/server/src/services/native-runtime/native-routine-status.test.ts +++ b/server/src/services/native-runtime/native-routine-status.test.ts @@ -2,12 +2,13 @@ import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, pluginManagedResources, plugins, routineRuns, routines, workAssessments } from "@paperclipai/db"; +import { agentWakeupRequests, agents, companies, completionContracts, createDb, heartbeatRuns, issueComments, issueRelations, issueThreadInteractions, issues, nativeRunFinalizations, nativeRunResults, pluginManagedResources, plugins, routineRuns, routines, statusDecisionEffects, statusDecisions, workAssessments } from "@paperclipai/db"; import type { Db } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { routineService } from "../routines.js"; import { commitNativeStatusDecision } from "./status-decision-committer.js"; import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js"; +import { INTERACTION_DELIVERY_DENIED } from "../agent-interaction-delivery.js"; const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_ROUTINE_STATUS_TEST_DATABASE_URL"); const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport(); @@ -73,6 +74,66 @@ if (!support.supported) console.warn(`Skipping native routine status tests: ${su const replay = await commit(f, status); expect(replay.replayed).toBe(true); expect(replay.decision.id).toBe(first.decision.id); expect((await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId)))[0]).toEqual(saved); }); + it("retains the native decision author when requesting a protected agent reviewer", async () => { + const f = await seed(), reviewer = randomUUID(); + await db.insert(agents).values({ id: reviewer, companyId, name: "Protected reviewer", adapterType: "process", status: "active", + permissions: { interactions: { canReceiveAssignmentsFromAgents: false } } }); + const requestReview = () => commitNativeStatusDecision({ db, companyId, issueId: f.issueId, runId: f.runId, assessmentId: f.assessmentId, + priorStatus: "in_progress", priorStatusVersion: f.priorStatusVersion, priorDecisionId: f.priorDecisionId, + decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "preserve", toStatus: "in_progress", + reasonCode: "review_requested", unblockDescriptor: null, effects: [{ kind: "bind_reviewer", ownerAgentId: reviewer, + ownerUserId: null, prompt: "Review this work" }] }, + }); + await expect(requestReview()).rejects.toMatchObject({ status: 403, details: expect.objectContaining({ actorAgentId: agentId, + permission: "canReceiveAssignmentsFromAgents" }) }); + expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, reviewer))).toEqual([]); + expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, f.issueId))).toEqual([]); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("in_progress"); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]) + .toMatchObject({ phase: "terminal_failure", failureCode: "agent_interaction_denied", nextAttemptAt: null }); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("running"); + await db.update(agents).set({ permissions: {} }).where(eq(agents.id, reviewer)); + await expect(requestReview()).rejects.toMatchObject({ status: 403, + details: expect.objectContaining({ permission: "canReceiveAssignmentsFromAgents" }) }); + }); + it.each([false, true])("commits child completion while permanently refusing its protected parent report (dependency: %s)", async dependency => { + const f = await seed(), parentId = randomUUID(), parentAgentId = randomUUID(); + const report = "PRIVATE_REPORT_TEXT: agent-authored completion instructions"; + await db.insert(agents).values({ id: parentAgentId, companyId, name: "Protected parent", adapterType: "process", status: "active", + permissions: { interactions: { canReceiveCommentsFromAgents: false } } }); + await db.insert(issues).values({ id: parentId, companyId, title: "Parent task", status: dependency ? "blocked" : "in_progress", assigneeAgentId: parentAgentId }); + await db.update(issues).set({ parentId }).where(eq(issues.id, f.issueId)); + await db.update(nativeRunResults).set({ resultJson: { result: { summary: report } } }).where(eq(nativeRunResults.runId, f.runId)); + if (dependency) await db.insert(issueRelations).values({ companyId, issueId: f.issueId, relatedIssueId: parentId, type: "blocks" }); + + const completed = await commit(f, "done"); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done"); + expect((await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId)))[0].status).toBe("completed"); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]) + .toMatchObject({ phase: "committed", failureCode: null, nextAttemptAt: null }); + expect((await db.select().from(issues).where(eq(issues.id, parentId)))[0].status).toBe(dependency ? "blocked" : "in_progress"); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, parentId))).toEqual([]); + + const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, parentAgentId)).orderBy(agentWakeupRequests.id); + const reportWake = wakes.find(wake => wake.reason === "issue_children_completed"); + expect(reportWake).toMatchObject({ status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: expect.any(Date), runId: null, + payload: { interactionOrigin: { actorType: "agent", actorId: agentId, operation: "comment" }, + interactionPermissionDenials: { assignment: { permission: "canReceiveCommentsFromAgents", targetAgentId: parentAgentId } } } }); + expect(JSON.stringify(wakes)).not.toContain(report); + expect(wakes).toHaveLength(dependency ? 2 : 1); + const dependencyWake = wakes.find(wake => wake.reason === "issue_blockers_resolved"); + if (dependency) expect(dependencyWake).toMatchObject({ status: "queued", requestedByActorType: "system", error: null, + payload: { resolvedBlockerIssueId: f.issueId, blockerIssueIds: [f.issueId], interactionOrigin: { actorType: "system" } } }); + + const effects = await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.decisionId, completed.decision.id)); + const reportEffect = effects.find(effect => effect.targetId === reportWake!.id); + expect(reportEffect).toMatchObject({ deliveryState: "skipped", lastError: INTERACTION_DELIVERY_DENIED, deliveredAt: null, nextAttemptAt: null }); + expect(JSON.stringify(reportEffect)).not.toContain(report); + await db.update(agents).set({ permissions: {} }).where(eq(agents.id, parentAgentId)); + expect((await commit(f, "done")).replayed).toBe(true); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, parentAgentId)).orderBy(agentWakeupRequests.id)).toEqual(wakes); + }); it.each(["ordinary", "wrong-invocation", "wrong-issue", "foreign-company"])("does not settle an invocation with %s custody", async mode => { const f = await seed(mode); const [before] = await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId)); await commit(f, "done"); diff --git a/server/src/services/native-runtime/obsolete-policy-reviews.ts b/server/src/services/native-runtime/obsolete-policy-reviews.ts index 5233b37559c..1e1e656e844 100644 --- a/server/src/services/native-runtime/obsolete-policy-reviews.ts +++ b/server/src/services/native-runtime/obsolete-policy-reviews.ts @@ -2,9 +2,9 @@ import type { ObsoletePolicyReviewScope } from "../obsolete-policy-review-notifi import { DELIVERY_QUEUES } from "../delivery-work-notifications.js"; import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js"; import { registerScopedDeadlineWork } from "../scoped-deadline-work.js"; -import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; import { - approvals, issueApprovals, issueThreadInteractions, issues, + approvals, heartbeatRuns, issueApprovals, issueThreadInteractions, issues, nativeRunFinalizations, statusDecisionEffects, statusDecisions, workAssessments, type Db, } from "@paperclipai/db"; @@ -37,6 +37,8 @@ function findObsoletePolicyReviews(db: Db, options: { runIds?: string[]; scope?: eq(statusDecisions.issueId, issueThreadInteractions.issueId), eq(statusDecisions.runId, issueThreadInteractions.sourceRunId), )) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, issueThreadInteractions.sourceRunId), + eq(heartbeatRuns.companyId, issueThreadInteractions.companyId))) .innerJoin(workAssessments, and( eq(workAssessments.id, statusDecisions.assessmentId), eq(workAssessments.companyId, statusDecisions.companyId), @@ -45,7 +47,8 @@ function findObsoletePolicyReviews(db: Db, options: { runIds?: string[]; scope?: .where(and( eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"), - isNull(issueThreadInteractions.createdByAgentId), + or(isNull(issueThreadInteractions.createdByAgentId), + eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)), isNull(issueThreadInteractions.createdByUserId), eq(statusDecisions.applicationState, "applied"), eq(statusDecisions.reasonCode, "completion_review_required"), @@ -97,7 +100,10 @@ async function dismissCandidates(db: Db, candidates: Awaited { const agentReview = (await db.select().from(issueThreadInteractions) .where(eq(issueThreadInteractions.issueId, issueId))) .find((entry) => entry.addresseeAgentId === reviewerAgentId)!; + await issueThreadInteractionService(db).sweepSupersededPendingRequestConfirmations(); + expect(await db.select({ status: issueThreadInteractions.status, createdByAgentId: issueThreadInteractions.createdByAgentId }) + .from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))) + .toEqual([ + { status: "pending", createdByAgentId: identity.agentId }, + { status: "pending", createdByAgentId: identity.agentId }, + ]); expect(reviewWakes[0]).toMatchObject({ companyId: identity.companyId, status: "queued", reason: "native_completion_review", payload: { issueId, nativeReviewInteractionId: agentReview.id }, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index ecf9cfdcf07..c3ed5300321 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,5 @@ +import { assertAgentInteraction } from "../agent-interaction-policy.js"; +import { authorizationDeniedDetails } from "../authorization.js"; import { budgetServiceInTransaction } from "../budgets.js"; import { resolveTaskWatchdogMutationScope, taskWatchdogScopeAllowsIssueMutation } from "../task-watchdog-scope.js"; import { resolveNewTaskParent } from "../issue-hierarchy.js"; @@ -1215,7 +1217,7 @@ export class PaperclipRunnerToolAuthority { if (!this.definitions().some(tool => tool.name === DOWNLOAD_ARTIFACT) || current.issue.workMode !== "standard") throw forbidden("Artifact installation is unavailable to this run or provider"); const decision = await authorizationService(this.db).decide({ actor: this.#privacyActor(current.run), action: "issue:mutate", resource: { type: "issue", companyId: this.binding.companyId, issueId: current.issue.id } }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); const freshAsset = await assetService(this.db).getById(input.artifactId); if (!freshAsset || freshAsset.companyId !== asset.companyId || freshAsset.objectKey !== asset.objectKey || freshAsset.byteSize !== input.byteSize || freshAsset.sha256 !== input.sha256 || !(await canActorReadAssetContent(this.db, { ...this.#privacyActor(current.run), keyScope: this.#apiScope(current) }, freshAsset))) throw forbidden("Artifact is not available to this actor"); @@ -1279,7 +1281,7 @@ export class PaperclipRunnerToolAuthority { if (!this.#imageGenerationAdvertised(current.issue.workMode)) throw forbidden("Image generation is unavailable to this run or provider"); const decision = await authorizationService(this.db).decide({ actor: this.#privacyActor(current.run), action: "issue:mutate", resource: { type: "issue", companyId: this.binding.companyId, issueId: current.issue.id } }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); // #boundContext checks company and agent budgets; spending also needs the task's project. if (current.issue.projectId && await budgetServiceInTransaction(this.db).getInvocationBlock(this.binding.companyId, this.binding.agentId, { issueId: current.issue.id, projectId: current.issue.projectId })) throw forbidden("Company or budget authority unavailable"); @@ -1940,7 +1942,10 @@ export class PaperclipRunnerToolAuthority { type: "issue", companyId: this.binding.companyId, issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId, assigneeAgentId: parent.assigneeAgentId, assigneeUserId: parent.assigneeUserId, status: parent.status, }, scope: { issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId } }); - if (!decision.allowed) return decision.explanation; + if (!decision.allowed) { + if (decision.interactionDenial) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + return decision.explanation; + } } return null; }; @@ -2014,7 +2019,7 @@ export class PaperclipRunnerToolAuthority { resource: { type: "issue", companyId: this.binding.companyId, ...scope }, scope, }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" }); if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId); const project = projectId @@ -2231,7 +2236,7 @@ export class PaperclipRunnerToolAuthority { parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId, assigneeUserId: action === "tasks:assign" ? assigneeUserId : target.assigneeUserId, status: target.status, }, scope: { issueId: taskId, projectId: target.projectId, parentIssueId: target.parentId, assigneeAgentId, assigneeUserId } }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" }); if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId); @@ -2250,6 +2255,13 @@ export class PaperclipRunnerToolAuthority { throw new Error("paperclip_runner_reassignment_conflict: refresh search_tasks before deciding again"); } }; + const assertReassignmentComment = async (tx: Db, target: typeof issues.$inferSelect) => { + if (target.assigneeAgentId === assigneeAgentId && target.assigneeUserId === assigneeUserId) return; + await assertAgentInteraction(tx, { companyId: this.binding.companyId, actorAgentId: this.binding.agentId, + operation: "comment", issueId: taskId, texts: [reason], lock: true }); + await assertAgentInteraction(tx, { companyId: this.binding.companyId, actorAgentId: this.binding.agentId, + operation: "comment", targetAgentId: assigneeAgentId, delivery: true, lock: true }); + }; // Stop outside row locks: the runtime needs those locks to acknowledge cancellation. // A second locked check below prevents any intervening ownership/version change. const prepared = receiptOnly ? null : await this.db.transaction(async (tx) => { @@ -2258,6 +2270,7 @@ export class PaperclipRunnerToolAuthority { const target = await authorize(tx as unknown as Db, context.run); if (await priorReceipt(tx as unknown as Db)) return null; validate(target); + await assertReassignmentComment(tx as unknown as Db, target); return target; }); // Cancellation and ownership commit cannot share locks. If the second @@ -2319,6 +2332,7 @@ export class PaperclipRunnerToolAuthority { if (run && ["running", "queued", "scheduled_retry"].includes(run.status)) throw new Error("paperclip_runner_reassignment_stop_unconfirmed"); } const changed = target.assigneeAgentId !== assigneeAgentId || target.assigneeUserId !== assigneeUserId; + await assertReassignmentComment(tx, target); const updated = changed ? await issueService(tx).update(taskId, { companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId, status: target.status === "in_progress" ? "todo" : target.status, @@ -2420,7 +2434,7 @@ export class PaperclipRunnerToolAuthority { type: "issue", companyId: this.binding.companyId, issueId: target.id, status: target.status, assigneeAgentId: target.assigneeAgentId, assigneeUserId: target.assigneeUserId, } }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } await assertCanManageIssueMonitor(access, { actor }, target.companyId, target.assigneeAgentId, true); if (target.assigneeAgentId !== this.binding.agentId || target.assigneeUserId || @@ -3070,6 +3084,7 @@ export class PaperclipRunnerToolAuthority { }; return this.db.transaction(async tx => { if (operationId === "create_task") { + await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, this.binding.companyId)).for("no key update"); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${this.binding.companyId}`}, 0))`); } return mutate(tx); @@ -3133,7 +3148,7 @@ export class PaperclipRunnerToolAuthority { projectId: context.issue.projectId, parentIssueId: context.issue.parentId, status: context.issue.status, assigneeAgentId: context.issue.assigneeAgentId, assigneeUserId: context.issue.assigneeUserId }, }); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); await this.binding.assertBridgeAuthority?.(tx); } await assertNoCaseHandoffMutation(tx, this.binding.runId, this.binding.issueId); diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index 29089f8ddf4..9370ad3d8a9 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -1,4 +1,7 @@ import { notifyNativeStatusWakeWork } from "../deferred-execution-notifications.js"; +import { assertAgentInteraction } from "../agent-interaction-policy.js"; +import { forbidden, HttpError } from "../../errors.js"; +import { agentInteractionDeliveryDenial, agentWakeInteractionTexts, INTERACTION_DELIVERY_DENIED, withInteractionDeliveryOrigin } from "../agent-interaction-delivery.js"; import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js"; import { lockCurrentNativeWorkAssessmentEvidence } from "./work-assessments.js"; import { hasNativeWorkspaceFinalizationOwner } from "./native-workspace-finalization-recovery-state.js"; @@ -25,7 +28,7 @@ import { readNativePlanWait, type NativePlanWaitSource } from "./native-plan-wai import { activeIssueInteractionCondition } from "../issue-question-context.js"; import { isConversation } from "../agent-conversations.js"; import { hasPendingNativeChildCompletion, type NativeChildCompletionRecipient } from "./native-child-completion-delivery.js"; -import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import type { CreateIssueThreadInteraction } from "@paperclipai/shared"; import { @@ -208,6 +211,7 @@ export type NativeMaterializedStatusEffect = { targetType: string; targetId: string | null; payload: Record; + deliveryState?: "skipped"; }; function record(value: unknown): Record { @@ -463,6 +467,8 @@ async function enqueueWake(input: { idempotencyKey: string; payload: Record; contextSnapshot?: Record; + originRunId?: string; + originOperation?: "assign" | "comment"; }) { const existing = await input.tx .select({ id: agentWakeupRequests.id }) @@ -471,18 +477,32 @@ async function enqueueWake(input: { and( eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey), - inArray(agentWakeupRequests.status, [ + or(inArray(agentWakeupRequests.status, [ "queued", "deferred_issue_execution", "claimed", "completed", - ]), + ]), eq(agentWakeupRequests.error, INTERACTION_DELIVERY_DENIED)), ), ) .limit(1) .then((rows) => rows[0] ?? null); if (existing) return existing.id; - await notifyNativeStatusWakeWork(input.tx, { companyId: input.companyId }); + const [origin] = input.originRunId ? await input.tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, input.originRunId), eq(heartbeatRuns.companyId, input.companyId))) : []; + const actorType = origin ? "agent" : "system"; + const actorId = origin?.agentId ?? "native-status-committer"; + const originPayload = withInteractionDeliveryOrigin({}, actorType, actorId, { actorType, actorId }); + if (originPayload.interactionOrigin) { + originPayload.interactionOrigin = { ...record(originPayload.interactionOrigin), operation: input.originOperation ?? "assign" }; + } + const denial = origin ? await agentInteractionDeliveryDenial(input.tx, { + companyId: input.companyId, actorAgentId: origin.agentId, targetAgentId: input.agentId, issueId: input.issueId, + operation: input.originOperation ?? "assign", texts: agentWakeInteractionTexts(input.payload), delivery: true, lock: true, + }) : null; + const refusedParentReport = denial && input.reason === "issue_children_completed" && input.originOperation === "comment"; + if (denial && !refusedParentReport) throw forbidden("Agent interaction is not permitted.", denial); + if (!denial) await notifyNativeStatusWakeWork(input.tx, { companyId: input.companyId }); const inserted = await input.tx .insert(agentWakeupRequests) .values({ @@ -491,10 +511,18 @@ async function enqueueWake(input: { source: "automation", triggerDetail: "system", reason: input.reason, - payload: { + ...(denial ? { status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: new Date() } : {}), + payload: denial ? { + issueId: input.issueId, + taskId: input.issueId, + sourceRunId: input.originRunId, + ...originPayload, + interactionPermissionDenials: { assignment: denial }, + } : { issueId: input.issueId, taskId: input.issueId, ...input.payload, + ...originPayload, _paperclipWakeContext: { issueId: input.issueId, taskId: input.issueId, @@ -514,6 +542,13 @@ async function enqueueWake(input: { return inserted.id; } +async function refusedWakePolicy(tx: Db, companyId: string, wakeId: string) { + const [wake] = await tx.select({ error: agentWakeupRequests.error, payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests).where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId))); + return wake?.error === INTERACTION_DELIVERY_DENIED + ? record(record(wake.payload).interactionPermissionDenials).assignment : null; +} + async function validateGovernanceGate( tx: Db, input: { @@ -578,6 +613,7 @@ async function materializeDecisionEffect(input: { companyId: string; issue: typeof issues.$inferSelect; runId: string; + actorAgentId: string | null; decisionId: string; effect: NativeStatusEffect; failpoint?: NativeStatusCommitFailpoint; @@ -625,7 +661,7 @@ async function materializeDecisionEffect(input: { ], }, }, - { systemId: "native-status-committer", runId: input.runId }, + { agentId: input.actorAgentId, systemId: "native-status-committer", runId: input.runId }, ); return { effectKind: effect.kind, @@ -673,7 +709,8 @@ async function materializeDecisionEffect(input: { const interaction = await issueThreadInteractionService(input.tx).create( input.issue, reviewInput, - { systemId: "native-status-committer", runId: input.runId }, + { agentId: input.actorAgentId, systemId: "native-status-committer", runId: input.runId }, + { supersedePendingSiblingInteractions: false }, ); // The card and its next actor commit together. The post-commit dispatcher // revalidates this exact review before granting a scoped reviewer run. @@ -684,6 +721,7 @@ async function materializeDecisionEffect(input: { const reviewerWakeId = effect.ownerAgentId && interaction.effectiveResolverPolicy !== "human_only" ? await enqueueWake({ tx: input.tx, + originRunId: input.runId, companyId: input.companyId, issueId: input.issue.id, agentId: effect.ownerAgentId, @@ -709,6 +747,7 @@ async function materializeDecisionEffect(input: { if (effect.kind === "notify_owner") { const wakeId = await enqueueWake({ tx: input.tx, + originRunId: input.runId, companyId: input.companyId, issueId: input.issue.id, agentId: effect.agentId, @@ -736,7 +775,7 @@ async function materializeDecisionEffect(input: { status: "todo", priority: input.issue.priority, assigneeAgentId: effect.agentId, - createdByAgentId: input.issue.assigneeAgentId, + createdByAgentId: input.actorAgentId, workMode: "standard", idempotencyKey: `native-delegation:${input.decisionId}`, inheritExecutionWorkspaceFromIssueId: input.issue.id, @@ -786,6 +825,7 @@ async function materializeDecisionEffect(input: { if (effect.continuationKind === "monitor") throw new Error("Monitor delivery belongs to the issue monitor scheduler"); const wakeId = await enqueueWake({ tx: input.tx, + originRunId: input.runId, companyId: input.companyId, issueId: input.issue.id, agentId: effect.agentId, @@ -810,6 +850,9 @@ async function materializeDecisionEffect(input: { } if (effect.kind === "bind_blocker") { failAt("blocker_materialization", input.failpoint); + await assertAgentInteraction(input.tx, { companyId: input.companyId, actorAgentId: input.actorAgentId, + operation: "assign", issueId: input.issue.id, targetAgentId: typeof effect.owner === "object" ? effect.owner.agentId : null, + texts: [effect.action], lock: true }); const [bound] = await input.tx .update(issues) .set({ @@ -828,6 +871,7 @@ async function materializeDecisionEffect(input: { if (effect.owner !== "board") { wakeId = await enqueueWake({ tx: input.tx, + originRunId: input.runId, companyId: input.companyId, issueId: input.issue.id, agentId: effect.owner.agentId, @@ -863,6 +907,7 @@ async function materializeDecisionEffect(input: { agentId: effect.agentId, reason: "issue_status_changed", idempotencyKey: `native-status:${input.decisionId}:retry`, + originRunId: input.runId, payload: { nativeDecisionId: input.decisionId, retryCause: effect.cause, @@ -1677,6 +1722,10 @@ export async function commitNativeStatusDecision(input: { .then((rows) => rows[0] ?? null); if (!coordinator) throw new Error("native_finalization_coordinator_missing"); + if (coordinator.failureCode === "agent_interaction_denied") { + throw forbidden("This native interaction was permanently refused by agent permissions.", + record(record(coordinator.failureDetail).interactionDenial)); + } const issue = await tx .select() .from(issues) @@ -1690,6 +1739,8 @@ export async function commitNativeStatusDecision(input: { .limit(1) .then((rows) => rows[0] ?? null); if (!issue) throw new NativeStatusRaceError(); + const [originRun] = await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId))); if (input.requireReassessmentSource) { const expected = input.requireReassessmentSource; const now = Date.now(); @@ -2260,6 +2311,7 @@ export async function commitNativeStatusDecision(input: { issue, runId: input.runId, decisionId: decisionRow.id, + actorAgentId: originRun?.agentId ?? null, effect, failpoint: input.failpoint, preMaterializedEffects, @@ -2291,7 +2343,7 @@ export async function commitNativeStatusDecision(input: { statusVersion: input.priorStatusVersion + 1, lastStatusDecisionId: decisionRow.id, unblockDescriptor: input.decision.unblockDescriptor, - actorAgentId: null, + actorAgentId: originRun?.agentId ?? null, actorUserId: null, }, tx, @@ -2400,6 +2452,8 @@ export async function commitNativeStatusDecision(input: { : null; const wakeId = await enqueueWake({ tx: tx as unknown as Db, + originRunId: isCompletedChildParent ? input.runId : undefined, + originOperation: isCompletedChildParent ? "comment" : undefined, companyId: input.companyId, issueId: dependent.id, agentId: dependent.assigneeAgentId, @@ -2414,22 +2468,35 @@ export async function commitNativeStatusDecision(input: { }, contextSnapshot: childCompletionContext ?? undefined, }); + const reportDenial = isCompletedChildParent ? await refusedWakePolicy(tx as unknown as Db, input.companyId, wakeId) : null; materialized.push({ effectKind: isCompletedChildParent ? "parent_dependency_wake" : "dependency_wake", targetType: "agent_wakeup_request", targetId: wakeId, + ...(reportDenial ? { deliveryState: "skipped" as const } : {}), payload: { dependentIssueId: dependent.id, resolvedBlockerIssueId: input.issueId, - ...(childCompletionContext ?? {}), + ...(reportDenial ? { interactionPermissionDenials: { assignment: reportDenial } } : childCompletionContext ?? {}), }, }); + if (reportDenial) { + const dependencyWakeId = await enqueueWake({ tx: tx as unknown as Db, companyId: input.companyId, + issueId: dependent.id, agentId: dependent.assigneeAgentId, reason: "issue_blockers_resolved", + idempotencyKey: buildIssueBlockersResolvedWakeIdempotencyKey({ dependentIssueId: dependent.id, resolvedBlockerIssueId: input.issueId }), + payload: { resolvedBlockerIssueId: input.issueId, blockerIssueIds: dependent.blockerIssueIds }, + }); + materialized.push({ effectKind: "dependency_wake", targetType: "agent_wakeup_request", targetId: dependencyWakeId, + payload: { dependentIssueId: dependent.id, resolvedBlockerIssueId: input.issueId } }); + } } if (parent && !parentIsDependent) { const wakeId = await enqueueWake({ tx: tx as unknown as Db, + originRunId: input.runId, + originOperation: "comment", companyId: input.companyId, issueId: parent.id, agentId: parent.assigneeAgentId, @@ -2452,17 +2519,21 @@ export async function commitNativeStatusDecision(input: { childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, }); + const reportDenial = await refusedWakePolicy(tx as unknown as Db, input.companyId, wakeId); materialized.push({ effectKind: "parent_wake", targetType: "agent_wakeup_request", targetId: wakeId, + ...(reportDenial ? { deliveryState: "skipped" as const } : {}), payload: { parentIssueId: parent.id, completedChildIssueId: input.issueId, ...childCompletionDelivery, - childIssueSummaries: parent.childIssueSummaries, - onboardingCompletion: parent.onboardingCompletion, - childIssueSummaryTruncated: parent.childIssueSummaryTruncated, + ...(reportDenial ? { interactionPermissionDenials: { assignment: reportDenial } } : { + childIssueSummaries: parent.childIssueSummaries, + onboardingCompletion: parent.onboardingCompletion, + childIssueSummaryTruncated: parent.childIssueSummaryTruncated, + }), }, }); } @@ -2481,9 +2552,10 @@ export async function commitNativeStatusDecision(input: { targetId: effect.targetId, idempotencyKey: `native-status:${decisionRow.id}:${index + 1}`, payload: effect.payload, - deliveryState: "delivered", + deliveryState: effect.deliveryState ?? "delivered", attemptCount: 1, - deliveredAt: new Date(), + deliveredAt: effect.deliveryState === "skipped" ? null : new Date(), + lastError: effect.deliveryState === "skipped" ? INTERACTION_DELIVERY_DENIED : null, }) .onConflictDoNothing(); } @@ -2540,6 +2612,22 @@ export async function commitNativeStatusDecision(input: { issue: updated, replayed: false, }; + }).catch(async (error: unknown) => { + if (error instanceof HttpError && error.status === 403 && record(error.details).code === "agent_interaction_denied") { + await input.db.transaction(async tx => { + const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and( + eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.companyId, input.companyId), + eq(nativeRunFinalizations.issueId, input.issueId))).for("update"); + if (!coordinator || coordinator.phase === "committed" || coordinator.failureCode === "agent_interaction_denied") return; + await notifyNativeFinalizationWork(tx, { companyId: input.companyId, runId: input.runId }); + await tx.update(nativeRunFinalizations).set({ phase: "terminal_failure", failureCode: "agent_interaction_denied", + failureDetail: { ...record(coordinator.failureDetail), interactionDenial: error.details, + recoveryOwner: { kind: "none", reason: "agent_interaction_denied" } }, + nextAttemptAt: null, leaseOwner: null, leaseExpiresAt: null, updatedAt: new Date() }) + .where(and(eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.companyId, input.companyId))); + }); + } + throw error; }); for (const publication of publications) publishActivity(publication); diff --git a/server/src/services/native-runtime/superseded-external-review-requests.ts b/server/src/services/native-runtime/superseded-external-review-requests.ts index 9697b436ed3..63013bf9ad7 100644 --- a/server/src/services/native-runtime/superseded-external-review-requests.ts +++ b/server/src/services/native-runtime/superseded-external-review-requests.ts @@ -1,5 +1,5 @@ import { notifyAttentionRetentionWork } from "../retention-work-notifications.js"; -import { and, eq, isNull, ne, sql } from "drizzle-orm"; +import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; import { completionContracts, heartbeatRuns, issues, issueThreadInteractions, statusDecisionEffects, statusDecisions, workAssessments, type Db } from "@paperclipai/db"; import { hasPublishedGitHubReview } from "../chat-channels/github/review-wait.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; @@ -47,7 +47,8 @@ export async function findSupersededExternalReviewRequests(db: Db, binding: Revi eq(issueThreadInteractions.companyId, binding.companyId), eq(issueThreadInteractions.issueId, binding.issueId), eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"), ne(issueThreadInteractions.sourceRunId, binding.runId), - isNull(issueThreadInteractions.createdByAgentId), isNull(issueThreadInteractions.createdByUserId), + or(isNull(issueThreadInteractions.createdByAgentId), eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)), + isNull(issueThreadInteractions.createdByUserId), isNull(issueThreadInteractions.addresseeAgentId), eq(issueThreadInteractions.effectiveResolverPolicy, "human_only"), sql`exists (select 1 from jsonb_array_elements(${statusDecisions.decisionJson}->'effects') effect where effect->>'kind' = 'bind_reviewer' diff --git a/server/src/services/native-runtime/unread-steered-messages.ts b/server/src/services/native-runtime/unread-steered-messages.ts index fad8481e29b..6079b2019e2 100644 --- a/server/src/services/native-runtime/unread-steered-messages.ts +++ b/server/src/services/native-runtime/unread-steered-messages.ts @@ -2,6 +2,7 @@ import { and, asc, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; import { heartbeatRunEvents, issueComments, issueThreadInteractions, type Db } from "@paperclipai/db"; import { unreadSteeredMessageIds } from "../../vendor/paperclip-runner/index.js"; import { queuedInteractionSteeringBody } from "../queued-interaction-response.js"; +import { filterRunMessageInteractionDelivery } from "../agent-interaction-delivery.js"; const kind = sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind'`; @@ -38,6 +39,7 @@ export async function readUnreadSteeredMessages( if (ids.length === 0) return []; const uuids = ids.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)); if (uuids.length === 0) return []; + const allowedIds = await filterRunMessageInteractionDelivery(db, { ...input, commentIds: uuids }); const texts = new Map(); const comments = await db .select({ id: issueComments.id, body: issueComments.body }) @@ -62,6 +64,7 @@ export async function readUnreadSteeredMessages( for (const interaction of interactions) texts.set(interaction.id, queuedInteractionSteeringBody(interaction)); } return ids.flatMap((id) => { + if (!allowedIds.has(id)) return []; const text = texts.get(id); return text === undefined ? [] : [{ id, text }]; }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 1c7030036ea..cbabcf674cf 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1,3 +1,5 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; import { projectGitAuthorizationService } from "./project-git-authorization.js"; import { createLiveEventDelivery } from "../realtime/live-event-delivery.js"; import { createLiveRunEventRedactor } from "./live-run-event-redaction.js"; @@ -3103,6 +3105,9 @@ export function buildHostServices( await ensurePluginAvailableForCompany(companyId); const agent = await agents.getById(params.agentId); requireInCompany("Agent", agent, companyId); + const origin = currentPluginInteractionOrigin(); + await assertAgentInteraction(db, { companyId, actorAgentId: origin?.agentId, + operation: "assign", targetAgentId: params.agentId, texts: params.prompt ? [params.prompt] : [] }); const run = await heartbeat.wakeup(params.agentId, { source: "automation", triggerDetail: "system", @@ -3116,8 +3121,8 @@ export function buildHostServices( pluginKey, }, }, - requestedByActorType: "system", - requestedByActorId: pluginId, + requestedByActorType: origin ? "agent" : "system", + requestedByActorId: origin?.agentId ?? pluginId, }); if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); return { runId: run.id }; @@ -3397,15 +3402,14 @@ export function buildHostServices( const policy = params.policy ? sanitizeRecord(params.policy) : null; if (params.resourceType === "agent") { const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId); - const permissions = agent.permissions && typeof agent.permissions === "object" - ? { ...(agent.permissions as Record) } - : {}; - if (policy) permissions.authorizationPolicy = policy; - else delete permissions.authorizationPolicy; - await db - .update(agentsTable) - .set({ permissions, updatedAt: new Date() }) - .where(eq(agentsTable.id, agent.id)); + await db.transaction(async tx => { + await assertAgentInteraction(tx, { companyId, operation: "configure", targetAgentId: agent.id, lock: "update" }); + await tx.update(agentsTable) + .set({ permissions: policy + ? sql`jsonb_set(${agentsTable.permissions}, '{authorizationPolicy}', ${JSON.stringify(policy)}::jsonb)` + : sql`${agentsTable.permissions} - 'authorizationPolicy'`, updatedAt: new Date() }) + .where(eq(agentsTable.id, agent.id)); + }); } else if (params.resourceType === "project") { const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId); const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" @@ -3418,16 +3422,18 @@ export function buildHostServices( .set({ executionWorkspacePolicy, updatedAt: new Date() }) .where(eq(projectsTable.id, project.id)); } else if (params.resourceType === "issue") { - const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId); - const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object" - ? { ...(issue.executionPolicy as Record) } - : {}; - if (policy) executionPolicy.authorizationPolicy = policy; - else delete executionPolicy.authorizationPolicy; - await db - .update(issuesTable) - .set({ executionPolicy, updatedAt: new Date() }) - .where(eq(issuesTable.id, issue.id)); + await db.transaction(async tx => { + const [locked] = await tx.select().from(issuesTable).where(and( + eq(issuesTable.id, params.resourceId), eq(issuesTable.companyId, companyId))).for("update"); + const issue = requireInCompany("Issue", locked ?? null, companyId); + await assertAgentInteraction(tx, { companyId, operation: "edit", issueId: issue.id, lock: true }); + const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object" + ? { ...(issue.executionPolicy as Record) } : {}; + if (policy) executionPolicy.authorizationPolicy = policy; + else delete executionPolicy.authorizationPolicy; + await tx.update(issuesTable).set({ executionPolicy, updatedAt: new Date() }) + .where(and(eq(issuesTable.id, issue.id), eq(issuesTable.companyId, companyId))); + }); } else { const company = await companies.getById(params.resourceId); if (!company || company.id !== companyId) throw new Error("Company not found"); diff --git a/server/src/services/plugin-interaction-context.ts b/server/src/services/plugin-interaction-context.ts new file mode 100644 index 00000000000..4c092c89d8f --- /dev/null +++ b/server/src/services/plugin-interaction-context.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export interface PluginInteractionOrigin { + companyId: string; + agentId: string; + runId?: string | null; +} + +const origins = new AsyncLocalStorage | undefined>(); + +export function withPluginInteractionOrigin(origin: PluginInteractionOrigin | undefined, fn: () => T): T { + return origins.run(origin ? Object.freeze({ ...origin }) : undefined, fn); +} + +export function currentPluginInteractionOrigin(): Readonly | undefined { + return origins.getStore(); +} diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 38ed65d8a53..fb2ac061a0b 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -1,3 +1,4 @@ +import { withPluginInteractionOrigin } from "./plugin-interaction-context.js"; import { pluginIdleManifestGeneration, type IdlePluginCatalogEntry } from "./plugin-idle-catalog.js"; import { createHash, randomUUID } from "node:crypto"; import { PluginWorkerUnavailableError } from "./plugin-worker-unavailable-error.js"; @@ -1266,6 +1267,19 @@ export function createPluginWorkerHandle( ): PluginInvocationScope | null { if (!isRecord(params)) return null; + if (method === "executeTool" && isRecord(params.runContext)) { + const companyId = readNonEmptyString(params.runContext.companyId); + const agentId = readNonEmptyString(params.runContext.agentId); + const runId = readNonEmptyString(params.runContext.runId); + return companyId ? { companyId, ...(agentId ? { agentOrigin: { agentId, runId } } : {}) } : null; + } + if (method === "performAction" && isRecord(params.actorContext)) { + const companyId = readNonEmptyString(params.actorContext.companyId); + const agentId = params.actorContext.type === "agent" ? readNonEmptyString(params.actorContext.agentId) : null; + const runId = readNonEmptyString(params.actorContext.runId); + return companyId ? { companyId, ...(agentId ? { agentOrigin: { agentId, runId } } : {}) } : null; + } + const directCompanyId = readNonEmptyString(params.companyId); if (directCompanyId) { if (method === "resourceLifecycle" && params.resourceType === "project" && readNonEmptyString(params.resourceId)) { @@ -1288,16 +1302,6 @@ export function createPluginWorkerHandle( return { companyId: directCompanyId }; } - if (method === "performAction" && isRecord(params.actorContext)) { - const companyId = readNonEmptyString(params.actorContext.companyId); - return companyId ? { companyId } : null; - } - - if (method === "executeTool" && isRecord(params.runContext)) { - const companyId = readNonEmptyString(params.runContext.companyId); - return companyId ? { companyId } : null; - } - if (method === "onEvent" && isRecord(params.event)) { const companyId = readNonEmptyString(params.event.companyId); return companyId ? { companyId } : null; @@ -2843,6 +2847,9 @@ export function createPluginWorkerHandle( (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, ); if (!invocationId) { + if (Array.from(activeInvocations.values()).some(entry => entry.scope.agentOrigin)) { + return { invalidInvocationScope: true }; + } // No host-issued invocation is being echoed. This is a genuinely // proactive worker→host call (timer/loop). If it references a company the // plugin is authorized to act on proactively, resolve it to that @@ -2923,7 +2930,13 @@ export function createPluginWorkerHandle( } try { - const result = await handler(request.params, contextForWorkerMessage(request)); + const context = contextForWorkerMessage(request); + if (context.invalidInvocationScope) throw Object.assign(new Error("Missing or unknown invocation scope"), { + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); + const source = context.invocationScope?.agentOrigin; + const origin = source && context.invocationScope ? { ...source, companyId: context.invocationScope.companyId } : undefined; + const result = await withPluginInteractionOrigin(origin, () => handler(request.params, context)); sendMessage({ jsonrpc: JSONRPC_VERSION, id: request.id, diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts index 6c3b94e147f..ce7da72da9f 100644 --- a/server/src/services/recovery/provider-failure-classification.test.ts +++ b/server/src/services/recovery/provider-failure-classification.test.ts @@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js"; describe("classifyAdapterFailureForRecovery", () => { + it("does not retry a permanently refused agent interaction delivery", () => { + expect(classifyContinuationFailure({ errorCode: "agent_interaction_delivery_denied", resultJson: null } as never)) + .toMatchObject({ kind: "non_retryable", maxAttempts: 0 }); + }); it("does not automatically replay a project access denial", () => { expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never)) .toMatchObject({ kind: "non_retryable", maxAttempts: 0 }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 0255f72bc0d..4cadaea1b47 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,4 +1,5 @@ import { wakeWaitersOfStalledIssues, type BlockerStallWakeup } from "./blocker-stall-wakeups.js"; +import { INTERACTION_DELIVERY_DENIED, isInteractionDeliveryDeniedRun } from "../agent-interaction-delivery.js"; import { notifyScheduledRetryDispatch } from "../run-dispatch-notifications.js"; import { OWNER_UNPROVABLE_CAUSE } from "./owner-gone-attestation.js"; import { promoteDeferredWorkIntents } from "../work-intent-outbox.js"; @@ -547,6 +548,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([ ]); const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([ + INTERACTION_DELIVERY_DENIED, "project_access_denied", "native_provider_model_rejected", "provider_tool_definition_invalid", @@ -4660,6 +4662,10 @@ export function recoveryService( } let latestRun = await getLatestIssueRun(issue.companyId, issue.id); + if (latestRun?.agentId === agentId && isInteractionDeliveryDeniedRun(latestRun)) { + result.skipped += 1; + continue; + } // Terminal run/wake rows survive a restart even if finalization never // reached the escalation transaction. Retry that idempotent disposition // before the participant-only review branch can skip pathless reviews. @@ -4829,6 +4835,10 @@ export function recoveryService( issue.status === "in_review" ? participantLatestRunForRecovery : latestRun; + if (isInteractionDeliveryDeniedRun(executionRecoverySource)) { + result.skipped += 1; + continue; + } if ( executionRecoverySource?.agentId === agentId && ( diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 3046fdb2384..39c6b24dac6 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -1,3 +1,5 @@ +import { assertAgentInteraction } from "./agent-interaction-policy.js"; +import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; import { notifyStatusCardIssueChange } from "./status-card-notifications.js"; import { notifyApprovalWork } from "./approval-work-notifications.js"; import { notifyDecisionTargetIssueRemoval } from "./decision-targets.js"; @@ -156,6 +158,7 @@ async function resolveRoutineResponsibleUserId(db: Db, companyId: string, actorU type Actor = { agentId?: string | null; + agentCompanyId?: string; userId?: string | null; actorResponsibleUserId?: string | null; runId?: string | null; @@ -970,6 +973,10 @@ export function routineService( restoredFromRevisionId?: string | null; } = {}, ) { + const pluginOrigin = currentPluginInteractionOrigin(); + if (pluginOrigin) actor = { ...actor, agentId: pluginOrigin.agentId, userId: null, runId: pluginOrigin.runId }; + await assertAgentInteraction(executor, { companyId: routine.companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId, + operation: "assign", targetAgentId: routine.assigneeAgentId, parentIssueId: routine.parentIssueId, lock: true }); const snapshot = await buildRoutineRevisionSnapshot(executor, routine); const nextRevisionNumber = routine.latestRevisionId ? routine.latestRevisionNumber + 1 : 1; const now = new Date(); @@ -1792,6 +1799,23 @@ export function routineService( await tx.execute( sql`select id from ${routines} where ${routines.id} = ${input.routine.id} and ${routines.companyId} = ${input.routine.companyId} for update`, ); + const revisionActor = input.source !== "manual" && input.routine.latestRevisionId + ? await tx.select({ agentId: routineRevisions.createdByAgentId, userId: routineRevisions.createdByUserId }) + .from(routineRevisions).where(and(eq(routineRevisions.companyId, input.routine.companyId), + eq(routineRevisions.routineId, input.routine.id), eq(routineRevisions.id, input.routine.latestRevisionId))) + .then(rows => rows[0]) : undefined; + const sourceRunActor = input.createdByRunId ? await tx.select({ agentId: heartbeatRuns.agentId }) + .from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.routine.companyId), eq(heartbeatRuns.id, input.createdByRunId))) + .then(rows => rows[0]?.agentId ?? null) : null; + const interactionActorAgentId = currentPluginInteractionOrigin()?.agentId ?? input.actor?.agentId ?? sourceRunActor + ?? (input.actor?.userId ? null : revisionActor ? revisionActor.agentId + : input.routine.updatedByAgentId ?? (input.routine.updatedByUserId ? null : input.routine.createdByAgentId)); + const interactionActorCompanyId = interactionActorAgentId ? await tx.select({ companyId: agents.companyId }).from(agents) + .where(eq(agents.id, interactionActorAgentId)).then(rows => rows[0]?.companyId) : undefined; + await assertAgentInteraction(tx, { companyId: input.routine.companyId, actorAgentId: interactionActorAgentId, + actorCompanyId: interactionActorCompanyId, + operation: "assign", targetAgentId: assigneeAgentId, parentIssueId: input.routine.parentIssueId, + texts: [title, description ?? ""], lock: true }); if (input.trigger && (input.source === "webhook" || input.source === "schedule")) { const currentTrigger = await txDb.select().from(routineTriggers).where(eq(routineTriggers.id, input.trigger.id)).then((rows) => rows[0]); @@ -1975,7 +1999,9 @@ export function routineService( reason: "issue_assigned", mutation: "create", contextSource: "routine.dispatch", - requestedByActorType: input.source === "schedule" ? "system" : undefined, + requestedByActorType: interactionActorAgentId ? "agent" : input.actor?.userId ? "user" : input.source === "schedule" ? "system" : undefined, + requestedByActorId: interactionActorAgentId ?? input.actor?.userId ?? null, + requestedByActorCompanyId: interactionActorCompanyId, rethrowOnError: true, }); const updated = await finalizeRun(createdRun.id, { @@ -2236,6 +2262,9 @@ export function routineService( } const createdRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; + await assertAgentInteraction(tx, { companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId, operation: "assign", + targetAgentId: input.assigneeAgentId, parentIssueId: input.parentIssueId, + texts: [input.title, input.description ?? ""], lock: true }); await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); const [created] = await txDb .insert(routines) @@ -2350,6 +2379,13 @@ export function routineService( .where(eq(routines.id, id)) .then((rows) => rows[0] ?? null); if (!locked) return null; + await assertAgentInteraction(tx, { companyId: locked.companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId, operation: "assign", + targetAgentId: patch.assigneeAgentId === undefined ? locked.assigneeAgentId : patch.assigneeAgentId, + parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId, + texts: [ + ...(patch.title !== undefined && patch.title !== locked.title ? [patch.title] : []), + ...(patch.description !== undefined && patch.description !== locked.description ? [patch.description ?? ""] : []), + ], lock: true }); previousResponsibleUserId = locked.responsibleUserId; if (patch.baseRevisionId && patch.baseRevisionId !== locked.latestRevisionId) { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 2733fa83bda..5bafbc60524 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -3,7 +3,7 @@ import { RepositoryCatalogUnavailableError, findRepositoryCatalogUnavailableErro import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js"; import { notifyToolConnectionHealth, writeToolConnectionHealthChange } from "./tool-connection-health-notifications.js"; import { connectionHealthSchedule } from "./tool-access/catalog/health-schedule.js"; -import { assertAgentConnectionInstallAccess, type ConnectionManagementContext } from "./connection-management.js"; +import { assertAgentConnectionInstallAccess, assertAgentToolAssignmentInteractions, type ConnectionManagementContext } from "./connection-management.js"; import { connectionActorUserId } from "./tool-access/actor.js"; import { restConnectorFor } from "./connectors/rest-registry.js"; import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js"; @@ -3296,7 +3296,17 @@ export function toolAccessService( .where(and(eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id))); const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active") ? [{ targetType: "company", targetId: companyId }] : []; - await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]); + const sourceAgentId = actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId + : actor?.actorType === "agent" ? actor.actorId : null; + await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets], sourceAgentId); + const requestedAgentTargets = new Set(bindingInputs.filter(target => target.targetType === "agent").map(target => target.targetId)); + const changedAgentTargets: string[] = []; + for (const savedTargets of [existingBindings, ...(connection.connectionPurpose === "tool" ? [existingInstalls] : [])]) { + const existingAgentTargets = new Set(savedTargets.filter(target => target.targetType === "agent").map(target => target.targetId)); + changedAgentTargets.push(...[...requestedAgentTargets].filter(id => !existingAgentTargets.has(id))); + if (!input.preserveExistingAccess) changedAgentTargets.push(...[...existingAgentTargets].filter(id => !requestedAgentTargets.has(id))); + } + await assertAgentToolAssignmentInteractions(tx as unknown as Db, sourceAgentId, companyId, changedAgentTargets, false); // A renamed connection can leave its old profile name in use. Resolve // names against profiles too, including when finalizing an installed draft. const otherProfiles = await tx diff --git a/server/src/services/tool-access/assignment-locks.ts b/server/src/services/tool-access/assignment-locks.ts index 28712b463ca..04ba0d51052 100644 --- a/server/src/services/tool-access/assignment-locks.ts +++ b/server/src/services/tool-access/assignment-locks.ts @@ -1,4 +1,5 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { currentPluginInteractionOrigin } from "../plugin-interaction-context.js"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { agents, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfiles } from "@paperclipai/db"; import type { CreateToolProfileEntryForProfile } from "@paperclipai/shared"; import type { ToolAccessMutationDb } from "./types.js"; @@ -10,12 +11,14 @@ export async function lockManagedAppPolicies(client: ToolAccessMutationDb, compa )).orderBy(toolPolicies.id).for("no key update"); } -export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) { +export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>, actorAgentId?: string | null) { const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId); const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId); + const sourceAgentId = currentPluginInteractionOrigin()?.agentId ?? actorAgentId; + if (sourceAgentId) ids.push(sourceAgentId); if (!allAgents && !ids.length) return; await client.select({ id: agents.id }).from(agents) - .where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`, + .where(and(eq(agents.companyId, companyId), or(sql`${agents.status} NOT IN ('pending_approval', 'terminated')`, ids.length ? inArray(agents.id, ids) : undefined), allAgents ? undefined : inArray(agents.id, ids))) .orderBy(agents.id).for("no key update"); } diff --git a/server/src/services/tool-access/connections/installs.ts b/server/src/services/tool-access/connections/installs.ts index 6e4459a4f37..4b99b2d42a8 100644 --- a/server/src/services/tool-access/connections/installs.ts +++ b/server/src/services/tool-access/connections/installs.ts @@ -1,3 +1,4 @@ +import { assertAgentToolAssignmentInteractions } from "../../connection-management.js"; import { connectionActorUserId } from "../actor.js"; import { toConnection, toConnectionInstall } from "./projection.js"; import { actorBinding } from "../actor.js"; @@ -208,7 +209,13 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId }) .from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId)) .where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`))); - await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]); + const sourceAgentId = actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId + : actor?.actorType === "agent" ? actor.actorId : null; + await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings], sourceAgentId); + const changedAgentIds = [...existing.filter(install => !requested.has(`${install.targetType}:${install.targetId}`)), + ...[...requested.entries()].filter(([key]) => !existingKeys.has(key)).map(([, install]) => install)] + .filter(install => install.targetType === "agent").map(install => install.targetId); + await assertAgentToolAssignmentInteractions(tx as unknown as Db, sourceAgentId, connection.companyId, changedAgentIds, false); const removals = existing.filter( (install) => !requested.has(`${install.targetType}:${install.targetId}`), diff --git a/server/src/services/tool-access/profiles/service.ts b/server/src/services/tool-access/profiles/service.ts index 223ca9a9169..d9622039d39 100644 --- a/server/src/services/tool-access/profiles/service.ts +++ b/server/src/services/tool-access/profiles/service.ts @@ -1,3 +1,4 @@ +import { assertAgentToolAssignmentInteractions } from "../../connection-management.js"; import type { ConnectionManagementContext } from "../../connection-management.js"; import { createConnectionProfileConfigurationReader } from "./connection-configuration.js"; import { connectionActorUserId } from "../actor.js"; @@ -843,6 +844,10 @@ export function createToolProfileService(db: Db, now: () => Date) { input.targetId, tx, ); + if (input.targetType === "agent") await assertAgentToolAssignmentInteractions(db, + actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId : actor?.actorType === "agent" ? actor.actorId : null, + profile.companyId, [input.targetId]); + const [row] = await db .insert(toolProfileBindings) .values({ @@ -879,6 +884,9 @@ export function createToolProfileService(db: Db, now: () => Date) { input.targetId, tx, ); + if (input.targetType === "agent") await assertAgentToolAssignmentInteractions(db, + managementContext?.actor.type === "agent" ? managementContext.actor.agentId : null, profile.companyId, [input.targetId]); + const rows = await db .delete(toolProfileBindings) .where( diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 1a507bb137a..bbf1140e2fe 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,3 +1,4 @@ +import { assertAgentToolAssignmentInteractions } from "./connection-management.js"; import { isNewHumanIntentAfterToolRejection } from "./tool-action-replay-intent.js"; import { approvalSnapshotsMatch, approvalTargetChangeMessage, oauthApprovalConfig, approvalGrantTenantIdentity, externalCredentialApprovalIdentity } from "./tool-approval-target.js"; @@ -9111,54 +9112,60 @@ export function createToolGatewayService( input.body.displaySlug ?? input.body.slug ?? slugSegment(input.body.name, "gateway"); - const [gateway] = await db - .insert(toolMcpGateways) - .values({ - companyId: input.companyId, - name: input.body.name, - slug, - displaySlug: slug, - description: input.body.description ?? null, - defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only", - contextScopeType: input.body.contextScopeType ?? "none", - contextScopeId: input.body.contextScopeId ?? null, - profileId: input.body.profileId, - agentId: input.body.agentId ?? null, - projectId: input.body.projectId ?? null, - issueId: input.body.issueId ?? null, - approvalIssueId: input.body.approvalIssueId ?? null, - ...(input.body.authConfig !== undefined - ? { authConfig: input.body.authConfig } - : {}), - ...(input.body.headerPolicy !== undefined - ? { headerPolicy: input.body.headerPolicy } - : {}), - ...(input.body.metadataPolicy !== undefined - ? { metadataPolicy: input.body.metadataPolicy } - : {}), - ...(input.body.onDemandToolsConfig !== undefined - ? { onDemandToolsConfig: input.body.onDemandToolsConfig } - : {}), - metadata: input.body.metadata ?? {}, - createdByAgentId: input.actor?.agentId ?? null, - createdByUserId: input.actor?.userId ?? null, - createdAt: now, - updatedAt: now, - }) - .returning(); - await db - .insert(toolProfileBindings) - .values({ - companyId: input.companyId, - profileId: input.body.profileId, - targetType: "gateway", - targetId: gateway.id, - priority: 10, - metadata: { source: "named_mcp_gateway" }, - createdByAgentId: input.actor?.agentId ?? null, - createdByUserId: input.actor?.userId ?? null, - }) - .onConflictDoNothing(); + const gateway = await db.transaction(async tx => { + await assertAgentToolAssignmentInteractions(tx as unknown as Db, + input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : input.actor?.agentId, + input.companyId, input.body.agentId ? [input.body.agentId] : []); + const [gateway] = await tx + .insert(toolMcpGateways) + .values({ + companyId: input.companyId, + name: input.body.name, + slug, + displaySlug: slug, + description: input.body.description ?? null, + defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only", + contextScopeType: input.body.contextScopeType ?? "none", + contextScopeId: input.body.contextScopeId ?? null, + profileId: input.body.profileId, + agentId: input.body.agentId ?? null, + projectId: input.body.projectId ?? null, + issueId: input.body.issueId ?? null, + approvalIssueId: input.body.approvalIssueId ?? null, + ...(input.body.authConfig !== undefined + ? { authConfig: input.body.authConfig } + : {}), + ...(input.body.headerPolicy !== undefined + ? { headerPolicy: input.body.headerPolicy } + : {}), + ...(input.body.metadataPolicy !== undefined + ? { metadataPolicy: input.body.metadataPolicy } + : {}), + ...(input.body.onDemandToolsConfig !== undefined + ? { onDemandToolsConfig: input.body.onDemandToolsConfig } + : {}), + metadata: input.body.metadata ?? {}, + createdByAgentId: input.actor?.agentId ?? null, + createdByUserId: input.actor?.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + await tx + .insert(toolProfileBindings) + .values({ + companyId: input.companyId, + profileId: input.body.profileId, + targetType: "gateway", + targetId: gateway.id, + priority: 10, + metadata: { source: "named_mcp_gateway" }, + createdByAgentId: input.actor?.agentId ?? null, + createdByUserId: input.actor?.userId ?? null, + }) + .onConflictDoNothing(); + return gateway; + }); await writeAudit({ session: { id: `gateway:${gateway.id}`, @@ -9211,117 +9218,123 @@ export function createToolGatewayService( managementContext?: ConnectionManagementContext; }): Promise { await input.managementContext?.reauthorize(); - const [existing] = await db - .select() - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.companyId, input.companyId), - eq(toolMcpGateways.id, input.gatewayId), - ), - ) - .limit(1); - if (!existing) - throw new ToolGatewayHttpError( - 404, - "MCP gateway not found", - "gateway_not_found", - ); - await assertGatewayContext({ - companyId: input.companyId, - profileId: input.body.profileId ?? existing.profileId, - agentId: - input.body.agentId === undefined - ? existing.agentId - : input.body.agentId, - projectId: - input.body.projectId === undefined - ? existing.projectId - : input.body.projectId, - issueId: - input.body.issueId === undefined - ? existing.issueId - : input.body.issueId, - }); - const [updated] = await db - .update(toolMcpGateways) - .set({ - ...(input.body.name !== undefined ? { name: input.body.name } : {}), - ...(input.body.slug !== undefined || - input.body.displaySlug !== undefined - ? { slug: input.body.displaySlug ?? input.body.slug } - : {}), - ...(input.body.slug !== undefined || - input.body.displaySlug !== undefined - ? { displaySlug: input.body.displaySlug ?? input.body.slug } - : {}), - ...(input.body.description !== undefined - ? { description: input.body.description ?? null } - : {}), - ...(input.body.status !== undefined - ? { status: input.body.status } - : {}), - ...(input.body.profileId !== undefined - ? { profileId: input.body.profileId } - : {}), - ...(input.body.defaultProfileMode !== undefined - ? { defaultProfileMode: input.body.defaultProfileMode } - : {}), - ...(input.body.contextScopeType !== undefined - ? { contextScopeType: input.body.contextScopeType } - : {}), - ...(input.body.contextScopeId !== undefined - ? { contextScopeId: input.body.contextScopeId ?? null } - : {}), - ...(input.body.agentId !== undefined - ? { agentId: input.body.agentId ?? null } - : {}), - ...(input.body.projectId !== undefined - ? { projectId: input.body.projectId ?? null } - : {}), - ...(input.body.issueId !== undefined - ? { issueId: input.body.issueId ?? null } - : {}), - ...(input.body.approvalIssueId !== undefined - ? { approvalIssueId: input.body.approvalIssueId ?? null } - : {}), - ...(input.body.authConfig !== undefined - ? { authConfig: input.body.authConfig } - : {}), - ...(input.body.headerPolicy !== undefined - ? { headerPolicy: input.body.headerPolicy } - : {}), - ...(input.body.metadataPolicy !== undefined - ? { metadataPolicy: input.body.metadataPolicy } - : {}), - ...(input.body.onDemandToolsConfig !== undefined - ? { onDemandToolsConfig: input.body.onDemandToolsConfig } - : {}), - ...(input.body.metadata !== undefined - ? { metadata: input.body.metadata ?? {} } - : {}), - updatedAt: new Date(), - }) - .where( - and( - eq(toolMcpGateways.companyId, input.companyId), - eq(toolMcpGateways.id, input.gatewayId), - ), - ) - .returning(); - if (input.body.profileId && input.body.profileId !== existing.profileId) { - await db - .insert(toolProfileBindings) - .values({ - companyId: input.companyId, - profileId: input.body.profileId, - targetType: "gateway", - targetId: input.gatewayId, - priority: 10, - metadata: { source: "named_mcp_gateway" }, + const updated = await db.transaction(async tx => { + const [existing] = await tx + .select() + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.companyId, input.companyId), + eq(toolMcpGateways.id, input.gatewayId), + ), + ) + .limit(1).for("update"); + if (!existing) + throw new ToolGatewayHttpError( + 404, + "MCP gateway not found", + "gateway_not_found", + ); + const targets = [...new Set([existing.agentId, input.body.agentId].filter((id): id is string => Boolean(id)))]; + await assertAgentToolAssignmentInteractions(tx as unknown as Db, + input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : null, input.companyId, targets); + await assertGatewayContext({ + companyId: input.companyId, + profileId: input.body.profileId ?? existing.profileId, + agentId: + input.body.agentId === undefined + ? existing.agentId + : input.body.agentId, + projectId: + input.body.projectId === undefined + ? existing.projectId + : input.body.projectId, + issueId: + input.body.issueId === undefined + ? existing.issueId + : input.body.issueId, + }); + const [updated] = await tx + .update(toolMcpGateways) + .set({ + ...(input.body.name !== undefined ? { name: input.body.name } : {}), + ...(input.body.slug !== undefined || + input.body.displaySlug !== undefined + ? { slug: input.body.displaySlug ?? input.body.slug } + : {}), + ...(input.body.slug !== undefined || + input.body.displaySlug !== undefined + ? { displaySlug: input.body.displaySlug ?? input.body.slug } + : {}), + ...(input.body.description !== undefined + ? { description: input.body.description ?? null } + : {}), + ...(input.body.status !== undefined + ? { status: input.body.status } + : {}), + ...(input.body.profileId !== undefined + ? { profileId: input.body.profileId } + : {}), + ...(input.body.defaultProfileMode !== undefined + ? { defaultProfileMode: input.body.defaultProfileMode } + : {}), + ...(input.body.contextScopeType !== undefined + ? { contextScopeType: input.body.contextScopeType } + : {}), + ...(input.body.contextScopeId !== undefined + ? { contextScopeId: input.body.contextScopeId ?? null } + : {}), + ...(input.body.agentId !== undefined + ? { agentId: input.body.agentId ?? null } + : {}), + ...(input.body.projectId !== undefined + ? { projectId: input.body.projectId ?? null } + : {}), + ...(input.body.issueId !== undefined + ? { issueId: input.body.issueId ?? null } + : {}), + ...(input.body.approvalIssueId !== undefined + ? { approvalIssueId: input.body.approvalIssueId ?? null } + : {}), + ...(input.body.authConfig !== undefined + ? { authConfig: input.body.authConfig } + : {}), + ...(input.body.headerPolicy !== undefined + ? { headerPolicy: input.body.headerPolicy } + : {}), + ...(input.body.metadataPolicy !== undefined + ? { metadataPolicy: input.body.metadataPolicy } + : {}), + ...(input.body.onDemandToolsConfig !== undefined + ? { onDemandToolsConfig: input.body.onDemandToolsConfig } + : {}), + ...(input.body.metadata !== undefined + ? { metadata: input.body.metadata ?? {} } + : {}), + updatedAt: new Date(), }) - .onConflictDoNothing(); - } + .where( + and( + eq(toolMcpGateways.companyId, input.companyId), + eq(toolMcpGateways.id, input.gatewayId), + ), + ) + .returning(); + if (input.body.profileId && input.body.profileId !== existing.profileId) { + await tx + .insert(toolProfileBindings) + .values({ + companyId: input.companyId, + profileId: input.body.profileId, + targetType: "gateway", + targetId: input.gatewayId, + priority: 10, + metadata: { source: "named_mcp_gateway" }, + }) + .onConflictDoNothing(); + } + return updated; + }); if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit, action: "tool_gateway.updated", entityType: "tool_mcp_gateway", entityId: updated.id, details: { profileId: updated.profileId, status: updated.status } }); diff --git a/server/src/services/work-products.ts b/server/src/services/work-products.ts index b993c9d656a..e4373a34081 100644 --- a/server/src/services/work-products.ts +++ b/server/src/services/work-products.ts @@ -1,4 +1,5 @@ import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js"; +import { assertAgentInteraction } from "./agent-interaction-policy.js"; import { notifyApprovalQueueWork } from "./approval-work-notifications.js"; import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -273,8 +274,10 @@ export function workProductService( return row ? toIssueWorkProduct(row) : null; }, - createForIssue: async (issueId: string, companyId: string, data: Omit) => { + createForIssue: async (issueId: string, companyId: string, data: Omit, actorAgentId?: string | null) => { const row = await db.transaction(async (tx) => { + await assertAgentInteraction(tx, { companyId, actorAgentId, operation: "edit", issueId, + texts: [data.title, data.summary ?? ""], lock: true }); await notifyNativeStatusReassessment(tx, { companyId, issueId }); if (data.type === "pull_request") { await notifyTerminalWorkspaceWork(tx, `issue:${issueId}`); @@ -306,7 +309,7 @@ export function workProductService( return row ? toIssueWorkProduct(row) : null; }, - update: async (id: string, patch: Partial) => { + update: async (id: string, patch: Partial, actorAgentId?: string | null) => { const row = await db.transaction(async (tx) => { const existing = await tx .select() @@ -314,6 +317,15 @@ export function workProductService( .where(eq(issueWorkProducts.id, id)) .then((rows) => rows[0] ?? null); if (!existing) return null; + await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId, operation: "edit", issueId: existing.issueId, + texts: [ + ...(patch.title !== undefined && patch.title !== existing.title ? [patch.title] : []), + ...(patch.summary !== undefined && patch.summary !== existing.summary ? [patch.summary ?? ""] : []), + ], lock: true }); + if ((patch.issueId && patch.issueId !== existing.issueId) || (patch.companyId && patch.companyId !== existing.companyId)) { + await assertAgentInteraction(tx, { companyId: patch.companyId ?? existing.companyId, actorAgentId, + operation: "edit", issueId: patch.issueId ?? existing.issueId, lock: true }); + } await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: existing.issueId }); if ((patch.companyId && patch.companyId !== existing.companyId) || (patch.issueId && patch.issueId !== existing.issueId)) { await notifyNativeStatusReassessment(tx, { companyId: patch.companyId ?? existing.companyId, issueId: patch.issueId ?? existing.issueId }); @@ -357,7 +369,7 @@ export function workProductService( * imported rows themselves. We reproduce "last primary wins" within each * (issue, type) group and insert the whole batch in chunked statements. */ - createManyForImport: async (rows: ImportIssueWorkProductRow[]): Promise => { + createManyForImport: async (rows: ImportIssueWorkProductRow[], actorAgentId?: string | null, actorCompanyId?: string | null): Promise => { if (rows.length === 0) return; const lastPrimaryIndexByGroup = new Map(); rows.forEach((row, index) => { @@ -391,6 +403,8 @@ export function workProductService( // prefix behind (which a retry would then duplicate). Mirrors the // per-writer transaction the batched issue/document writers use. await db.transaction(async (tx) => { + for (const row of rows) await assertAgentInteraction(tx, { companyId: row.companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined, + operation: "mention", issueId: row.issueId, texts: [row.title, row.summary ?? ""], lock: true }); for (const scope of new Map(values.map(value => [value.issueId, { companyId: value.companyId, issueId: value.issueId }])).values()) { await notifyNativeStatusReassessment(tx, scope); } @@ -404,10 +418,12 @@ export function workProductService( }); }, - remove: async (id: string) => { + remove: async (id: string, actorAgentId?: string | null) => { const row = await db.transaction(async tx => { const [existing] = await tx.select({ type: issueWorkProducts.type, issueId: issueWorkProducts.issueId, companyId: issueWorkProducts.companyId }) .from(issueWorkProducts).where(eq(issueWorkProducts.id, id)); + if (existing) await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId, + operation: "edit", issueId: existing.issueId, lock: true }); if (existing) await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: existing.issueId }); if (existing?.type === "pull_request") { await notifyTerminalWorkspaceWork(tx, `issue:${existing.issueId}`);