feat: track agent API key usage

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 18:31:56 +00:00
1 parent a4aac781b7
commit 8b288dbc1e
7 files changed
+211 -50

No files matched your search

@@ -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<Record<string, unknown>> = [];
@@ -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<Record<string, unknown>> = [];
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<string, unknown>) {
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,
+26 -10
View File
@@ -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",
};
+2
View File
@@ -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,
}))),
+1
View File
@@ -28,6 +28,7 @@ export interface AgentKey {
id: string;
name: string;
scope: AgentApiKeyScope;
lastUsedAt: Date | null;
createdAt: Date;
revokedAt: Date | null;
}
@@ -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", () => {
+98 -12
View File
@@ -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<AgentConfigurationSectionId> {
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),
);
}
+33 -20
View File
@@ -4278,26 +4278,39 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
<h3 className="text-xs font-medium text-muted-foreground mb-2">
Active Keys
</h3>
<div className="border border-border rounded-lg divide-y divide-border">
{activeKeys.map((key: AgentKey) => (
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
<div>
<span className="text-sm font-medium">{key.name}</span>
<span className="text-xs text-muted-foreground ml-3">
Created {formatDate(key.createdAt)}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs"
onClick={() => revokeKey.mutate(key.id)}
disabled={revokeKey.isPending}
>
Revoke
</Button>
</div>
))}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left">
<thead className="border-b border-border text-xs font-medium text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Last used</th>
<th className="px-4 py-2"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{activeKeys.map((key: AgentKey) => (
<tr key={key.id}>
<td className="whitespace-nowrap px-4 py-2.5 text-sm font-medium">{key.name}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-xs text-muted-foreground">{formatDate(key.createdAt)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-xs font-mono text-muted-foreground" title={key.lastUsedAt ? formatDate(key.lastUsedAt) : undefined}>
{key.lastUsedAt ? relativeTime(key.lastUsedAt) : "—"}
</td>
<td className="px-4 py-2.5 text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive text-xs"
onClick={() => revokeKey.mutate(key.id)}
disabled={revokeKey.isPending}
>
Revoke
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}