mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
4158799d1b
commit
5bad074baa
97 files changed
+3590
-433
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface LifecycleEffects {
|
||||
beforeConnectionEligibilityRestore?(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
reportFailure(context: LifecycleFailure, error: unknown): void;
|
||||
deleteDependencies(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
lockConfigurationInteractions(db: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<UpdateAgentOptions | undefined>;
|
||||
prepareConfiguration(db: Db, current: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch>;
|
||||
completeConfiguration(db: Db, before: AgentConfigurationRecord, after: AgentConfigurationRecord, patch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]): Promise<void>;
|
||||
transaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
return workers.get(db)?.process(id) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
export function initializeImportedAgentConfigurationInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[],
|
||||
id: string, data: Pick<AgentConfigurationPatch, "adapterConfig" | "reportsTo">) {
|
||||
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<ReturnType<typeof agentRecords>["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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user