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:
parent
58ae799dbd
commit
e41ba306c5
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue