From 8b288dbc1e8311dfa745474ef16fda55baecfc4e Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 18:31:56 +0000 Subject: [PATCH] feat: track agent API key usage Co-Authored-By: Paperclip --- .../__tests__/agent-auth-middleware.test.ts | 52 ++++++++- server/src/middleware/auth.ts | 36 ++++-- server/src/services/agents.ts | 2 + ui/src/api/agents.ts | 1 + .../agent-configuration-shell.test.ts | 7 +- .../components/agent-configuration-shell.tsx | 110 ++++++++++++++++-- ui/src/pages/AgentDetail.tsx | 53 +++++---- 7 files changed, 211 insertions(+), 50 deletions(-) diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts index ff27e685b77..33359c458fc 100644 --- a/server/src/__tests__/agent-auth-middleware.test.ts +++ b/server/src/__tests__/agent-auth-middleware.test.ts @@ -1,7 +1,7 @@ import { createHash, createHmac, randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { activityLog, agentApiKeys, @@ -32,7 +32,7 @@ function createSelectChain(rowsForTable: (table: unknown) => unknown[]) { function createDbState(input: { agent: { id: string; companyId: string; status?: string }; - agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null }; + agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null; lastUsedAt?: Date | null }; run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null }; }) { const activity: Array> = []; @@ -48,6 +48,7 @@ function createDbState(input: { companyId: input.agentKey.companyId, keyHash: input.agentKey.keyHash, responsibleUserId: input.agentKey.responsibleUserId ?? null, + lastUsedAt: input.agentKey.lastUsedAt ?? null, revokedAt: null, scopeConfig: null, } @@ -61,6 +62,7 @@ function createDbState(input: { } : null; + const keyUpdates: Array> = []; const db = { select: () => createSelectChain((table) => { @@ -70,10 +72,14 @@ function createDbState(input: { if (table === heartbeatRuns) return runRow ? [runRow] : []; return []; }), - update: () => ({ - set() { + update: (table: unknown) => ({ + set(values: Record) { return { where() { + if (table === agentApiKeys && keyRow) { + keyUpdates.push(values); + Object.assign(keyRow, values); + } return Promise.resolve([]); }, }; @@ -87,7 +93,7 @@ function createDbState(input: { }), } as any; - return { db, activity }; + return { db, activity, keyUpdates }; } function createApp(db: any) { @@ -163,6 +169,7 @@ describe("agent auth middleware", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; if (originalTtl === undefined) delete process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS; @@ -345,12 +352,44 @@ describe("agent auth middleware", () => { }); }); + it("throttles persisted last-used updates for repeated agent-key authentication", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-22T18:00:00.000Z")); + const companyId = randomUUID(); + const agentId = randomUUID(); + const token = "pcp_test_throttled_agent_key"; + const { db, keyUpdates } = createDbState({ + agent: { id: agentId, companyId }, + agentKey: { + id: randomUUID(), + agentId, + companyId, + keyHash: hashToken(token), + responsibleUserId: "user-key", + lastUsedAt: new Date("2026-07-22T17:58:00.000Z"), + }, + }); + const app = createApp(db); + + const first = await request(app).get("/actor").set("Authorization", `Bearer ${token}`); + vi.setSystemTime(new Date("2026-07-22T18:00:30.000Z")); + const second = await request(app).get("/actor").set("Authorization", `Bearer ${token}`); + vi.setSystemTime(new Date("2026-07-22T18:01:01.000Z")); + const third = await request(app).get("/actor").set("Authorization", `Bearer ${token}`); + + expect([first.status, second.status, third.status]).toEqual([200, 200, 200]); + expect(keyUpdates).toEqual([ + { lastUsedAt: new Date("2026-07-22T18:00:00.000Z") }, + { lastUsedAt: new Date("2026-07-22T18:01:01.000Z") }, + ]); + }); + it("rejects agent keys that lack a responsible user binding and audits the denial", async () => { const companyId = randomUUID(); const agentId = randomUUID(); const keyId = randomUUID(); const token = "pcp_test_agent_key_without_user"; - const { db, activity } = createDbState({ + const { db, activity, keyUpdates } = createDbState({ agent: { id: agentId, companyId }, agentKey: { id: keyId, @@ -367,6 +406,7 @@ describe("agent auth middleware", () => { expect(res.status).toBe(403); expect(res.body.code).toBe("RESPONSIBLE_USER_UNAVAILABLE"); + expect(keyUpdates).toHaveLength(0); expect(activity).toHaveLength(1); expect(activity[0]).toMatchObject({ companyId, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 5a60ccee90e..b27296b042b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,6 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; import type { Request, RequestHandler } from "express"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, lt, or } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -20,6 +20,12 @@ import { boardAuthService } from "../services/board-auth.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; import { forbidden, unprocessable } from "../errors.js"; +const AGENT_API_KEY_LAST_USED_THROTTLE_MS = 60_000; + +function shouldUpdateAgentApiKeyLastUsed(lastUsedAt: Date | null, now: Date): boolean { + return lastUsedAt === null || now.getTime() - lastUsedAt.getTime() > AGENT_API_KEY_LAST_USED_THROTTLE_MS; +} + function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } @@ -325,11 +331,6 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return; } - await db - .update(agentApiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(agentApiKeys.id, key.id)); - const agentRecord = await db .select() .from(agents) @@ -356,6 +357,24 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return; } + const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, { + companyId: key.companyId, + userId: responsibleUserId, + }); + const authenticatedAt = new Date(); + if (shouldUpdateAgentApiKeyLastUsed(key.lastUsedAt, authenticatedAt)) { + await db + .update(agentApiKeys) + .set({ lastUsedAt: authenticatedAt }) + .where(and( + eq(agentApiKeys.id, key.id), + or( + isNull(agentApiKeys.lastUsedAt), + lt(agentApiKeys.lastUsedAt, new Date(authenticatedAt.getTime() - AGENT_API_KEY_LAST_USED_THROTTLE_MS)), + ), + )); + } + req.actor = { type: "agent", agentId: key.agentId, @@ -363,10 +382,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa keyId: key.id, keyScope: normalizeAgentApiKeyScope(key.scopeConfig), onBehalfOfUserId: responsibleUserId, - onBehalfOfMemberships: await loadResponsibleUserMemberships(db, { - companyId: key.companyId, - userId: responsibleUserId, - }), + onBehalfOfMemberships, runId: runIdHeader || undefined, source: "agent_key", }; diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 51546b911cc..00ded758323 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -926,6 +926,7 @@ export function agentService(db: Db) { name: agentApiKeys.name, responsibleUserId: agentApiKeys.responsibleUserId, scopeConfig: agentApiKeys.scopeConfig, + lastUsedAt: agentApiKeys.lastUsedAt, createdAt: agentApiKeys.createdAt, revokedAt: agentApiKeys.revokedAt, }) @@ -936,6 +937,7 @@ export function agentService(db: Db) { name: row.name, scope: normalizeAgentApiKeyScope(row.scopeConfig), responsibleUserId: row.responsibleUserId, + lastUsedAt: row.lastUsedAt, createdAt: row.createdAt, revokedAt: row.revokedAt, }))), diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 45e134c3cd1..5c4e89d38f2 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -28,6 +28,7 @@ export interface AgentKey { id: string; name: string; scope: AgentApiKeyScope; + lastUsedAt: Date | null; createdAt: Date; revokedAt: Date | null; } diff --git a/ui/src/components/agent-configuration-shell.test.ts b/ui/src/components/agent-configuration-shell.test.ts index 62ea0b8e307..8bac7db8028 100644 --- a/ui/src/components/agent-configuration-shell.test.ts +++ b/ui/src/components/agent-configuration-shell.test.ts @@ -12,11 +12,14 @@ import { } from "./agent-configuration-shell"; describe("agent configuration shell", () => { - it("matches both section names and field labels", () => { + it("matches section names, field metadata, help copy, and synonyms", () => { expect([...filterAgentConfigurationSections("")].slice(0, 2)).toEqual(["runtime", "environment"]); - expect([...filterAgentConfigurationSections("heartbeat")]).toEqual(["schedule"]); + expect([...filterAgentConfigurationSections("heartbeat")]).toEqual(["runtime", "schedule"]); expect([...filterAgentConfigurationSections("API Keys")]).toEqual(["keys"]); expect([...filterAgentConfigurationSections("sandbox")]).toEqual(["danger"]); + expect([...filterAgentConfigurationSections("filesystem")]).toEqual(["danger"]); + expect([...filterAgentConfigurationSections("cron")]).toEqual(["schedule"]); + expect([...filterAgentConfigurationSections("periodic tasks")]).toEqual(["schedule"]); }); it("does not guess an inherited adapter model from available choices", () => { diff --git a/ui/src/components/agent-configuration-shell.tsx b/ui/src/components/agent-configuration-shell.tsx index 6b50172e8d6..7d90e0b5e22 100644 --- a/ui/src/components/agent-configuration-shell.tsx +++ b/ui/src/components/agent-configuration-shell.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { Search, Zap } from "lucide-react"; import type { Agent, AgentEnvConfig } from "@paperclipai/shared"; -import { adapterLabels } from "./agent-config-primitives"; +import { adapterLabels, help } from "./agent-config-primitives"; import { Badge } from "@/components/ui/badge"; import { cn } from "../lib/utils"; @@ -14,28 +14,114 @@ export type AgentConfigurationSectionId = | "danger" | "history"; +export type AgentConfigurationSetting = { + key: string; + label: string; + helpText: string; + synonyms?: readonly string[]; +}; + export const agentConfigurationSections: ReadonlyArray<{ id: AgentConfigurationSectionId; label: string; instant?: boolean; - fields: string[]; + settings: readonly AgentConfigurationSetting[]; }> = [ - { id: "runtime", label: "Runtime", fields: ["adapter", "model", "effort", "turns", "command", "arguments", "engine", "timeout", "chrome"] }, - { id: "environment", label: "Environment", fields: ["execution environment", "environment override", "variables", "secrets"] }, - { id: "schedule", label: "Schedule & Runs", fields: ["heartbeat", "wake on demand", "cooldown", "concurrent runs", "continuation"] }, - { id: "access", label: "Access & Governance", instant: true, fields: ["trust preset", "boundary", "create agents", "create skills", "assign tasks"] }, - { id: "keys", label: "API Keys", instant: true, fields: ["credentials", "create key", "revoke key"] }, - { id: "danger", label: "Danger & Legacy", fields: ["skip permissions", "bypass sandbox", "working directory", "deprecated"] }, - { id: "history", label: "History", instant: true, fields: ["configuration revisions", "restore"] }, + { + id: "runtime", + label: "Runtime", + settings: [ + { key: "adapterType", label: "Adapter type", helpText: help.adapterType }, + { key: "model", label: "Model", helpText: help.model }, + { key: "thinkingEffort", label: "Thinking effort", helpText: help.thinkingEffort, synonyms: ["reasoning"] }, + { key: "maxTurnsPerRun", label: "Max turns per run", helpText: help.maxTurnsPerRun }, + { key: "command", label: "Command", helpText: help.localCommand, synonyms: ["arguments", "args", "engine"] }, + { key: "timeoutSec", label: "Timeout", helpText: help.timeoutSec }, + { key: "chrome", label: "Chrome", helpText: help.chrome }, + ], + }, + { + id: "environment", + label: "Environment", + settings: [ + { key: "defaultEnvironmentId", label: "Execution environment", helpText: "Override the company default execution environment for this agent." }, + { key: "env", label: "Environment variables", helpText: help.envVars }, + { key: "secretAccess", label: "Secret access", helpText: help.secretAccess }, + { key: "workspaceStrategy", label: "Workspace strategy", helpText: help.workspaceStrategy, synonyms: ["worktree"] }, + ], + }, + { + id: "schedule", + label: "Schedule & Runs", + settings: [ + { key: "heartbeat.enabled", label: "Heartbeat on interval", helpText: help.heartbeatInterval, synonyms: ["cron", "schedule", "timer"] }, + { key: "heartbeat.intervalSec", label: "Heartbeat interval", helpText: help.intervalSec, synonyms: ["cron", "cadence"] }, + { key: "heartbeat.wakeOnDemand", label: "Wake on demand", helpText: help.wakeOnDemand }, + { key: "heartbeat.cooldownSec", label: "Cooldown", helpText: help.cooldownSec }, + { key: "heartbeat.maxConcurrentRuns", label: "Max concurrent runs", helpText: help.maxConcurrentRuns }, + { key: "heartbeat.maxTurnContinuation", label: "Continue after max-turn stop", helpText: help.maxTurnContinuationEnabled, synonyms: ["continuation"] }, + ], + }, + { + id: "access", + label: "Access & Governance", + instant: true, + settings: [ + { key: "permissions.trustPreset", label: "Trust preset", helpText: "Choose the default governance boundary for this agent." }, + { key: "permissions.canCreateAgents", label: "Create agents", helpText: "Allow this agent to create or hire other agents." }, + { key: "permissions.canCreateSkills", label: "Create skills", helpText: "Allow this agent to create reusable company skills." }, + { key: "permissions.canAssignTasks", label: "Assign tasks", helpText: "Allow this agent to assign tasks to other agents." }, + ], + }, + { + id: "keys", + label: "API Keys", + instant: true, + settings: [ + { key: "apiKeys", label: "API keys", helpText: "Credentials used by this agent to authenticate calls to the Paperclip server.", synonyms: ["credentials", "revoke"] }, + ], + }, + { + id: "danger", + label: "Danger & Legacy", + settings: [ + { key: "dangerouslySkipPermissions", label: "Skip permissions", helpText: help.dangerouslySkipPermissions }, + { key: "dangerouslyBypassSandbox", label: "Bypass sandbox", helpText: help.dangerouslyBypassSandbox, synonyms: ["filesystem", "network"] }, + { key: "cwd", label: "Working directory", helpText: help.cwd, synonyms: ["deprecated", "legacy"] }, + ], + }, + { + id: "history", + label: "History", + instant: true, + settings: [ + { key: "configurationRevisions", label: "Configuration revisions", helpText: "Review and restore earlier saved agent configurations.", synonyms: ["restore", "rollback"] }, + ], + }, ]; +export const agentConfigurationSettingIndex = agentConfigurationSections.flatMap((section) => + section.settings.map((setting) => ({ + ...setting, + section: section.id, + sectionLabel: section.label, + sectionAnchor: `config-${section.id}`, + })), +); + export function filterAgentConfigurationSections(query: string): Set { const normalized = query.trim().toLowerCase(); if (!normalized) return new Set(agentConfigurationSections.map((section) => section.id)); return new Set( - agentConfigurationSections - .filter((section) => section.label.toLowerCase().includes(normalized) || section.fields.some((field) => field.includes(normalized))) - .map((section) => section.id), + agentConfigurationSettingIndex + .filter((setting) => [ + setting.key, + setting.label, + setting.helpText, + setting.sectionLabel, + ...(setting.synonyms ?? []), + ].some((value) => value.toLowerCase().includes(normalized))) + .map((setting) => setting.section), ); } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 91ac8e6e509..d674a02e3a2 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -4278,26 +4278,39 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }

Active Keys

-
- {activeKeys.map((key: AgentKey) => ( -
-
- {key.name} - - Created {formatDate(key.createdAt)} - -
- -
- ))} +
+ + + + + + + + + + + {activeKeys.map((key: AgentKey) => ( + + + + + + + ))} + +
NameCreatedLast usedActions
{key.name}{formatDate(key.createdAt)} + {key.lastUsedAt ? relativeTime(key.lastUsedAt) : "—"} + + +
)}