diff --git a/README.md b/README.md index f3210c5..34143f2 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ All endpoints require authentication unless noted. Full reference below. | `GET` | `/api/agents` | viewer | List agents with task stats | | `POST` | `/api/agents` | operator | Register/update agent | | `GET` | `/api/agents/[id]` | viewer | Agent details | +| `GET` | `/api/agents/[id]/attribution` | viewer | Self-scope attribution/audit/cost report (`?privileged=1` admin override) | | `POST` | `/api/agents/sync` | operator | Sync agents from openclaw.json | | `GET/PUT` | `/api/agents/[id]/soul` | operator | Agent SOUL content (reads from workspace, writes to both) | | `GET/POST` | `/api/agents/comms` | operator | Agent inter-agent communication | @@ -225,6 +226,14 @@ All endpoints require authentication unless noted. Full reference below. +### Attribution Contract (`/api/agents/[id]/attribution`) + +- Self-scope by default: requester identity must match target agent via `x-agent-name` (or matching authenticated username). +- Admin override requires explicit `?privileged=1`. +- Query params: + - `hours`: integer window `1..720` (default `24`) + - `section`: comma-separated subset of `identity,audit,mutations,cost` (default all) +
Monitoring diff --git a/openapi.json b/openapi.json index 639b75f..a041c78 100644 --- a/openapi.json +++ b/openapi.json @@ -2917,6 +2917,86 @@ } } }, + "/api/agents/{id}/attribution": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get attribution report for an agent", + "description": "Self-scope by default. Requester must match target agent (`x-agent-name` or username), unless admin uses `?privileged=1`.", + "operationId": "getAgentAttribution", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "hours", + "in": "query", + "required": false, + "description": "Time window in hours, integer range 1..720. Defaults to 24.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 24 + } + }, + { + "name": "section", + "in": "query", + "required": false, + "description": "Comma-separated subset of identity,audit,mutations,cost. Defaults to all.", + "schema": { + "type": "string", + "example": "identity,audit" + } + }, + { + "name": "privileged", + "in": "query", + "required": false, + "description": "Set to 1 for admin override of self-scope checks.", + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + }, + { + "name": "x-agent-name", + "in": "header", + "required": false, + "description": "Attribution identity header used for self-scope authorization.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Attribution report" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, "/api/gateway-config": { "get": { "tags": [ diff --git a/src/app/api/agents/[id]/attribution/route.ts b/src/app/api/agents/[id]/attribution/route.ts new file mode 100644 index 0000000..53f3456 --- /dev/null +++ b/src/app/api/agents/[id]/attribution/route.ts @@ -0,0 +1,356 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; +import { logger } from '@/lib/logger'; + +const ALLOWED_SECTIONS = new Set(['identity', 'audit', 'mutations', 'cost']); + +/** + * GET /api/agents/[id]/attribution - Agent-Level Identity & Attribution + * + * Returns a comprehensive audit trail and cost attribution report for + * a specific agent. Enables per-agent observability, debugging, and + * cost analysis in multi-agent environments. + * + * Query params: + * hours - Time window (default: 24, max: 720) + * section - Comma-separated: audit,cost,mutations,identity (default: all) + * + * Response: + * identity - Agent profile, status, and session info + * audit - Full audit trail of agent actions + * mutations - Task/memory/soul changes attributed to this agent + * cost - Token usage and cost breakdown per model + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer'); + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const db = getDatabase(); + const resolvedParams = await params; + const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; + + // Resolve agent + let agent: any; + if (/^\d+$/.test(agentId)) { + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); + } else { + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); + } + + if (!agent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + + const { searchParams } = new URL(request.url); + const privileged = searchParams.get('privileged') === '1'; + const isSelfByHeader = auth.user.agent_name === agent.name; + const isSelfByUsername = auth.user.username === agent.name; + const isSelf = isSelfByHeader || isSelfByUsername; + const isPrivileged = auth.user.role === 'admin' && privileged; + if (!isSelf && !isPrivileged) { + return NextResponse.json( + { error: 'Forbidden: attribution is self-scope by default. Admin can use ?privileged=1 override.' }, + { status: 403 } + ); + } + + const hoursRaw = searchParams.get('hours'); + const hours = parseHours(hoursRaw); + if (!hours) { + return NextResponse.json({ error: 'Invalid hours. Expected integer 1..720.' }, { status: 400 }); + } + + const sections = parseSections(searchParams.get('section')); + if ('error' in sections) { + return NextResponse.json({ error: sections.error }, { status: 400 }); + } + + const now = Math.floor(Date.now() / 1000); + const since = now - hours * 3600; + + const result: Record = { + agent_name: agent.name, + timeframe: { hours, since, until: now }, + access_scope: isSelf ? 'self' : 'privileged', + }; + + if (sections.sections.has('identity')) { + result.identity = buildIdentity(db, agent, workspaceId); + } + + if (sections.sections.has('audit')) { + result.audit = buildAuditTrail(db, agent.name, workspaceId, since); + } + + if (sections.sections.has('mutations')) { + result.mutations = buildMutations(db, agent.name, workspaceId, since); + } + + if (sections.sections.has('cost')) { + result.cost = buildCostAttribution(db, agent.name, workspaceId, since); + } + + return NextResponse.json(result); + } catch (error) { + logger.error({ err: error }, 'GET /api/agents/[id]/attribution error'); + return NextResponse.json({ error: 'Failed to fetch attribution data' }, { status: 500 }); + } +} + +/** Agent identity and profile info */ +function buildIdentity(db: any, agent: any, workspaceId: number) { + const config = safeParseJson(agent.config, {}); + + // Count total tasks ever assigned + const taskStats = db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status IN ('assigned', 'in_progress') THEN 1 ELSE 0 END) as active + FROM tasks WHERE assigned_to = ? AND workspace_id = ? + `).get(agent.name, workspaceId) as any; + + // Count comments authored + const commentCount = (db.prepare( + `SELECT COUNT(*) as c FROM comments WHERE author = ? AND workspace_id = ?` + ).get(agent.name, workspaceId) as any).c; + + return { + id: agent.id, + name: agent.name, + role: agent.role, + status: agent.status, + last_seen: agent.last_seen, + last_activity: agent.last_activity, + created_at: agent.created_at, + session_key: agent.session_key ? '***' : null, // Masked for security + has_soul: !!agent.soul_content, + config_keys: Object.keys(config), + lifetime_stats: { + tasks_total: taskStats?.total || 0, + tasks_completed: taskStats?.completed || 0, + tasks_active: taskStats?.active || 0, + comments_authored: commentCount, + }, + }; +} + +/** Audit trail — all activities attributed to this agent */ +function buildAuditTrail(db: any, agentName: string, workspaceId: number, since: number) { + // Activities where this agent is the actor + const activities = db.prepare(` + SELECT id, type, entity_type, entity_id, description, data, created_at + FROM activities + WHERE actor = ? AND workspace_id = ? AND created_at >= ? + ORDER BY created_at DESC + LIMIT 200 + `).all(agentName, workspaceId, since) as any[]; + + // Audit log entries (system-wide, may reference agent) + let auditEntries: any[] = []; + try { + auditEntries = db.prepare(` + SELECT id, action, actor, detail, created_at + FROM audit_log + WHERE (actor = ? OR detail LIKE ?) AND created_at >= ? + ORDER BY created_at DESC + LIMIT 100 + `).all(agentName, `%${agentName}%`, since) as any[]; + } catch { + // audit_log table may not exist + } + + // Group activities by type for summary + const byType: Record = {}; + for (const a of activities) { + byType[a.type] = (byType[a.type] || 0) + 1; + } + + return { + total_activities: activities.length, + by_type: byType, + activities: activities.map(a => ({ + ...a, + data: safeParseJson(a.data, null), + })), + audit_log_entries: auditEntries.map(e => ({ + ...e, + detail: safeParseJson(e.detail, null), + })), + }; +} + +/** Mutations — task changes, comments, status transitions */ +function buildMutations(db: any, agentName: string, workspaceId: number, since: number) { + // Task mutations (created, updated, status changes) + const taskMutations = db.prepare(` + SELECT id, type, entity_type, entity_id, description, data, created_at + FROM activities + WHERE actor = ? AND workspace_id = ? AND created_at >= ? + AND entity_type = 'task' + AND type IN ('task_created', 'task_updated', 'task_status_change', 'task_assigned') + ORDER BY created_at DESC + LIMIT 100 + `).all(agentName, workspaceId, since) as any[]; + + // Comments authored + const comments = db.prepare(` + SELECT c.id, c.task_id, c.content, c.created_at, c.mentions, t.title as task_title + FROM comments c + LEFT JOIN tasks t ON c.task_id = t.id AND t.workspace_id = ? + WHERE c.author = ? AND c.workspace_id = ? AND c.created_at >= ? + ORDER BY c.created_at DESC + LIMIT 50 + `).all(workspaceId, agentName, workspaceId, since) as any[]; + + // Agent status changes (by heartbeat or others) + const statusChanges = db.prepare(` + SELECT id, type, description, data, created_at + FROM activities + WHERE entity_type = 'agent' AND workspace_id = ? + AND created_at >= ? + AND (actor = ? OR description LIKE ?) + ORDER BY created_at DESC + LIMIT 50 + `).all(workspaceId, since, agentName, `%${agentName}%`) as any[]; + + return { + task_mutations: taskMutations.map(m => ({ + ...m, + data: safeParseJson(m.data, null), + })), + comments: comments.map(c => ({ + ...c, + mentions: safeParseJson(c.mentions, []), + content_preview: c.content?.substring(0, 200) || '', + })), + status_changes: statusChanges.map(s => ({ + ...s, + data: safeParseJson(s.data, null), + })), + summary: { + task_mutations_count: taskMutations.length, + comments_count: comments.length, + status_changes_count: statusChanges.length, + }, + }; +} + +/** Cost attribution — token usage per model */ +function buildCostAttribution(db: any, agentName: string, workspaceId: number, since: number) { + try { + const byModel = db.prepare(` + SELECT model, + COUNT(*) as request_count, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens + FROM token_usage + WHERE session_id = ? AND workspace_id = ? AND created_at >= ? + GROUP BY model + ORDER BY (input_tokens + output_tokens) DESC + `).all(agentName, workspaceId, since) as Array<{ + model: string; request_count: number; input_tokens: number; output_tokens: number + }>; + + // Also check session IDs that contain the agent name (e.g. "agentname:cli") + const byModelAlt = db.prepare(` + SELECT model, + COUNT(*) as request_count, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens + FROM token_usage + WHERE session_id LIKE ? AND session_id != ? AND workspace_id = ? AND created_at >= ? + GROUP BY model + ORDER BY (input_tokens + output_tokens) DESC + `).all(`${agentName}:%`, agentName, workspaceId, since) as Array<{ + model: string; request_count: number; input_tokens: number; output_tokens: number + }>; + + // Merge results + const merged = new Map(); + for (const row of [...byModel, ...byModelAlt]) { + const existing = merged.get(row.model); + if (existing) { + existing.request_count += row.request_count; + existing.input_tokens += row.input_tokens; + existing.output_tokens += row.output_tokens; + } else { + merged.set(row.model, { ...row }); + } + } + + const models = Array.from(merged.values()); + const total = models.reduce((acc, r) => ({ + input_tokens: acc.input_tokens + r.input_tokens, + output_tokens: acc.output_tokens + r.output_tokens, + requests: acc.requests + r.request_count, + }), { input_tokens: 0, output_tokens: 0, requests: 0 }); + + // Daily breakdown for trend + const daily = db.prepare(` + SELECT (created_at / 86400) * 86400 as day_bucket, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + COUNT(*) as requests + FROM token_usage + WHERE (session_id = ? OR session_id LIKE ?) AND workspace_id = ? AND created_at >= ? + GROUP BY day_bucket + ORDER BY day_bucket ASC + `).all(agentName, `${agentName}:%`, workspaceId, since) as any[]; + + return { + by_model: models, + total, + daily_trend: daily.map(d => ({ + date: new Date(d.day_bucket * 1000).toISOString().split('T')[0], + ...d, + })), + }; + } catch { + return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 }, daily_trend: [] }; + } +} + +function parseHours(hoursRaw: string | null): number | null { + if (!hoursRaw || hoursRaw.trim() === '') return 24; + if (!/^\d+$/.test(hoursRaw)) return null; + const hours = Number(hoursRaw); + if (!Number.isInteger(hours) || hours < 1 || hours > 720) return null; + return hours; +} + +function parseSections( + sectionRaw: string | null +): { sections: Set } | { error: string } { + const value = (sectionRaw || 'identity,audit,mutations,cost').trim(); + const parsed = value + .split(',') + .map((section) => section.trim()) + .filter(Boolean); + + if (parsed.length === 0) { + return { error: 'Invalid section. Expected one or more of identity,audit,mutations,cost.' }; + } + + const invalid = parsed.filter((section) => !ALLOWED_SECTIONS.has(section)); + if (invalid.length > 0) { + return { error: `Invalid section value(s): ${invalid.join(', ')}` }; + } + + return { sections: new Set(parsed) }; +} + +function safeParseJson(raw: string | null | undefined, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e1517e7..49a553c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -31,6 +31,8 @@ export interface User { created_at: number updated_at: number last_login_at: number | null + /** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */ + agent_name?: string | null } export interface UserSession { @@ -268,12 +270,15 @@ export function deleteUser(id: number): boolean { * For API key auth, returns a synthetic "api" user. */ export function getUserFromRequest(request: Request): User | null { + // Extract agent identity header (optional, for attribution) + const agentName = (request.headers.get('x-agent-name') || '').trim() || null + // Check session cookie const cookieHeader = request.headers.get('cookie') || '' const sessionToken = parseCookie(cookieHeader, 'mc-session') if (sessionToken) { const user = validateSession(sessionToken) - if (user) return user + if (user) return { ...user, agent_name: agentName } } // Check API key - return synthetic user @@ -289,6 +294,7 @@ export function getUserFromRequest(request: Request): User | null { created_at: 0, updated_at: 0, last_login_at: null, + agent_name: agentName, } } diff --git a/tests/agent-attribution.spec.ts b/tests/agent-attribution.spec.ts new file mode 100644 index 0000000..30af759 --- /dev/null +++ b/tests/agent-attribution.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test' +import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers' + +test.describe('Agent Attribution API', () => { + const cleanup: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of cleanup) { + await deleteTestAgent(request, id).catch(() => {}) + } + cleanup.length = 0 + }) + + test('allows self-scope access using x-agent-name attribution header', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const res = await request.get(`/api/agents/${id}/attribution`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.agent_name).toBe(name) + expect(body.access_scope).toBe('self') + }) + + test('denies cross-agent attribution access by default', async ({ request }) => { + const primary = await createTestAgent(request) + const other = await createTestAgent(request) + cleanup.push(primary.id, other.id) + + const res = await request.get(`/api/agents/${primary.id}/attribution`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': other.name }, + }) + + expect(res.status()).toBe(403) + }) + + test('allows privileged override for admin caller', async ({ request }) => { + const primary = await createTestAgent(request) + const other = await createTestAgent(request) + cleanup.push(primary.id, other.id) + + const res = await request.get(`/api/agents/${primary.id}/attribution?privileged=1`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': other.name }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.access_scope).toBe('privileged') + }) + + test('validates section parameter and timeframe hours', async ({ request }) => { + const { id, name } = await createTestAgent(request) + cleanup.push(id) + + const sectionRes = await request.get(`/api/agents/${id}/attribution?section=identity&hours=48`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(sectionRes.status()).toBe(200) + const sectionBody = await sectionRes.json() + expect(sectionBody.timeframe.hours).toBe(48) + expect(sectionBody.identity).toBeDefined() + expect(sectionBody.audit).toBeUndefined() + expect(sectionBody.mutations).toBeUndefined() + expect(sectionBody.cost).toBeUndefined() + + const invalidSection = await request.get(`/api/agents/${id}/attribution?section=unknown`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(invalidSection.status()).toBe(400) + + const invalidHours = await request.get(`/api/agents/${id}/attribution?hours=0`, { + headers: { ...API_KEY_HEADER, 'x-agent-name': name }, + }) + expect(invalidHours.status()).toBe(400) + }) +})