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 <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-23 15:31:55 -07:00 committed by GitHub
parent 58ae799dbd
commit e41ba306c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 67 additions and 4 deletions

View File

@ -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: {

View File

@ -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 = {