From 71fd39ddf3f93577de168b4434ad79f15ff625bf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 7 Apr 2026 11:21:11 -0400 Subject: [PATCH 1/7] feat: add agent monitoring controls (User-Agent, request log, pause/interval) Addresses member feedback about unexpected automated traffic to agent endpoints. Adds proper User-Agent identification, an outbound request log visible to agent owners, and controls to pause or adjust check frequency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/agent-monitoring-controls.md | 4 + server/public/dashboard-agents.html | 125 ++++++++++++++++++ server/src/adagents-manager.ts | 7 +- server/src/addie/jobs/compliance-heartbeat.ts | 20 +++ server/src/addie/jobs/job-definitions.ts | 15 +++ server/src/brand-manager.ts | 3 +- server/src/capabilities.ts | 24 +++- server/src/config/user-agents.ts | 11 ++ server/src/db/compliance-db.ts | 57 ++++++-- .../376_agent_monitoring_controls.sql | 41 ++++++ server/src/db/outbound-log-db.ts | 87 ++++++++++++ server/src/formats.ts | 3 +- server/src/health.ts | 24 +++- server/src/properties.ts | 3 +- server/src/routes/registry-api.ts | 110 +++++++++++++++ server/src/types.ts | 2 + server/src/validator.ts | 3 +- 17 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 .changeset/agent-monitoring-controls.md create mode 100644 server/src/config/user-agents.ts create mode 100644 server/src/db/migrations/376_agent_monitoring_controls.sql create mode 100644 server/src/db/outbound-log-db.ts diff --git a/.changeset/agent-monitoring-controls.md b/.changeset/agent-monitoring-controls.md new file mode 100644 index 0000000000..a3c971ace6 --- /dev/null +++ b/.changeset/agent-monitoring-controls.md @@ -0,0 +1,4 @@ +--- +--- + +Agent monitoring controls: User-Agent headers on all automated outbound requests, outbound request log with dashboard visibility, and owner controls to pause or adjust check frequency. diff --git a/server/public/dashboard-agents.html b/server/public/dashboard-agents.html index a13dd66f22..b1876b65e8 100644 --- a/server/public/dashboard-agents.html +++ b/server/public/dashboard-agents.html @@ -626,16 +626,35 @@

Agents

