From e41ba306c59c4313bad0979ef5d318ec6d5807c5 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 23 Jul 2026 15:31:55 -0700 Subject: [PATCH] feat(secrets): thread audit actor into skip-user-secret skills routes (#10124) ## Thinking Path > - Paperclip manages agent work and needs auditable control over secret resolution > - The skip-user-secret skills routes still have to attribute access to the real actor > - These routes were calling the adapter config resolver without an access context > - That dropped actor attribution from the company `secret_ref` audit trail > - This pull request threads the existing actor-secret context helper into both skills routes > - The benefit is that audit fidelity is restored without changing `skipUserSecrets` behavior ## Linked Issues or Issue Description Refs #10115. This PR fixes a gap in the skills read/sync routes where `resolveAdapterConfigForRuntime` was being called without an audit access context, so company secret resolution could not reliably attribute the request to the acting user or agent. The change keeps `skipUserSecrets: true` intact and only restores audit fidelity. ## What Changed - Threaded `buildActorSecretContext(req, { consumerType: "agent", consumerId })` into `GET /agents/:id/skills` - Threaded the same actor context into `POST /agents/:id/skills/sync` - Updated the route tests to assert a non-`undefined` actor context reaches the resolver while `skipUserSecrets: true` stays unchanged ## Verification - `tsc --noEmit` - `agents` and `secrets` Vitest suites: 33 files / 448 tests green - Route spy assertions confirm both skills routes now pass an actor-derived context to the resolver ## Risks - Low risk: the change is limited to audit context propagation on two skills routes - If a downstream resolver assumes the third argument can be `undefined`, this makes the context explicit on these routes - The user-secret authorization behavior does not change because `skipUserSecrets` remains true ## Model Used OpenAI GPT-5 via Codex, tool-using coding agent, 256k context window ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Harold Kim Co-authored-by: Paperclip --- .../src/__tests__/agent-skills-routes.test.ts | 67 ++++++++++++++++++- server/src/routes/agents.ts | 4 +- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 9cffa8f2a6..3728a77e7c 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -403,7 +403,16 @@ describe.sequential("agent skill routes", () => { opts?: { skipUserSecrets?: boolean }, ) => { expect(config).toBe(adapterConfig); - expect(context).toBeUndefined(); + // Audit-only actor context is threaded through for company `secret_ref` + // attribution; user secrets are still skipped (skipUserSecrets: true). + expect(context).toEqual({ + consumerType: "agent", + consumerId: "11111111-1111-4111-8111-111111111111", + actorType: "user", + actorId: "local-board", + actorSource: "local_implicit", + responsibleUserId: "local-board", + }); expect(opts).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); return { config: { env: { HOME: "/home/agent" } } }; }, @@ -427,6 +436,51 @@ describe.sequential("agent skill routes", () => { ); }); + it("threads a non-undefined actor secret context into resolveAdapterConfigForRuntime on both skills routes (audit fidelity, skipUserSecrets preserved)", async () => { + const expectedContext = { + consumerType: "agent", + consumerId: "11111111-1111-4111-8111-111111111111", + actorType: "user", + actorId: "local-board", + actorSource: "local_implicit", + responsibleUserId: "local-board", + }; + + // GET /agents/:id/skills + mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); + const listRes = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), + ); + expect(listRes.status, JSON.stringify(listRes.body)).toBe(200); + const listCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); + expect(listCall?.[2]).toBeDefined(); + expect(listCall?.[2]).toEqual(expectedContext); + expect(listCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); + + // POST /agents/:id/skills/sync + mockAdapter.syncSkills.mockResolvedValue({ + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + const syncRes = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclip"] }), + ); + expect(syncRes.status, JSON.stringify(syncRes.body)).toBe(200); + const syncCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); + expect(syncCall?.[2]).toBeDefined(); + expect(syncCall?.[2]).toEqual(expectedContext); + expect(syncCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); + }); + it("skips runtime materialization when listing Codex skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); mockAdapter.listSkills.mockResolvedValue({ @@ -662,7 +716,16 @@ describe.sequential("agent skill routes", () => { type: "user_secret_ref", key: "github_pat_read_only", }); - expect(context).toBeUndefined(); + // Audit-only actor context is threaded through for company `secret_ref` + // attribution; user secrets are still skipped (skipUserSecrets: true). + expect(context).toEqual({ + consumerType: "agent", + consumerId: "11111111-1111-4111-8111-111111111111", + actorType: "user", + actorId: "local-board", + actorSource: "local_implicit", + responsibleUserId: "local-board", + }); expect(opts).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); return { config: { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f034ce5324..32fbf97476 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1860,7 +1860,7 @@ export function agentRoutes( const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( agent.companyId, agent.adapterConfig, - undefined, + buildActorSecretContext(req, { consumerType: "agent", consumerId: agent.id }), { adapterType: agent.adapterType, skipUserSecrets: true }, ); const runtimeSkillConfig = await buildRuntimeSkillConfig( @@ -1925,7 +1925,7 @@ export function agentRoutes( const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( updated.companyId, updated.adapterConfig, - undefined, + buildActorSecretContext(req, { consumerType: "agent", consumerId: updated.id }), { adapterType: updated.adapterType, skipUserSecrets: true }, ); const runtimeSkillConfig = {