mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Extract change consent gate
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3a47491ac4
commit
78774af89e
10 files changed
+220
-154
No files matched your search
+6
-7
@@ -164,13 +164,12 @@ From a reflection issue (assigned to the target's manager or the requester):
|
||||
|
||||
Server-enforced mutation target keys:
|
||||
|
||||
- Agent instructions: `reflection-coach:agent-instructions:<agentId>`
|
||||
- Agent/tool description fields: `reflection-coach:agent-description:<agentId>`
|
||||
- Existing company skill: `reflection-coach:company-skill:<skillId>`
|
||||
- New local company skill by slug: `reflection-coach:company-skill-slug:<slug>`
|
||||
- Imported skill source: `reflection-coach:company-skill-import:<source>`
|
||||
- Catalog skill install/update: `reflection-coach:company-skill-catalog:<catalogSkillId>`
|
||||
- Project workspace skill scan: `reflection-coach:company-skills:scan-projects`
|
||||
- Agent instructions: `agent:<agentId>:instructions`
|
||||
- Agent/tool description fields: `agent:<agentId>:profile`
|
||||
- Existing company skill: `skill:<skillId>`
|
||||
- New local company skill by slug: `skill-slug:<slug>`
|
||||
- Imported or catalog skill source: `skill-import:<source>`
|
||||
- Project workspace skill scan: `skills:scan-projects`
|
||||
|
||||
### 10) Apply only after acceptance, in a follow-up run
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"packageName": "@paperclipai/skills-catalog",
|
||||
"packageVersion": "0.3.1",
|
||||
"generatedAt": "2026-07-08T20:34:14.622Z",
|
||||
"generatedAt": "2026-07-09T15:02:07.574Z",
|
||||
"skills": [
|
||||
{
|
||||
"id": "paperclipai:bundled:docs:doc-maintenance",
|
||||
@@ -102,11 +102,11 @@
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 12120,
|
||||
"sha256": "4050db7eb27559727c972673f419cd35335f082ee7f54ade636ada8ccd3e2e11"
|
||||
"sizeBytes": 11903,
|
||||
"sha256": "ca167eac8d1e89cadc8009b61a204368507e7edb7f2da0688fea6dba8223e189"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:292fa8b1ce949cae972633fe6c283a628c87bce6f64d000d421b0a6644e84d4e"
|
||||
"contentHash": "sha256:20381a898f05ceb668e305708dd33a03ce36aef0e00b923c01ae20ab785f04d2"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:task-planning",
|
||||
|
||||
@@ -947,7 +947,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
].join("\n"),
|
||||
target: {
|
||||
type: "custom",
|
||||
key: `reflection-coach:agent-instructions:${target.id}`,
|
||||
key: `agent:${target.id}:instructions`,
|
||||
revisionId: "proposal-v1",
|
||||
label: "Target Coder AGENTS.md diff",
|
||||
},
|
||||
@@ -1003,7 +1003,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
].join("\n"),
|
||||
target: {
|
||||
type: "custom",
|
||||
key: `reflection-coach:agent-instructions:${target.id}`,
|
||||
key: `agent:${target.id}:instructions`,
|
||||
revisionId: "proposal-v2",
|
||||
label: "Target Coder AGENTS.md rejected diff",
|
||||
},
|
||||
|
||||
+38
-14
@@ -13,14 +13,14 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
reflectionCoachCompanySkillTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
changeConsentGateService,
|
||||
skillChangeTargetKey,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
const embeddedPostgresSupport = getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
describeEmbeddedPostgres("changeConsentGateService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
@@ -47,7 +47,7 @@ describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
const sourceRunId = randomUUID();
|
||||
const proposalIssueId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const targetKey = reflectionCoachCompanySkillTargetKey(skillId);
|
||||
const targetKey = skillChangeTargetKey(skillId);
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -64,12 +64,6 @@ describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: { canCreateSkills: true },
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
@@ -94,7 +88,7 @@ describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
it("rejects Reflection Coach skill mutation without an accepted bound interaction", async () => {
|
||||
const { companyId, coachId, targetKey } = await seedGateFixture();
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
@@ -127,7 +121,7 @@ describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: sourceRunId,
|
||||
@@ -160,7 +154,37 @@ describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("allows legacy Reflection Coach target keys for durable accepted interactions", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: `reflection-coach:company-skill:${skillId}`, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
@@ -75,7 +75,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockReflectionCoachMutationGate = vi.hoisted(() => ({
|
||||
assertAllowed: vi.fn(),
|
||||
assertConsented: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
@@ -108,13 +108,13 @@ function registerModuleMocks() {
|
||||
|
||||
vi.doMock("../services/skills-catalog.js", () => mockCatalogService);
|
||||
|
||||
vi.doMock("../services/reflection-coach-mutation-gate.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/reflection-coach-mutation-gate.js")>(
|
||||
"../services/reflection-coach-mutation-gate.js",
|
||||
vi.doMock("../services/change-consent-gate.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/change-consent-gate.js")>(
|
||||
"../services/change-consent-gate.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
reflectionCoachMutationGateService: () => mockReflectionCoachMutationGate,
|
||||
changeConsentGateService: () => mockReflectionCoachMutationGate,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("company skill mutation permissions", () => {
|
||||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/company-skills.js");
|
||||
vi.doUnmock("../services/skills-catalog.js");
|
||||
vi.doUnmock("../services/reflection-coach-mutation-gate.js");
|
||||
vi.doUnmock("../services/change-consent-gate.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/company-skills.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
@@ -604,7 +604,7 @@ describe("company skill mutation permissions", () => {
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertAllowed.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
@@ -1175,7 +1175,7 @@ describe("company skill mutation permissions", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mockReflectionCoachMutationGate.assertAllowed.mockRejectedValue(forbidden("gate required", {
|
||||
mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(forbidden("gate required", {
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
}));
|
||||
|
||||
@@ -1189,11 +1189,11 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockReflectionCoachMutationGate.assertAllowed).toHaveBeenCalledWith({
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "reflection-coach",
|
||||
actorRunId: "run-apply",
|
||||
targetKeys: ["reflection-coach:company-skill-slug:reflection-draft"],
|
||||
targetKeys: ["skill-slug:reflection-draft"],
|
||||
});
|
||||
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -31,13 +31,12 @@ You may be granted permission to create and update skills, update agent AGENTS.m
|
||||
|
||||
Server-enforced target keys:
|
||||
|
||||
- `reflection-coach:agent-instructions:<agentId>`
|
||||
- `reflection-coach:agent-description:<agentId>`
|
||||
- `reflection-coach:company-skill:<skillId>`
|
||||
- `reflection-coach:company-skill-slug:<slug>`
|
||||
- `reflection-coach:company-skill-import:<source>`
|
||||
- `reflection-coach:company-skill-catalog:<catalogSkillId>`
|
||||
- `reflection-coach:company-skills:scan-projects`
|
||||
- `agent:<agentId>:instructions`
|
||||
- `agent:<agentId>:profile`
|
||||
- `skill:<skillId>`
|
||||
- `skill-slug:<slug>`
|
||||
- `skill-import:<source>`
|
||||
- `skills:scan-projects`
|
||||
|
||||
## Execution contract
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ This routine is **paused by default** and spends no tokens until an operator ena
|
||||
## Hard limits for this routine
|
||||
|
||||
- Proposal-only. This routine must not edit any agent's live AGENTS.md, skill assignments, or tool descriptions directly.
|
||||
- Any actual instruction/skill/tool-description change requires a displayed diff and an **accepted** task interaction (`request_confirmation`, `request_checkbox_confirmation`, or `ask_user_questions`), applied only in a separate follow-up run.
|
||||
- Any actual instruction/skill/tool-description change requires a displayed diff and an **accepted** `request_confirmation` task interaction, applied only in a separate follow-up run.
|
||||
- Mutation confirmations must bind the exact resource key they will apply, using `agent:<agentId>:instructions`, `agent:<agentId>:profile`, `skill:<skillId>`, `skill-slug:<slug>`, `skill-import:<source>`, or `skills:scan-projects`.
|
||||
- Keep every read company-scoped. Do not cross company boundaries.
|
||||
- Every proposed rule needs linked issue/comment evidence or it is dropped. No scoring without trajectories.
|
||||
- Respect the size caps: AGENTS.md +20% max per proposal, skills 15KB max, tool descriptions 500 chars max.
|
||||
|
||||
+27
-20
@@ -101,10 +101,11 @@ import { readObject } from "../lib/objects.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import {
|
||||
reflectionCoachAgentDescriptionTargetKey,
|
||||
reflectionCoachAgentInstructionsTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
agentInstructionsChangeTargetKey,
|
||||
agentProfileChangeTargetKey,
|
||||
changeConsentGateService,
|
||||
touchesAgentProfileChangeConsentFields,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -1335,37 +1336,43 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedByReflectionCoachGate = await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [reflectionCoachAgentInstructionsTargetKey(targetAgent.id)],
|
||||
});
|
||||
if (allowedByReflectionCoachGate) return;
|
||||
if (await isReflectionCoachActor(req, targetAgent.companyId)) {
|
||||
const allowedByReflectionCoachGate = await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [agentInstructionsChangeTargetKey(targetAgent.id)],
|
||||
});
|
||||
if (allowedByReflectionCoachGate) return;
|
||||
}
|
||||
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
}
|
||||
|
||||
async function isReflectionCoachActor(req: Request, companyId: string) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return false;
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
return readBuiltInAgentMarker(actorAgent.metadata)?.key === "reflection-coach";
|
||||
}
|
||||
|
||||
async function assertReflectionCoachAgentDescriptionMutationGate(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
) {
|
||||
await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
if (!(await isReflectionCoachActor(req, targetAgent.companyId))) return;
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [reflectionCoachAgentDescriptionTargetKey(targetAgent.id)],
|
||||
targetKeys: [agentProfileChangeTargetKey(targetAgent.id)],
|
||||
});
|
||||
}
|
||||
|
||||
function touchesAgentDescription(patchData: Record<string, unknown>) {
|
||||
return ["name", "role", "title", "capabilities"].some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patchData, key),
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
@@ -2966,7 +2973,7 @@ export function agentRoutes(
|
||||
},
|
||||
);
|
||||
}
|
||||
if (touchesAgentDescription(patchData)) {
|
||||
if (touchesAgentProfileChangeConsentFields(patchData)) {
|
||||
await assertReflectionCoachAgentDescriptionMutationGate(req, existing);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,14 +34,14 @@ import {
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import {
|
||||
reflectionCoachCompanySkillCatalogTargetKey,
|
||||
reflectionCoachCompanySkillImportTargetKey,
|
||||
reflectionCoachCompanySkillScanTargetKey,
|
||||
reflectionCoachCompanySkillSlugTargetKey,
|
||||
reflectionCoachCompanySkillTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
changeConsentGateService,
|
||||
skillChangeTargetKey,
|
||||
skillImportChangeTargetKey,
|
||||
skillSlugChangeTargetKey,
|
||||
skillsScanProjectsChangeTargetKey,
|
||||
} from "../services/change-consent-gate.js";
|
||||
|
||||
type SkillTelemetryInput = {
|
||||
key: string;
|
||||
@@ -106,8 +106,38 @@ export function companySkillRoutes(db: Db) {
|
||||
return { type: "system" as const };
|
||||
}
|
||||
|
||||
function skillMutationTargets(input: {
|
||||
skillId?: string | null;
|
||||
slug?: unknown;
|
||||
source?: unknown;
|
||||
catalogSkillId?: unknown;
|
||||
scanProjects?: boolean;
|
||||
}) {
|
||||
const targetKeys: string[] = [];
|
||||
const skillId = asString(input.skillId);
|
||||
const slug = asString(input.slug);
|
||||
const source = asString(input.source);
|
||||
const catalogSkillId = asString(input.catalogSkillId);
|
||||
if (skillId) targetKeys.push(skillChangeTargetKey(skillId));
|
||||
if (slug) targetKeys.push(skillSlugChangeTargetKey(slug));
|
||||
if (source) targetKeys.push(skillImportChangeTargetKey(source));
|
||||
if (catalogSkillId) targetKeys.push(skillImportChangeTargetKey(catalogSkillId));
|
||||
if (input.scanProjects) targetKeys.push(skillsScanProjectsChangeTargetKey());
|
||||
return targetKeys;
|
||||
}
|
||||
|
||||
async function isReflectionCoachActor(req: Request, companyId: string) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return false;
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
return readBuiltInAgentMarker(actorAgent.metadata)?.key === "reflection-coach";
|
||||
}
|
||||
|
||||
async function assertReflectionCoachSkillMutationGate(req: Request, companyId: string, targetKeys: string[]) {
|
||||
await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
if (!(await isReflectionCoachActor(req, companyId))) return;
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
@@ -626,9 +656,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -697,12 +725,10 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
skillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -833,11 +859,9 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -867,9 +891,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateSkill(companyId, skillId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -900,9 +922,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
@@ -968,9 +988,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillImportTargetKey(source),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ source }));
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1010,12 +1028,10 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillCatalogTargetKey(String(req.body.catalogSkillId ?? "")),
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
catalogSkillId: req.body.catalogSkillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.installFromCatalog(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1048,9 +1064,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillScanTargetKey(),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ scanProjects: true }));
|
||||
const result = await svc.scanProjectWorkspaces(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1082,9 +1096,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.deleteSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1116,9 +1128,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.auditSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1155,9 +1165,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.installUpdate(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
@@ -1198,9 +1206,7 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.resetSkill(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
|
||||
+67
-37
@@ -1,36 +1,39 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { issueThreadInteractions } from "@paperclipai/db";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
|
||||
export function reflectionCoachAgentInstructionsTargetKey(agentId: string) {
|
||||
return `reflection-coach:agent-instructions:${agentId}`;
|
||||
export const AGENT_PROFILE_CHANGE_CONSENT_FIELDS = ["name", "role", "title", "capabilities"] as const;
|
||||
|
||||
export function agentInstructionsChangeTargetKey(agentId: string) {
|
||||
return `agent:${agentId}:instructions`;
|
||||
}
|
||||
|
||||
export function reflectionCoachAgentDescriptionTargetKey(agentId: string) {
|
||||
return `reflection-coach:agent-description:${agentId}`;
|
||||
export function agentProfileChangeTargetKey(agentId: string) {
|
||||
return `agent:${agentId}:profile`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillTargetKey(skillId: string) {
|
||||
return `reflection-coach:company-skill:${skillId}`;
|
||||
export function skillChangeTargetKey(skillId: string) {
|
||||
return `skill:${skillId}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillSlugTargetKey(slug: string) {
|
||||
return `reflection-coach:company-skill-slug:${slug}`;
|
||||
export function skillSlugChangeTargetKey(slug: string) {
|
||||
return `skill-slug:${slug}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillImportTargetKey(source: string) {
|
||||
return `reflection-coach:company-skill-import:${source}`;
|
||||
export function skillImportChangeTargetKey(source: string) {
|
||||
return `skill-import:${source}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillCatalogTargetKey(catalogSkillId: string) {
|
||||
return `reflection-coach:company-skill-catalog:${catalogSkillId}`;
|
||||
export function skillsScanProjectsChangeTargetKey() {
|
||||
return "skills:scan-projects";
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillScanTargetKey() {
|
||||
return "reflection-coach:company-skills:scan-projects";
|
||||
export function touchesAgentProfileChangeConsentFields(patchData: Record<string, unknown>) {
|
||||
return AGENT_PROFILE_CHANGE_CONSENT_FIELDS.some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patchData, key),
|
||||
);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown) {
|
||||
@@ -44,9 +47,52 @@ function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) {
|
||||
return /(^|\n)[+-][^\n]+/.test(details);
|
||||
}
|
||||
|
||||
export function reflectionCoachMutationGateService(db: Db) {
|
||||
function legacyTargetKeysFor(targetKey: string) {
|
||||
if (targetKey.startsWith("agent:") && targetKey.endsWith(":instructions")) {
|
||||
const agentId = targetKey.slice("agent:".length, -":instructions".length);
|
||||
if (agentId) return [`reflection-coach:agent-instructions:${agentId}`];
|
||||
}
|
||||
if (targetKey.startsWith("agent:") && targetKey.endsWith(":profile")) {
|
||||
const agentId = targetKey.slice("agent:".length, -":profile".length);
|
||||
if (agentId) return [`reflection-coach:agent-description:${agentId}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill:")) {
|
||||
const skillId = targetKey.slice("skill:".length);
|
||||
if (skillId) return [`reflection-coach:company-skill:${skillId}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill-slug:")) {
|
||||
const slug = targetKey.slice("skill-slug:".length);
|
||||
if (slug) return [`reflection-coach:company-skill-slug:${slug}`];
|
||||
}
|
||||
if (targetKey.startsWith("skill-import:")) {
|
||||
const source = targetKey.slice("skill-import:".length);
|
||||
if (source) {
|
||||
return [
|
||||
`reflection-coach:company-skill-import:${source}`,
|
||||
`reflection-coach:company-skill-catalog:${source}`,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (targetKey === "skills:scan-projects") {
|
||||
return ["reflection-coach:company-skills:scan-projects"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) {
|
||||
const expanded = new Set<string>();
|
||||
for (const targetKey of targetKeys) {
|
||||
expanded.add(targetKey);
|
||||
for (const legacyTargetKey of legacyTargetKeysFor(targetKey)) {
|
||||
expanded.add(legacyTargetKey);
|
||||
}
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
export function changeConsentGateService(db: Db) {
|
||||
return {
|
||||
assertAllowed: async (input: {
|
||||
assertConsented: async (input: {
|
||||
companyId: string;
|
||||
actorAgentId: string | null | undefined;
|
||||
actorRunId: string | null | undefined;
|
||||
@@ -55,23 +101,6 @@ export function reflectionCoachMutationGateService(db: Db) {
|
||||
const actorAgentId = readNonEmptyString(input.actorAgentId);
|
||||
if (!actorAgentId) return false;
|
||||
|
||||
const actorAgent = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
metadata: agents.metadata,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, actorAgentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!actorAgent || actorAgent.companyId !== input.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
const marker = readBuiltInAgentMarker(actorAgent.metadata);
|
||||
if (marker?.key !== "reflection-coach") return false;
|
||||
|
||||
const actorRunId = readNonEmptyString(input.actorRunId);
|
||||
if (!actorRunId) {
|
||||
throw forbidden("Reflection Coach mutations require a run id", {
|
||||
@@ -85,9 +114,10 @@ export function reflectionCoachMutationGateService(db: Db) {
|
||||
code: "reflection_coach_mutation_target_required",
|
||||
});
|
||||
}
|
||||
const queryTargetKeys = expandTargetKeysForLegacyCompatibility(targetKeys);
|
||||
|
||||
const targetKeyPredicate = or(
|
||||
...targetKeys.map((targetKey) =>
|
||||
...queryTargetKeys.map((targetKey) =>
|
||||
sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`,
|
||||
),
|
||||
);
|
||||
@@ -114,7 +144,7 @@ export function reflectionCoachMutationGateService(db: Db) {
|
||||
const payload = row.payload as RequestConfirmationPayload;
|
||||
const result = row.result as RequestConfirmationResult | null;
|
||||
return payload.target?.type === "custom"
|
||||
&& targetKeys.includes(payload.target.key)
|
||||
&& queryTargetKeys.includes(payload.target.key)
|
||||
&& result?.outcome === "accepted"
|
||||
&& payloadHasDisplayedDiff(payload)
|
||||
&& Boolean(row.sourceRunId)
|
||||
Reference in new issue
Block a user