${clickableTrackPills ? '
' + clickableTrackPills + '
' : ''} ${sparkline} +
+ + +
Last checked: ${escapeHtml(lastChecked)}
${!hasAuth ? 'Connect agent' : ''} +
+ `; }).join(''); @@ -673,6 +692,112 @@

Agents

} }); + // Monitoring pause toggle + document.addEventListener('change', async function(e) { + const toggle = e.target.closest('.monitoring-pause-toggle'); + if (toggle) { + const agentUrl = toggle.dataset.agentUrl; + try { + const res = await fetch(`/api/registry/agents/${encodeURIComponent(agentUrl)}/monitoring/pause`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ paused: toggle.checked }), + }); + if (!res.ok) { + toggle.checked = !toggle.checked; + } + } catch { + toggle.checked = !toggle.checked; + } + } + }); + + // Capture initial value for interval selects on focus (before change fires) + document.addEventListener('focus', function(e) { + const select = e.target.closest('.monitoring-interval-select'); + if (select) { + select.dataset.previousValue = select.value; + } + }, true); + + // Monitoring interval change + document.addEventListener('change', async function(e) { + const select = e.target.closest('.monitoring-interval-select'); + if (select) { + const agentUrl = select.dataset.agentUrl; + const previousValue = select.dataset.previousValue; + select.dataset.previousValue = select.value; + try { + const res = await fetch(`/api/registry/agents/${encodeURIComponent(agentUrl)}/monitoring/interval`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ interval_hours: parseInt(select.value, 10) }), + }); + if (!res.ok) { + select.value = previousValue; + } + } catch { + select.value = previousValue; + } + } + }); + + // View outbound requests log + document.addEventListener('click', async function(e) { + const btn = e.target.closest('.agent-requests-btn'); + if (!btn) return; + + const agentUrl = btn.dataset.agentUrl; + const cardId = btn.dataset.cardId; + const panel = document.getElementById(cardId + '-requests'); + if (!panel) return; + + if (panel.style.display !== 'none') { + panel.style.display = 'none'; + return; + } + + panel.style.display = 'block'; + panel.innerHTML = '
Loading requests...
'; + + try { + const res = await fetch(`/api/registry/agents/${encodeURIComponent(agentUrl)}/monitoring/requests?limit=20`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + + if (data.requests.length === 0) { + panel.innerHTML = '
No outbound requests recorded yet.
'; + return; + } + + const rows = data.requests.map(function(r) { + const time = new Date(r.created_at).toLocaleString(); + const status = r.success + ? 'OK' + : 'ERR'; + const duration = r.response_time_ms != null ? r.response_time_ms + 'ms' : '-'; + return '' + + '' + escapeHtml(time) + '' + + '' + escapeHtml(r.request_type) + '' + + '' + status + '' + + '' + escapeHtml(duration) + '' + + (r.error_message ? '' + escapeHtml(r.error_message) + '' : '') + + ''; + }).join(''); + + panel.innerHTML = '
Outbound Requests (' + data.total + ' total)
' + + '' + + '' + + '' + rows + '
TimeTypeStatusDurationError
'; + } catch { + panel.innerHTML = '
Failed to load requests.
'; + } + }); + // Track pill click — fetch history and show scenario details for that track document.addEventListener('click', async function(e) { const trackBtn = e.target.closest('.agent-track[data-track]'); diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts index 0952ae6dea..0d5337f3f5 100644 --- a/server/src/adagents-manager.ts +++ b/server/src/adagents-manager.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { PropertyDefinition, PlacementDefinition } from './types.js'; +import { AAO_UA_VALIDATOR } from './config/user-agents.js'; export interface ValidationError { field: string; @@ -175,7 +176,7 @@ export class AdAgentsManager { timeout: 10000, headers: { 'Accept': 'application/json', - 'User-Agent': 'AdCP-Testing-Framework/1.0' + 'User-Agent': AAO_UA_VALIDATOR }, validateStatus: () => true, // Don't throw on non-2xx status codes responseType: 'arraybuffer', @@ -314,7 +315,7 @@ export class AdAgentsManager { timeout: 10000, headers: { 'Accept': 'application/json', - 'User-Agent': 'AdCP-Testing-Framework/1.0' + 'User-Agent': AAO_UA_VALIDATOR }, validateStatus: () => true, responseType: 'arraybuffer', @@ -1229,7 +1230,7 @@ export class AdAgentsManager { timeout: 3000, // Keep short for responsive UX headers: { 'Accept': 'application/json', - 'User-Agent': 'AdCP-Testing-Framework/1.0' + 'User-Agent': AAO_UA_VALIDATOR }, validateStatus: () => true }); diff --git a/server/src/addie/jobs/compliance-heartbeat.ts b/server/src/addie/jobs/compliance-heartbeat.ts index 0fb050979f..d2ef9e30df 100644 --- a/server/src/addie/jobs/compliance-heartbeat.ts +++ b/server/src/addie/jobs/compliance-heartbeat.ts @@ -11,6 +11,8 @@ import { query } from '../../db/client.js'; import { notifyComplianceChange } from '../../notifications/compliance.js'; import { notifySystemError } from '../error-notifier.js'; import { logger as baseLogger } from '../../logger.js'; +import { logOutboundRequest } from '../../db/outbound-log-db.js'; +import { AAO_UA_COMPLIANCE } from '../../config/user-agents.js'; const logger = baseLogger.child({ module: 'compliance-heartbeat' }); const complianceDb = new ComplianceDatabase(); @@ -48,6 +50,7 @@ export async function runComplianceHeartbeatJob(options: HeartbeatOptions = {}): ); for (const agent of agentsDue) { + const startTime = Date.now(); try { // Use the owning org's saved credentials from agent_contexts. // These are credentials the owner saved when connecting through Addie. @@ -62,6 +65,14 @@ export async function runComplianceHeartbeatJob(options: HeartbeatOptions = {}): const complianceResult = await comply(agent.agent_url, complyOptions); + logOutboundRequest({ + agent_url: agent.agent_url, + request_type: 'compliance', + user_agent: AAO_UA_COMPLIANCE, + response_time_ms: Date.now() - startTime, + success: true, + }); + // Map track results to storage format const tracksJson: TrackSummaryEntry[] = complianceResult.tracks.map(t => ({ track: t.track, @@ -127,6 +138,15 @@ export async function runComplianceHeartbeatJob(options: HeartbeatOptions = {}): } catch (error) { logger.error({ error, agentUrl: agent.agent_url }, 'Compliance check failed for agent'); + logOutboundRequest({ + agent_url: agent.agent_url, + request_type: 'compliance', + user_agent: AAO_UA_COMPLIANCE, + response_time_ms: Date.now() - startTime, + success: false, + error_message: error instanceof Error ? error.message : 'Unknown error', + }); + // Record failure so stale passing data doesn't persist try { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/server/src/addie/jobs/job-definitions.ts b/server/src/addie/jobs/job-definitions.ts index 64825d7a52..2a9977f401 100644 --- a/server/src/addie/jobs/job-definitions.ts +++ b/server/src/addie/jobs/job-definitions.ts @@ -378,6 +378,20 @@ export function registerAllJobs(): void { shouldLogResult: (r) => r.checked > 0, }); + // Outbound request log cleanup - retain 30 days + jobScheduler.register({ + name: 'outbound-log-cleanup', + description: 'Clean up old outbound request logs', + interval: { value: 24, unit: 'hours' }, + initialDelay: { value: 60, unit: 'minutes' }, + runner: async () => { + const { cleanupOldRequests } = await import('../../db/outbound-log-db.js'); + const deleted = await cleanupOldRequests(30); + return { deleted }; + }, + shouldLogResult: (r: { deleted: number }) => r.deleted > 0, + }); + // Shadow evaluator - generates what Addie would have said and compares with human answers jobScheduler.register({ name: 'shadow-evaluator', @@ -536,4 +550,5 @@ export const JOB_NAMES = { SHADOW_EVALUATOR: 'shadow-evaluator', KNOWLEDGE_GAP_CLOSER: 'knowledge-gap-closer', BRAND_REGISTRY_SWEEP: 'brand-registry-sweep', + OUTBOUND_LOG_CLEANUP: 'outbound-log-cleanup', } as const; diff --git a/server/src/brand-manager.ts b/server/src/brand-manager.ts index 03dabe24bc..c3e3bf7c95 100644 --- a/server/src/brand-manager.ts +++ b/server/src/brand-manager.ts @@ -9,6 +9,7 @@ import type { ResolvedBrand, KellerType, } from './types'; +import { AAO_UA_VALIDATOR } from './config/user-agents.js'; export interface BrandValidationError { field: string; @@ -168,7 +169,7 @@ export class BrandManager { timeout: 10000, headers: { Accept: 'application/json', - 'User-Agent': 'AdCP-Brand-Validator/1.0', + 'User-Agent': AAO_UA_VALIDATOR, }, validateStatus: () => true, responseType: 'arraybuffer', diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts index c698457975..876f36567d 100644 --- a/server/src/capabilities.ts +++ b/server/src/capabilities.ts @@ -2,6 +2,8 @@ import type { Agent } from "./types.js"; import { FormatsService } from "./formats.js"; import { createLogger } from "./logger.js"; import { is401Error, AuthenticationRequiredError } from "@adcp/client"; +import { AAO_UA_DISCOVERY } from "./config/user-agents.js"; +import { logOutboundRequest } from "./db/outbound-log-db.js"; const logger = createLogger('capabilities'); @@ -66,10 +68,19 @@ export class CapabilityDiscovery { return cached; } + const startTime = Date.now(); try { const protocol = agent.protocol || "mcp"; const tools = await this.discoverTools(agent.url, protocol); + logOutboundRequest({ + agent_url: agent.url, + request_type: 'discovery', + user_agent: AAO_UA_DISCOVERY, + response_time_ms: Date.now() - startTime, + success: true, + }); + const profile: AgentCapabilityProfile = { agent_url: agent.url, protocol, @@ -93,6 +104,15 @@ export class CapabilityDiscovery { this.cache.set(agent.url, profile); return profile; } catch (error: any) { + logOutboundRequest({ + agent_url: agent.url, + request_type: 'discovery', + user_agent: AAO_UA_DISCOVERY, + response_time_ms: Date.now() - startTime, + success: false, + error_message: error.message, + }); + const isOAuthError = error instanceof AuthenticationRequiredError; const errorProfile: AgentCapabilityProfile = { agent_url: agent.url, @@ -127,7 +147,7 @@ export class CapabilityDiscovery { name: "Discovery Client", agent_uri: url, protocol: "mcp", - }]); + }], { userAgent: AAO_UA_DISCOVERY }); const client = multiClient.agent("discovery"); const agentInfo = await client.getAgentInfo(); @@ -164,7 +184,7 @@ export class CapabilityDiscovery { name: "Discovery Client", agent_uri: url, protocol: "a2a", - }]); + }], { userAgent: AAO_UA_DISCOVERY }); const client = multiClient.agent("discovery"); const agentInfo = await client.getAgentInfo(); diff --git a/server/src/config/user-agents.ts b/server/src/config/user-agents.ts new file mode 100644 index 0000000000..40604bc5eb --- /dev/null +++ b/server/src/config/user-agents.ts @@ -0,0 +1,11 @@ +/** + * User-Agent strings for automated outbound requests to agent endpoints. + * Follows RFC 9110 convention with a +URL suffix pointing to documentation. + */ + +const INFO_URL = 'https://agenticadvertising.org/docs/monitoring'; + +export const AAO_UA_HEALTH_CHECK = `AAO-HealthCheck/1.0 (+${INFO_URL})`; +export const AAO_UA_DISCOVERY = `AAO-Discovery/1.0 (+${INFO_URL})`; +export const AAO_UA_COMPLIANCE = `AAO-ComplianceCheck/1.0 (+${INFO_URL})`; +export const AAO_UA_VALIDATOR = `AAO-Validator/1.0 (+${INFO_URL})`; diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts index c4aa97332f..752c3428ca 100644 --- a/server/src/db/compliance-db.ts +++ b/server/src/db/compliance-db.ts @@ -19,6 +19,9 @@ export interface AgentRegistryMetadata { lifecycle_stage: LifecycleStage; platform_type: string | null; compliance_opt_out: boolean; + monitoring_paused: boolean; + check_interval_hours: number; + monitoring_paused_at: Date | null; created_at: Date; updated_at: Date; } @@ -326,6 +329,7 @@ export class ComplianceDatabase { /** * Find agents that are due for a compliance check based on their lifecycle stage. * Joins federated agents (from discovered_agents + member profiles) with metadata and status. + * Respects owner-configured check_interval_hours and monitoring_paused. */ async getAgentsDueForCheck(limit: number = 10): Promise COALESCE(m.check_interval_hours, + CASE WHEN COALESCE(m.lifecycle_stage, 'production') = 'testing' THEN 24 ELSE 12 END + )) ) ORDER BY s.last_checked_at ASC NULLS FIRST LIMIT $1`, @@ -366,6 +366,47 @@ export class ComplianceDatabase { return result.rows; } + // ----- Monitoring Settings ----- + + async getMonitoringSettings(agentUrl: string): Promise<{ + monitoring_paused: boolean; + check_interval_hours: number; + monitoring_paused_at: Date | null; + }> { + const result = await query( + `SELECT monitoring_paused, check_interval_hours, monitoring_paused_at + FROM agent_registry_metadata WHERE agent_url = $1`, + [agentUrl], + ); + if (result.rows.length === 0) { + return { monitoring_paused: false, check_interval_hours: 12, monitoring_paused_at: null }; + } + return result.rows[0]; + } + + async updateMonitoringPaused(agentUrl: string, paused: boolean): Promise { + await query( + `INSERT INTO agent_registry_metadata (agent_url, monitoring_paused, monitoring_paused_at) + VALUES ($1, $2, CASE WHEN $2 THEN NOW() ELSE NULL END) + ON CONFLICT (agent_url) DO UPDATE SET + monitoring_paused = $2, + monitoring_paused_at = CASE WHEN $2 THEN NOW() ELSE NULL END, + updated_at = NOW()`, + [agentUrl, paused], + ); + } + + async updateCheckInterval(agentUrl: string, intervalHours: number): Promise { + await query( + `INSERT INTO agent_registry_metadata (agent_url, check_interval_hours) + VALUES ($1, $2) + ON CONFLICT (agent_url) DO UPDATE SET + check_interval_hours = $2, + updated_at = NOW()`, + [agentUrl, intervalHours], + ); + } + // ----- Helpers ----- /** diff --git a/server/src/db/migrations/376_agent_monitoring_controls.sql b/server/src/db/migrations/376_agent_monitoring_controls.sql new file mode 100644 index 0000000000..07913116ce --- /dev/null +++ b/server/src/db/migrations/376_agent_monitoring_controls.sql @@ -0,0 +1,41 @@ +-- Agent monitoring controls: outbound request logging and owner-configurable check frequency. + +-- Log of automated outbound requests AAO makes to agent endpoints. +CREATE TABLE IF NOT EXISTS agent_outbound_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_url TEXT NOT NULL, + request_type TEXT NOT NULL, + user_agent TEXT NOT NULL, + response_time_ms INTEGER, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_request_type CHECK ( + request_type IN ('health_check', 'discovery', 'compliance', 'crawl', 'validation') + ) +); + +CREATE INDEX IF NOT EXISTS idx_outbound_requests_agent_time + ON agent_outbound_requests(agent_url, created_at DESC); + +-- For retention cleanup +CREATE INDEX IF NOT EXISTS idx_outbound_requests_created + ON agent_outbound_requests(created_at); + +-- Owner-configurable monitoring controls on existing metadata table. +ALTER TABLE agent_registry_metadata + ADD COLUMN IF NOT EXISTS monitoring_paused BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE agent_registry_metadata + ADD COLUMN IF NOT EXISTS check_interval_hours INTEGER NOT NULL DEFAULT 12; + +-- Runs after the column is added above; safe since migration runs exactly once. +DO $$ BEGIN + ALTER TABLE agent_registry_metadata + ADD CONSTRAINT valid_check_interval CHECK (check_interval_hours BETWEEN 6 AND 168); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE agent_registry_metadata + ADD COLUMN IF NOT EXISTS monitoring_paused_at TIMESTAMPTZ; diff --git a/server/src/db/outbound-log-db.ts b/server/src/db/outbound-log-db.ts new file mode 100644 index 0000000000..3a96c4ec39 --- /dev/null +++ b/server/src/db/outbound-log-db.ts @@ -0,0 +1,87 @@ +import { query } from './client.js'; +import { logger as baseLogger } from '../logger.js'; + +const logger = baseLogger.child({ module: 'outbound-log' }); + +export type OutboundRequestType = 'health_check' | 'discovery' | 'compliance' | 'crawl' | 'validation'; + +export interface OutboundRequestEntry { + agent_url: string; + request_type: OutboundRequestType; + user_agent: string; + response_time_ms?: number; + success: boolean; + error_message?: string; +} + +export interface OutboundRequestRow extends OutboundRequestEntry { + id: string; + created_at: string; +} + +/** + * Fire-and-forget insert. Failures are logged but never propagated + * so monitoring never blocks the request path. + */ +export function logOutboundRequest(entry: OutboundRequestEntry): void { + query( + `INSERT INTO agent_outbound_requests + (agent_url, request_type, user_agent, response_time_ms, success, error_message) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + entry.agent_url, + entry.request_type, + entry.user_agent, + entry.response_time_ms ?? null, + entry.success, + entry.error_message ?? null, + ], + ).catch(err => { + logger.warn({ err, agentUrl: entry.agent_url }, 'Failed to log outbound request'); + }); +} + +export async function getRequestLog( + agentUrl: string, + options: { limit?: number; since?: string } = {}, +): Promise { + const limit = Math.max(1, Math.min(options.limit ?? 50, 200)); + const params: unknown[] = [agentUrl, limit]; + let whereClause = 'WHERE agent_url = $1'; + + if (options.since) { + const sinceDate = new Date(options.since); + if (!isNaN(sinceDate.getTime())) { + whereClause += ' AND created_at >= $3'; + params.push(sinceDate.toISOString()); + } + } + + const result = await query( + `SELECT id, agent_url, request_type, user_agent, response_time_ms, + success, error_message, created_at + FROM agent_outbound_requests + ${whereClause} + ORDER BY created_at DESC + LIMIT $2`, + params, + ); + return result.rows; +} + +export async function getRequestCount(agentUrl: string): Promise { + const result = await query( + 'SELECT COUNT(*) AS count FROM agent_outbound_requests WHERE agent_url = $1', + [agentUrl], + ); + return parseInt(result.rows[0]?.count ?? '0', 10); +} + +export async function cleanupOldRequests(retentionDays: number = 30): Promise { + const days = Math.max(1, Math.floor(retentionDays)); + const result = await query( + `DELETE FROM agent_outbound_requests WHERE created_at < NOW() - make_interval(days => $1)`, + [days], + ); + return result.rowCount ?? 0; +} diff --git a/server/src/formats.ts b/server/src/formats.ts index edcaf76eef..b0cb90b1a9 100644 --- a/server/src/formats.ts +++ b/server/src/formats.ts @@ -1,5 +1,6 @@ import { AdCPClient } from "@adcp/client"; import type { Agent, FormatInfo } from "./types.js"; +import { AAO_UA_DISCOVERY } from "./config/user-agents.js"; export interface AgentFormatsProfile { agent_url: string; @@ -29,7 +30,7 @@ export class FormatsService { agent_uri: agent.url, protocol: (agent.protocol || "mcp") as "mcp" | "a2a", }; - const multiClient = new AdCPClient([agentConfig]); + const multiClient = new AdCPClient([agentConfig], { userAgent: AAO_UA_DISCOVERY }); const client = multiClient.agent(agent.name); const result = await client.executeTask("list_creative_formats", {}); diff --git a/server/src/health.ts b/server/src/health.ts index 3e9f41a320..f3da821ef2 100644 --- a/server/src/health.ts +++ b/server/src/health.ts @@ -2,6 +2,8 @@ import type { Agent, AgentHealth, AgentStats } from "./types.js"; import { Cache } from "./cache.js"; import { getPropertyIndex } from "@adcp/client"; import { FormatsService } from "./formats.js"; +import { AAO_UA_HEALTH_CHECK } from "./config/user-agents.js"; +import { logOutboundRequest } from "./db/outbound-log-db.js"; export class HealthChecker { private healthCache: Cache; @@ -28,11 +30,20 @@ export class HealthChecker { const protocol = agent.protocol || "mcp"; // Only try the protocol the agent declares - if (protocol === "a2a") { - return await this.tryA2A(agent, startTime); - } else { - return await this.tryMCP(agent, startTime); - } + const health = protocol === "a2a" + ? await this.tryA2A(agent, startTime) + : await this.tryMCP(agent, startTime); + + logOutboundRequest({ + agent_url: agent.url, + request_type: 'health_check', + user_agent: AAO_UA_HEALTH_CHECK, + response_time_ms: health.response_time_ms ?? (Date.now() - startTime), + success: health.online, + error_message: health.error, + }); + + return health; } private async tryMCP(agent: Agent, startTime: number): Promise { @@ -44,7 +55,7 @@ export class HealthChecker { name: "Health Checker", agent_uri: agent.url, protocol: "mcp", - }]); + }], { userAgent: AAO_UA_HEALTH_CHECK }); const client = multiClient.agent("health-check"); const agentInfo = await client.getAgentInfo(); @@ -71,6 +82,7 @@ export class HealthChecker { // Check for A2A agent card at /.well-known/agent.json const agentCardUrl = `${agent.url.replace(/\/$/, "")}/.well-known/agent.json`; const response = await fetch(agentCardUrl, { + headers: { 'User-Agent': AAO_UA_HEALTH_CHECK }, signal: AbortSignal.timeout(5000), }); diff --git a/server/src/properties.ts b/server/src/properties.ts index c7d8a8a655..fa7a420bb7 100644 --- a/server/src/properties.ts +++ b/server/src/properties.ts @@ -1,5 +1,6 @@ import { AdCPClient } from "@adcp/client"; import type { Agent } from "./types.js"; +import { AAO_UA_DISCOVERY } from "./config/user-agents.js"; import { AgentValidator } from "./validator.js"; export interface PropertyInfo { @@ -48,7 +49,7 @@ export class PropertiesService { agent_uri: agent.url, protocol: (agent.protocol || "mcp") as "mcp" | "a2a", }; - const multiClient = new AdCPClient([agentConfig]); + const multiClient = new AdCPClient([agentConfig], { userAgent: AAO_UA_DISCOVERY }); const client = multiClient.agent(agent.name); const result = await client.executeTask("list_authorized_properties", {}); diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index b2b60683e4..41d1c6a160 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -54,6 +54,7 @@ import { PropertyCheckService } from "../services/property-check.js"; import { PropertyCheckDatabase } from "../db/property-check-db.js"; import { BulkPropertyCheckService } from "../services/bulk-property-check.js"; import { ComplianceDatabase, type LifecycleStage } from "../db/compliance-db.js"; +import { getRequestLog, getRequestCount } from "../db/outbound-log-db.js"; const logger = createLogger("registry-api"); const propertyCheckService = new PropertyCheckService(); @@ -2222,6 +2223,8 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { streak_days: cs.streak_days, last_checked_at: cs.last_checked_at?.toISOString() || null, headline: cs.headline, + monitoring_paused: meta?.monitoring_paused ?? false, + check_interval_hours: meta?.check_interval_hours ?? 12, }; } } @@ -2427,7 +2430,114 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { } }); + // ── Agent Monitoring Controls ────────────────────────────────── + router.get("/registry/agents/:encodedUrl/monitoring/settings", ...complianceWriteMiddleware, async (req, res) => { + try { + const agentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(agentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + const isOwner = await verifyAgentOwnership(req.user.id, agentUrl); + if (!isOwner) { + return res.status(403).json({ error: "You do not have permission to view this agent" }); + } + + const settings = await complianceDb.getMonitoringSettings(agentUrl); + res.json(settings); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to get monitoring settings"); + res.status(500).json({ error: "Failed to get monitoring settings" }); + } + }); + + router.put("/registry/agents/:encodedUrl/monitoring/pause", ...complianceWriteMiddleware, async (req, res) => { + try { + const agentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(agentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + const isOwner = await verifyAgentOwnership(req.user.id, agentUrl); + if (!isOwner) { + return res.status(403).json({ error: "You do not have permission to modify this agent" }); + } + + const { paused } = req.body; + if (typeof paused !== "boolean") { + return res.status(400).json({ error: "paused must be a boolean" }); + } + + await complianceDb.updateMonitoringPaused(agentUrl, paused); + const settings = await complianceDb.getMonitoringSettings(agentUrl); + res.json(settings); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to update monitoring pause"); + res.status(500).json({ error: "Failed to update monitoring pause" }); + } + }); + + router.put("/registry/agents/:encodedUrl/monitoring/interval", ...complianceWriteMiddleware, async (req, res) => { + try { + const agentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(agentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + const isOwner = await verifyAgentOwnership(req.user.id, agentUrl); + if (!isOwner) { + return res.status(403).json({ error: "You do not have permission to modify this agent" }); + } + + const { interval_hours } = req.body; + if (typeof interval_hours !== "number" || interval_hours < 6 || interval_hours > 168) { + return res.status(400).json({ error: "interval_hours must be a number between 6 and 168" }); + } + + await complianceDb.updateCheckInterval(agentUrl, interval_hours); + const settings = await complianceDb.getMonitoringSettings(agentUrl); + res.json(settings); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to update check interval"); + res.status(500).json({ error: "Failed to update check interval" }); + } + }); + + router.get("/registry/agents/:encodedUrl/monitoring/requests", ...complianceWriteMiddleware, async (req, res) => { + try { + const agentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(agentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + const isOwner = await verifyAgentOwnership(req.user.id, agentUrl); + if (!isOwner) { + return res.status(403).json({ error: "You do not have permission to view this agent" }); + } + + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const since = typeof req.query.since === "string" ? req.query.since : undefined; + + const [requests, total] = await Promise.all([ + getRequestLog(agentUrl, { limit, since }), + getRequestCount(agentUrl), + ]); + + res.json({ agent_url: agentUrl, requests, count: requests.length, total }); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to get monitoring requests"); + res.status(500).json({ error: "Failed to get monitoring requests" }); + } + }); // ── Storyboards ──────────────────────────────────────────────── diff --git a/server/src/types.ts b/server/src/types.ts index 723a93833c..d6569433d7 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -962,6 +962,8 @@ export interface AgentCompliance { streak_days: number; last_checked_at: string | null; headline: string | null; + monitoring_paused: boolean; + check_interval_hours: number; } // Federated Discovery Types diff --git a/server/src/validator.ts b/server/src/validator.ts index 4469c22ee1..1911ac50ac 100644 --- a/server/src/validator.ts +++ b/server/src/validator.ts @@ -12,6 +12,7 @@ import type { PublisherPropertySelector, } from "./types.js"; import { Cache } from "./cache.js"; +import { AAO_UA_VALIDATOR } from "./config/user-agents.js"; interface FetchResult { data?: AdAgentsJson; @@ -164,7 +165,7 @@ export class AgentValidator { // lgtm[js/request-forgery] -- fetch target is restricted to HTTPS and public internet hosts only. const response = await fetch(url, { - headers: { "User-Agent": "AdCP-Registry/1.0" }, + headers: { "User-Agent": AAO_UA_VALIDATOR }, signal: AbortSignal.timeout(5000), }); From ca9dbb1b380de72faad6d29ad4f2070fd6940f0c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 8 Apr 2026 07:38:00 -0400 Subject: [PATCH 2/7] feat: wire userAgent through PropertyCrawler and comply() via @adcp/client@4.22.0 Now that @adcp/client supports userAgent in PropertyCrawlerConfig and TestOptions (adcp-client#427), pass AAO-Discovery and AAO-ComplianceCheck User-Agent strings through the remaining two outbound paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 54 +++---------------- package.json | 2 +- server/src/addie/jobs/compliance-heartbeat.ts | 1 + server/src/crawler.ts | 3 +- 4 files changed, 11 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0bc28e90e4..1d7cf5a09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "adcontextprotocol", "version": "3.0.0-rc.3", "dependencies": { - "@adcp/client": "^4.19.0", + "@adcp/client": "^4.22.0", "@anthropic-ai/sdk": "^0.82.0", "@asteasolutions/zod-to-openapi": "^8.5.0", "@google/generative-ai": "^0.24.1", @@ -115,10 +115,13 @@ } }, "node_modules/@adcp/client": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@adcp/client/-/client-4.20.0.tgz", - "integrity": "sha512-VV7+ixNEEplQo3zk/SPGmjjlFC1jqUi6qB5ePO+yWDrFAndzLkURINh7YI6ETBbkY/LBd9RylrdU/CQHnCmBkQ==", + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@adcp/client/-/client-4.22.0.tgz", + "integrity": "sha512-Dkky5u2BNkNrwio7TMLqGfQE1QtI2S4iNngSwVRvF3hVRIZ5mdIhdkzq8dzn3VS+0ePT6372Wce8d5CHj+68Zw==", "license": "Apache-2.0", + "dependencies": { + "yaml": "^2.7.1" + }, "bin": { "adcp": "bin/adcp.js" }, @@ -814,28 +817,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, "node_modules/@emnapi/runtime": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", @@ -853,27 +834,6 @@ "license": "0BSD", "optional": true }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true, - "peer": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", diff --git a/package.json b/package.json index d905a6a5bf..01ce3cae55 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "check:images": "bash scripts/check-image-quality.sh" }, "dependencies": { - "@adcp/client": "^4.19.0", + "@adcp/client": "^4.22.0", "@anthropic-ai/sdk": "^0.82.0", "@asteasolutions/zod-to-openapi": "^8.5.0", "@google/generative-ai": "^0.24.1", diff --git a/server/src/addie/jobs/compliance-heartbeat.ts b/server/src/addie/jobs/compliance-heartbeat.ts index d2ef9e30df..7eaf41169e 100644 --- a/server/src/addie/jobs/compliance-heartbeat.ts +++ b/server/src/addie/jobs/compliance-heartbeat.ts @@ -61,6 +61,7 @@ export async function runComplianceHeartbeatJob(options: HeartbeatOptions = {}): dry_run: true, timeout_ms: 60_000, auth, + userAgent: AAO_UA_COMPLIANCE, }; const complianceResult = await comply(agent.agent_url, complyOptions); diff --git a/server/src/crawler.ts b/server/src/crawler.ts index fb0d44778d..b7d0315122 100644 --- a/server/src/crawler.ts +++ b/server/src/crawler.ts @@ -7,6 +7,7 @@ import { BrandDatabase } from "./db/brand-db.js"; import { MemberDatabase } from "./db/member-db.js"; import { CapabilityDiscovery } from "./capabilities.js"; import { AAO_HOST } from "./config/aao.js"; +import { AAO_UA_DISCOVERY } from "./config/user-agents.js"; import { createLogger } from "./logger.js"; import type { CatalogEventsDatabase, WriteEventInput } from "./db/catalog-events-db.js"; import type { AgentInventoryProfilesDatabase, ProfileUpsertInput } from "./db/agent-inventory-profiles-db.js"; @@ -30,7 +31,7 @@ export class CrawlerService { private profilesDb?: AgentInventoryProfilesDatabase; constructor(options?: { eventsDb?: CatalogEventsDatabase; profilesDb?: AgentInventoryProfilesDatabase }) { - this.crawler = new PropertyCrawler({ logLevel: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') || 'error' }); + this.crawler = new PropertyCrawler({ logLevel: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') || 'error', userAgent: AAO_UA_DISCOVERY }); this.federatedIndex = new FederatedIndexService(); this.adAgentsManager = new AdAgentsManager(); this.brandManager = new BrandManager(); From 747295f6c1b1cdb05a18f3f4d826b12ba9a86ca2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 8 Apr 2026 07:50:14 -0400 Subject: [PATCH 3/7] fix: improve monitoring controls UX per expert review - Rename "Pause monitoring" to "Pause automated checks" - Disable and dim interval dropdown when paused - Show inline warning when paused: "Compliance status will not update" - Use design tokens for select padding/border-radius - Add User-Agent column to request log table (shows short name, full UA on hover) - Increase default request log from 20 to 50 rows Co-Authored-By: Claude Opus 4.6 (1M context) --- server/public/dashboard-agents.html | 37 ++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/server/public/dashboard-agents.html b/server/public/dashboard-agents.html index b1876b65e8..3c31fae592 100644 --- a/server/public/dashboard-agents.html +++ b/server/public/dashboard-agents.html @@ -626,14 +626,14 @@

Agents

${clickableTrackPills ? '
' + clickableTrackPills + '
' : ''} ${sparkline} -
-