From 7ce5a460cd250344a2053e430b6d25758044f372 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 15:12:37 +1000 Subject: [PATCH 1/8] feat(api): provision and manage Claude agents for a team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the records and endpoints a team needs to own a Claude managed agent, ahead of anything that uses one. An agent can be provisioned — an Anthropic environment, a vault holding the creating user's ClickStack access key, and the agent itself, rolling back what it made if a later step fails — or imported by the ID of one someone wrote by hand. Deleting tears the remote resources down before the local record and keeps the record if any of that fails, so a partial teardown can be retried. The agent's tool policy is written out rather than inherited. Anthropic's built-in toolset auto-approves everything by default, so it is configured tool by tool with an always_ask default: no shell, no web search, and web_fetch evaluated per call. Only the read-only ClickStack MCP tools are auto-approved, which matters because nothing here answers an approval prompt — an unattended session stalls rather than mutating anything. Everything sits behind HDX_MANAGED_AGENTS_ENABLED, which 404s the whole surface when unset. Provisioning needs a second opt-in, HDX_MANAGED_AGENTS_ALLOW_CREATE, since it spends on the deployment's Anthropic account; importing works without it. Fail-open extension seams let a downstream distribution replace the prompts and the Anthropic key resolution without editing the core flow. They are time-bounded so a hook that throws or hangs cannot stall the caller. No alert can reference an agent yet. --- knip.json | 3 +- packages/api/.env.development | 6 + packages/api/.env.test | 6 +- packages/api/src/api-app.ts | 1 + packages/api/src/config.ts | 33 +- packages/api/src/extensions/index.ts | 15 + .../src/mcp/__tests__/agentToolPolicy.test.ts | 110 +++ packages/api/src/mcp/__tests__/app.test.ts | 28 +- packages/api/src/mcp/app.ts | 30 +- packages/api/src/models/agentRun.ts | 68 ++ packages/api/src/models/managedAgent.ts | 67 ++ .../managedAgentCreateFlagOff.int.test.ts | 52 ++ .../api/__tests__/managedAgents.int.test.ts | 407 +++++++++ packages/api/src/routers/api/index.ts | 2 + packages/api/src/routers/api/managedAgents.ts | 219 +++++ .../__tests__/agentRunExtensions.test.ts | 194 +++++ .../__tests__/anthropicAgents.int.test.ts | 785 ++++++++++++++++++ .../api/src/services/agentRunExtensions.ts | 294 +++++++ packages/api/src/services/anthropicAgents.ts | 775 +++++++++++++++++ packages/common-utils/src/managedAgents.ts | 97 +++ 20 files changed, 3187 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/extensions/index.ts create mode 100644 packages/api/src/mcp/__tests__/agentToolPolicy.test.ts create mode 100644 packages/api/src/models/agentRun.ts create mode 100644 packages/api/src/models/managedAgent.ts create mode 100644 packages/api/src/routers/api/__tests__/managedAgentCreateFlagOff.int.test.ts create mode 100644 packages/api/src/routers/api/__tests__/managedAgents.int.test.ts create mode 100644 packages/api/src/routers/api/managedAgents.ts create mode 100644 packages/api/src/services/__tests__/agentRunExtensions.test.ts create mode 100644 packages/api/src/services/__tests__/anthropicAgents.int.test.ts create mode 100644 packages/api/src/services/agentRunExtensions.ts create mode 100644 packages/api/src/services/anthropicAgents.ts create mode 100644 packages/common-utils/src/managedAgents.ts diff --git a/knip.json b/knip.json index 16cd951d26..693e946d32 100644 --- a/knip.json +++ b/knip.json @@ -52,5 +52,6 @@ "@stryker-mutator/core" ], "include": ["nsExports"], - "exclude": ["enumMembers", "duplicates"] + "exclude": ["enumMembers", "duplicates"], + "tags": ["-public"] } diff --git a/packages/api/.env.development b/packages/api/.env.development index 8ba0a6c0c8..ddf03becea 100644 --- a/packages/api/.env.development +++ b/packages/api/.env.development @@ -5,6 +5,9 @@ FRONTEND_URL="http://localhost:${HYPERDX_APP_PORT}" HDX_NODE_ADVANCED_NETWORK_CAPTURE=1 HDX_NODE_BETA_MODE=1 HDX_NODE_CONSOLE_CAPTURE=1 +HDX_MANAGED_AGENTS_ENABLED=true +# Provisioning is a separate opt-in; without it only importing works. +HDX_MANAGED_AGENTS_ALLOW_CREATE=true MONGO_URI="mongodb://localhost:${HDX_DEV_MONGO_PORT}/hyperdx" NODE_ENV=development OTEL_SERVICE_NAME="hdx-oss-dev-api" @@ -25,4 +28,7 @@ DEFAULT_SOURCES=[{"from":{"databaseName":"default","tableName":"otel_logs"},"kin INGESTION_API_KEY="super-secure-ingestion-api-key" HYPERDX_API_KEY=$INGESTION_API_KEY ANTHROPIC_API_KEY="your-anthropic-api-key-here" +# Anthropic's sandbox connects to the MCP server directly, so this must be a +# public HTTPS tunnel to your local /api/mcp. +# HDX_MANAGED_AGENTS_MCP_URL="https://.ngrok-free.app/api/mcp" ENABLE_PROMQL=true diff --git a/packages/api/.env.test b/packages/api/.env.test index 0b67f560d1..afb606a296 100644 --- a/packages/api/.env.test +++ b/packages/api/.env.test @@ -16,4 +16,8 @@ OPAMP_PORT=${HDX_CI_OPAMP_PORT:-14320} # Note: the logger module is mocked in jest.setup.ts to suppress expected # operational noise (validation errors, MCP tool errors, etc.) during tests. HYPERDX_LOG_LEVEL=error -ENABLE_PROMQL=true \ No newline at end of file +ENABLE_PROMQL=true +# Managed-agent code paths are feature-gated on this flag; enable it so the +# agent service / alert-dispatch tests exercise the real behaviour. +HDX_MANAGED_AGENTS_ENABLED=true +HDX_MANAGED_AGENTS_ALLOW_CREATE=true diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 5e336d0d8d..2be08669ea 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -103,6 +103,7 @@ app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter); app.use('/me', isUserAuthenticated, routers.meRouter); app.use('/team', isUserAuthenticated, routers.teamRouter); app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter); +app.use('/managed-agents', isUserAuthenticated, routers.managedAgentsRouter); app.use('/connections', isUserAuthenticated, connectionsRouter); app.use('/sources', isUserAuthenticated, sourcesRouter); app.use('/saved-search', isUserAuthenticated, savedSearchRouter); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index cdb50bd3bf..4e1755271e 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -69,7 +69,17 @@ export const CLICKHOUSE_PASSWORD = env.CLICKHOUSE_PASSWORD as string; // AI Assistant // Provider-agnostic configuration (preferred) -export const AI_PROVIDER = env.AI_PROVIDER as string; // 'anthropic' | 'openai' + +// Supported AI providers. Values are the accepted `AI_PROVIDER` env strings — +// compare against these members rather than bare string literals. +export enum AIProvider { + Anthropic = 'anthropic', + OpenAI = 'openai', +} + +// Raw env string (validated against AIProvider at the point of use so an +// unrecognised value fails loudly rather than being silently cast). +export const AI_PROVIDER = env.AI_PROVIDER as string; export const AI_API_KEY = env.AI_API_KEY as string; export const AI_BASE_URL = env.AI_BASE_URL as string; export const AI_MODEL_NAME = env.AI_MODEL_NAME as string; @@ -77,3 +87,24 @@ export const AI_REQUEST_HEADERS = env.AI_REQUEST_HEADERS as string; // Legacy Anthropic-specific configuration (backward compatibility) export const ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY as string; + +// AI agent alert investigations (in-product Claude managed-agent provisioning +// and the `agent` alert notification channel). Off by default. The Anthropic +// key is read from the environment (AI_API_KEY / ANTHROPIC_API_KEY — see +// anthropicAgents.getTeamAnthropicKey); a downstream distribution can supply a +// per-team key via the resolveAnthropicKey extension seam. +export const IS_MANAGED_AGENTS_ENABLED = + env.HDX_MANAGED_AGENTS_ENABLED === 'true'; + +// Provisioning specifically: creating an agent spends money on the deployment's +// Anthropic account and writes the creating user's ClickStack key into a vault, +// so it needs its own opt-in on top of the feature flag. Off by default — +// importing an agent someone else created still works with only the flag above. +export const IS_MANAGED_AGENT_CREATE_ENABLED = + IS_MANAGED_AGENTS_ENABLED && env.HDX_MANAGED_AGENTS_ALLOW_CREATE === 'true'; + +// Public HTTPS URL of the ClickStack MCP server for managed agents (falls back +// to FRONTEND_URL + /api/mcp at the call sites). Read at call time, not module +// load, so test suites can set it per-suite without mocking this module. +export const getManagedAgentsMcpUrl = (): string | undefined => + process.env.HDX_MANAGED_AGENTS_MCP_URL; diff --git a/packages/api/src/extensions/index.ts b/packages/api/src/extensions/index.ts new file mode 100644 index 0000000000..7c5ccabf1d --- /dev/null +++ b/packages/api/src/extensions/index.ts @@ -0,0 +1,15 @@ +// Downstream extension registration point. +// +// This file is intentionally empty in the open-source distribution. +// Downstream distributions REPLACE this file's body to register their +// extensions, for example: +// +// import { registerAgentRunExtension } from '@/services/agentRunExtensions'; +// registerAgentRunExtension(myExtension); +// +// CONTRACT: upstream never edits this file after its creation, so downstream +// replacements never conflict on upstream merges. Add new registration +// surfaces via new register* calls in downstream copies — not by editing +// core modules. + +export {}; diff --git a/packages/api/src/mcp/__tests__/agentToolPolicy.test.ts b/packages/api/src/mcp/__tests__/agentToolPolicy.test.ts new file mode 100644 index 0000000000..346c7dce70 --- /dev/null +++ b/packages/api/src/mcp/__tests__/agentToolPolicy.test.ts @@ -0,0 +1,110 @@ +import { + AGENT_TOOLSET, + AUTO_ALLOWED_MCP_TOOLS, +} from '@hyperdx/common-utils/dist/managedAgents'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import alertsTools from '@/mcp/tools/alerts/index'; +import dashboardsTools from '@/mcp/tools/dashboards/index'; +import queryTools from '@/mcp/tools/query/index'; +import savedSearchesTools from '@/mcp/tools/savedSearches/index'; +import sourcesTools from '@/mcp/tools/sources/index'; +import traceTools from '@/mcp/tools/trace/index'; +import type { ToolRegistrar } from '@/mcp/tools/types'; + +// Registration only touches registrar.registerTool, so a capturing stub reads +// the real registry without standing up a transport or a ClickHouse connection. +const registeredTools = () => { + const tools = new Map(); + const registrar: ToolRegistrar = { + server: new McpServer({ name: 'test', version: '0' }), + context: { teamId: 'team', userId: 'user' }, + registerTool: (name, config) => { + tools.set(name, { + destructive: config.annotations?.destructiveHint === true, + }); + }, + }; + + for (const register of [ + sourcesTools, + alertsTools, + dashboardsTools, + queryTools, + savedSearchesTools, + traceTools, + ]) { + register(registrar); + } + return tools; +}; + +describe('AUTO_ALLOWED_MCP_TOOLS', () => { + const tools = registeredTools(); + + it('names only tools the MCP server actually registers', () => { + const unknown = AUTO_ALLOWED_MCP_TOOLS.filter(name => !tools.has(name)); + expect(unknown).toEqual([]); + }); + + // The allowlist is what an unattended alert investigation may call without + // an approval prompt, and no one is there to answer one. Keyed off the + // registry's own destructiveHint rather than a name pattern, so a mutating + // tool named with a new verb cannot slip in unnoticed. + it('auto-approves no tool the registry marks destructive', () => { + const destructive = AUTO_ALLOWED_MCP_TOOLS.filter( + name => tools.get(name)?.destructive, + ); + expect(destructive).toEqual([]); + }); + + it('covers every non-destructive tool, so new read tools are a deliberate choice', () => { + const missing = [...tools.entries()] + .filter( + ([name, t]) => !t.destructive && !AUTO_ALLOWED_MCP_TOOLS.includes(name), + ) + .map(([name]) => name); + expect(missing).toEqual([]); + }); +}); + +// See the AGENT_TOOLSET docblock for why each value is what it is. The whole +// surface is pinned rather than spot-checked: a tool dropped from configs +// inherits the toolset default, which is the failure this policy exists to +// prevent, and a spot-check would not see it. +describe('AGENT_TOOLSET', () => { + it('fails closed by default', () => { + expect(AGENT_TOOLSET.default_config.permission_policy.type).toBe( + 'always_ask', + ); + }); + + it('pins every tool to its intended policy', () => { + const surface = AGENT_TOOLSET.configs.map(c => [ + c.name, + c.enabled ?? true, + c.permission_policy?.type ?? + AGENT_TOOLSET.default_config.permission_policy.type, + ]); + expect(surface).toEqual([ + ['read', true, 'always_allow'], + ['write', true, 'always_allow'], + ['edit', true, 'always_allow'], + ['glob', true, 'always_allow'], + ['grep', true, 'always_allow'], + // Gated, not open: the agent is told to follow a runbook link. + ['web_fetch', true, 'auto'], + ['web_search', false, 'always_ask'], + // No unattended shell — see the docblock. + ['bash', false, 'always_ask'], + ]); + }); + + it('is frozen, so a consumer cannot widen it at runtime', () => { + expect(Object.isFrozen(AGENT_TOOLSET.configs)).toBe(true); + expect(() => { + // @ts-expect-error -- readonly at compile time; this asserts runtime too + AGENT_TOOLSET.configs[0].enabled = false; + }).toThrow(); + }); +}); diff --git a/packages/api/src/mcp/__tests__/app.test.ts b/packages/api/src/mcp/__tests__/app.test.ts index a7fa87cb05..9843c624d6 100644 --- a/packages/api/src/mcp/__tests__/app.test.ts +++ b/packages/api/src/mcp/__tests__/app.test.ts @@ -1,7 +1,7 @@ import express from 'express'; import request from 'supertest'; -import mcpRouter from '@/mcp/app'; +import mcpRouter, { buildAllowedHosts } from '@/mcp/app'; // The MCP transport is stateless, so GET (standalone SSE stream) and DELETE // (session termination) are not offered and must return 405 so spec-compliant @@ -22,3 +22,29 @@ describe('mcp app transport methods', () => { expect(res.headers.allow).toBe('POST'); }); }); + +describe('buildAllowedHosts', () => { + it('always includes the localhost defaults', () => { + expect(buildAllowedHosts([])).toEqual(['localhost', '127.0.0.1', '[::1]']); + }); + + it('adds the bare hostname of a configured URL (drops path and port)', () => { + const hosts = buildAllowedHosts([ + 'https://78fc-1-2-3.ngrok-free.app/mcp', + 'http://localhost:30287', + ]); + expect(hosts).toContain('78fc-1-2-3.ngrok-free.app'); + expect(hosts).toContain('localhost'); + // No port, no path leaked into the allowlist. + expect(hosts).not.toContain('localhost:30287'); + }); + + it('ignores undefined and malformed URLs rather than throwing', () => { + expect(() => buildAllowedHosts([undefined, 'not a url', ''])).not.toThrow(); + expect(buildAllowedHosts([undefined, 'not a url'])).toEqual([ + 'localhost', + '127.0.0.1', + '[::1]', + ]); + }); +}); diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index 94b05777eb..c4541e51b6 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -3,6 +3,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; +import * as config from '@/config'; import { validateUserAccessKey } from '@/middleware/auth'; import logger from '@/utils/logger'; import rateLimiter, { rateLimiterKeyGenerator } from '@/utils/rateLimiter'; @@ -11,7 +12,34 @@ import { createServer } from './mcpServer'; import { McpContext } from './tools/types'; import { userAgentClientInfo } from './utils/mcpClient'; -const app = createMcpExpressApp(); +// The SDK applies localhost-only DNS-rebinding protection by default, which +// rejects any request whose Host header isn't localhost. Managed agents (and +// any remote MCP client) reach us over a public tunnel/domain, so allowlist the +// configured public hosts alongside localhost. Matching is port-agnostic, so we +// pass bare hostnames. +export const buildAllowedHosts = (urls: (string | undefined)[]): string[] => { + const hosts = ['localhost', '127.0.0.1', '[::1]']; + for (const url of urls) { + if (!url) continue; + try { + hosts.push(new URL(url).hostname); + } catch { + // ignore a malformed URL — it just won't be allowlisted + } + } + return hosts; +}; + +const app = createMcpExpressApp({ + // The managed-agents URL is only added when the feature is on, so a + // deployment that never enables it keeps the SDK's narrower default. + allowedHosts: buildAllowedHosts([ + config.IS_MANAGED_AGENTS_ENABLED + ? config.getManagedAgentsMcpUrl() + : undefined, + config.FRONTEND_URL, + ]), +}); const mcpRateLimiter = rateLimiter({ windowMs: 60 * 1000, // 1 minute diff --git a/packages/api/src/models/agentRun.ts b/packages/api/src/models/agentRun.ts new file mode 100644 index 0000000000..4e40574024 --- /dev/null +++ b/packages/api/src/models/agentRun.ts @@ -0,0 +1,68 @@ +import mongoose, { Schema } from 'mongoose'; + +import type { ObjectId } from '.'; + +// One managed-agent investigation kicked off by a firing alert. Correlates the +// Anthropic session back to the team + alert and, via the unique dedupeKey, +// keeps a level-triggered alert from starting a fresh investigation every +// evaluation window. Results live in the Anthropic session — nothing is +// delivered from here, so there is no delivery state to track. Holds no secrets. +interface IAgentRun { + _id: ObjectId; + team: ObjectId; + managedAgent: ObjectId; + anthropicSessionId: string; + alertId?: string; + // Per-alert, per-cooldown-window key (`${eventId}:${window}`) so a re-fire + // within the window reuses the run while a later window re-investigates. + // Unique index enforces the within-window collapse. + dedupeKey: string; + title: string; + // Reserved for downstream extensions (see services/agentRunExtensions.ts): + // persisted verbatim from onSessionStart's runMetadata. Core code never + // interprets it. + metadata?: Record; + createdAt: Date; + updatedAt: Date; +} + +const AgentRunSchema = new Schema( + { + team: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Team', + required: true, + index: true, + }, + managedAgent: { + type: mongoose.Schema.Types.ObjectId, + ref: 'ManagedAgent', + required: true, + }, + anthropicSessionId: { type: String, required: true }, + alertId: { type: String, required: false }, + dedupeKey: { type: String, required: true }, + title: { type: String, required: true }, + metadata: { + type: mongoose.Schema.Types.Mixed, + required: false, + }, + }, + { timestamps: true }, +); + +// Scoped to the team rather than global: the key is built from ObjectIds so a +// cross-team collision is already implausible, but tenancy shouldn't rest on +// that. +AgentRunSchema.index({ team: 1, dedupeKey: 1 }, { unique: true }); + +// Runs are short-lived operational records; expire them so the collection (and +// the dedupeKey space) doesn't grow unbounded. +AgentRunSchema.index( + { createdAt: 1 }, + { expireAfterSeconds: 60 * 60 * 24 * 14 }, +); + +export type AgentRunDocument = mongoose.HydratedDocument; + +export default mongoose.model('AgentRun', AgentRunSchema); diff --git a/packages/api/src/models/managedAgent.ts b/packages/api/src/models/managedAgent.ts new file mode 100644 index 0000000000..691ea93761 --- /dev/null +++ b/packages/api/src/models/managedAgent.ts @@ -0,0 +1,67 @@ +import mongoose, { Schema } from 'mongoose'; + +import type { ObjectId } from '.'; + +// References to a Claude Managed Agent provisioned on Anthropic for a team. +// Stores only non-secret identifiers (the agent/vault/environment IDs) so the +// agent can be listed, managed, and (later) used by an in-product receiver. +interface IManagedAgent { + _id: ObjectId; + team: ObjectId; + name: string; + /** Absent on an imported agent whose Anthropic record could not be read. */ + model?: string; + /** + * Team-authored brief appended to the standing SRE prompt at provisioning + * time, so one team can run (say) a database specialist alongside a general + * responder. Baked into the Anthropic agent object, so editing it means + * re-provisioning — kept here for display and for that rebuild. + */ + instructions?: string; + anthropicAgentId: string; + vaultId: string; + environmentId: string; + mcpServerUrl: string; + /** + * True when the agent object itself was written outside HyperDX and only + * linked here. HyperDX still provisions the environment and vault an + * imported agent needs, so deleting one tears down exactly those two and + * leaves the user's agent on Anthropic. + */ + imported?: boolean; + createdBy?: ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const ManagedAgentSchema = new Schema( + { + team: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Team', + required: true, + index: true, + }, + name: { type: String, required: true }, + model: { type: String, required: false }, + instructions: { type: String, required: false }, + anthropicAgentId: { type: String, required: true }, + vaultId: { type: String, required: true }, + environmentId: { type: String, required: true }, + mcpServerUrl: { type: String, required: true }, + imported: { type: Boolean, required: false }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + }, + { timestamps: true }, +); + +export type ManagedAgentDocument = mongoose.HydratedDocument; + +export default mongoose.model( + 'ManagedAgent', + ManagedAgentSchema, +); diff --git a/packages/api/src/routers/api/__tests__/managedAgentCreateFlagOff.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgentCreateFlagOff.int.test.ts new file mode 100644 index 0000000000..e529ae3af0 --- /dev/null +++ b/packages/api/src/routers/api/__tests__/managedAgentCreateFlagOff.int.test.ts @@ -0,0 +1,52 @@ +import { getLoggedInAgent, getServer } from '@/fixtures'; +import ManagedAgent from '@/models/managedAgent'; + +// Provisioning is gated separately from the feature itself, so a deployment can +// allow importing an agent without allowing one to be created. This pins that +// split: with the feature on and create off, POST / is refused while the rest +// of the surface still works. +jest.mock('@/config', () => ({ + ...jest.requireActual('@/config'), + IS_MANAGED_AGENTS_ENABLED: true, + IS_MANAGED_AGENT_CREATE_ENABLED: false, +})); + +describe('managed agents with provisioning disabled', () => { + const server = getServer(); + let agent: Awaited>['agent']; + let fetchSpy: jest.SpyInstance; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + ({ agent } = await getLoggedInAgent(server)); + }); + + afterEach(async () => { + fetchSpy?.mockRestore(); + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('refuses to provision an agent and never calls Anthropic', async () => { + fetchSpy = jest.spyOn(global, 'fetch'); + + const resp = await agent + .post('/managed-agents') + .send({ name: 'SRE Responder', model: 'claude-opus-4-8' }) + .expect(403); + + expect(resp.body.message).toMatch(/HDX_MANAGED_AGENTS_ALLOW_CREATE/); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + it('still lists agents, so the rest of the surface is unaffected', async () => { + await agent.get('/managed-agents').expect(200); + }); +}); diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts new file mode 100644 index 0000000000..1551fac3a2 --- /dev/null +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -0,0 +1,407 @@ +import { getLoggedInAgent, getServer, randomMongoId } from '@/fixtures'; +import Alert, { AlertSource, AlertThresholdType } from '@/models/alert'; +import ManagedAgent from '@/models/managedAgent'; +import { registerAgentRunExtension } from '@/services/agentRunExtensions'; +import { resetAgentRunExtensionsForTests } from '@/services/agentRunExtensions'; + +describe('managed agents router', () => { + const server = getServer(); + let agent: Awaited>['agent']; + let team: Awaited>['team']; + let user: Awaited>['user']; + let fetchSpy: jest.SpyInstance; + const originalMcpUrl = process.env.HDX_MANAGED_AGENTS_MCP_URL; + // Anthropic only accepts a public HTTPS MCP URL; the test FRONTEND_URL is + // http, so importing would fail the URL check before reaching the API. + const MCP_URL = 'https://mcp.example.test/api/mcp'; + + beforeAll(async () => { + process.env.HDX_MANAGED_AGENTS_MCP_URL = MCP_URL; + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + agent = result.agent; + team = result.team; + user = result.user; + // OSS resolves the Anthropic key from the environment, which is unset in + // tests; this is the seam a downstream distribution uses for a per-team key. + registerAgentRunExtension({ + name: 'test-key', + resolveAnthropicKey: async () => ({ apiKey: 'sk-ant-key' }), + }); + }); + + afterEach(async () => { + fetchSpy?.mockRestore(); + resetAgentRunExtensionsForTests(); + await server.clearDBs(); + }); + + afterAll(async () => { + process.env.HDX_MANAGED_AGENTS_MCP_URL = originalMcpUrl; + await server.stop(); + }); + + const seedAgent = () => + ManagedAgent.create({ + team: team._id, + name: 'SRE Responder', + model: 'claude-opus-4-8', + anthropicAgentId: 'agent_1', + vaultId: 'vlt_1', + environmentId: 'env_1', + mcpServerUrl: 'https://mcp.example.test/api/mcp', + }); + + describe('GET /managed-agents', () => { + it('names the creator, whose access key the agent carries', async () => { + await ManagedAgent.create({ + team: team._id, + name: 'SRE Responder', + model: 'claude-opus-4-8', + anthropicAgentId: 'agent_1', + vaultId: 'vlt_1', + environmentId: 'env_1', + mcpServerUrl: 'https://mcp.example.test/api/mcp', + createdBy: user._id, + }); + + const resp = await agent.get('/managed-agents').expect(200); + + // Resolved to something displayable, not the raw ObjectId the UI would + // otherwise print. + expect(resp.body.data[0].createdBy).toEqual({ + name: user.name, + email: user.email, + }); + }); + + it('omits the creator rather than leaking an id when it cannot be resolved', async () => { + await seedAgent(); + + const resp = await agent.get('/managed-agents').expect(200); + + expect(resp.body.data[0].createdBy).toBeUndefined(); + }); + + it('returns the stored brief so agents are tellable apart', async () => { + await ManagedAgent.create({ + team: team._id, + name: 'DB specialist', + model: 'claude-opus-4-8', + instructions: 'Check replication lag first.', + anthropicAgentId: 'agent_2', + vaultId: 'vlt_2', + environmentId: 'env_2', + mcpServerUrl: 'https://mcp.example.test/api/mcp', + }); + + const resp = await agent.get('/managed-agents').expect(200); + + expect(resp.body.data[0].instructions).toBe( + 'Check replication lag first.', + ); + }); + }); + + describe('POST /managed-agents/import', () => { + const body = { anthropicAgentId: 'agent_ext' }; + + // The MCP reachability probe that provisioning also runs, plus whatever + // Anthropic calls the test cares about. + const mockAnthropic = ( + handler: (url: string, init: any) => Response | null, + ) => + jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + const u = String(url); + if (!u.startsWith('https://api.anthropic.com')) { + return new Response('{}'); // the MCP initialize probe + } + return handler(u, init) ?? new Response('{"id":"generated"}'); + }); + + it('links the agent and provisions the vault and environment it needs', async () => { + const calls: string[] = []; + fetchSpy = mockAnthropic((url, init) => { + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url.endsWith('/v1/environments')) + return new Response('{"id":"env_new"}'); + if (url.endsWith('/v1/vaults')) return new Response('{"id":"vlt_new"}'); + if (url.includes('/v1/agents/')) + return new Response( + '{"id":"agent_ext","name":"Hand-rolled responder","model":"claude-opus-4-8"}', + ); + return new Response('{}'); + }); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(200); + + expect(resp.body.verified).toBe(true); + // The user's agent object is read, never created or modified. + expect(calls).toContain( + 'GET https://api.anthropic.com/v1/agents/agent_ext', + ); + expect(calls).not.toContain('POST https://api.anthropic.com/v1/agents'); + const stored = await ManagedAgent.findOne({ + anthropicAgentId: 'agent_ext', + }); + expect(stored?.imported).toBe(true); + expect(stored?.environmentId).toBe('env_new'); + expect(stored?.vaultId).toBe('vlt_new'); + // Name and model are read from the agent object, not retyped, so they + // can't drift from what Anthropic will actually run. + expect(stored?.name).toBe('Hand-rolled responder'); + expect(stored?.model).toBe('claude-opus-4-8'); + }); + + it('falls back to the ID for a name when the agent cannot be read', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response('boom', { status: 500 }) + : null, + ); + + await agent.post('/managed-agents/import').send(body).expect(200); + + const stored = await ManagedAgent.findOne({}); + expect(stored?.name).toBe('agent_ext'); + expect(stored?.model).toBeUndefined(); + }); + + it('prefers a name the user supplied', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response('{"name":"Anthropic name"}') + : null, + ); + + await agent + .post('/managed-agents/import') + .send({ ...body, name: 'Payments responder' }) + .expect(200); + + expect((await ManagedAgent.findOne({}))?.name).toBe('Payments responder'); + }); + + it('rejects an agent ID Anthropic does not know', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response('nf', { status: 404 }) + : null, + ); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(400); + + expect(resp.body.message).toMatch(/agent_ext/); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + // A verification outage shouldn't block an import the user can confirm + // themselves — the response says it went in unverified. + it('imports unverified when the check fails for another reason', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response('boom', { status: 500 }) + : null, + ); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(200); + + expect(resp.body.verified).toBe(false); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it('refuses to import the same agent twice', async () => { + fetchSpy = mockAnthropic(() => null); + await agent.post('/managed-agents/import').send(body).expect(200); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(409); + + expect(resp.body.message).toMatch(/already in the list/); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it('rolls the vault back if the environment or credential fails', async () => { + const deleted: string[] = []; + fetchSpy = mockAnthropic((url, init) => { + if (init?.method === 'DELETE') { + deleted.push(url); + return new Response('{}'); + } + if (url.endsWith('/v1/vaults')) return new Response('{"id":"vlt_new"}'); + if (url.includes('/credentials')) + return new Response('nope', { status: 500 }); + return null; + }); + + await agent.post('/managed-agents/import').send(body).expect(502); + + expect(deleted.some(u => u.endsWith('/v1/vaults/vlt_new'))).toBe(true); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + }); + + describe('DELETE /managed-agents/:id', () => { + it('deletes the agent, vault and environment on Anthropic before dropping the record', async () => { + const managedAgent = await seedAgent(); + const deleted: string[] = []; + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + if (init?.method === 'DELETE') deleted.push(String(url)); + return new Response('{}'); + }); + + await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(200); + + expect(deleted.some(u => u.endsWith('/v1/agents/agent_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/vaults/vlt_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/environments/env_1'))).toBe( + true, + ); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + // The record is the only pointer to those Anthropic resources. Dropping it + // after a failed teardown would strand a live vault holding the team's + // ClickStack access key with nothing left to clean it up. + it('keeps the record and reports the leftovers when Anthropic rejects the delete', async () => { + const managedAgent = await seedAgent(); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any) => + String(url).includes('/v1/vaults/') + ? new Response('nope', { status: 500 }) + : new Response('{}'), + ); + + const resp = await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(502); + + expect(resp.body.message).toMatch(/vault/); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it('retrying after a partial failure finishes the job', async () => { + const managedAgent = await seedAgent(); + // The vault is gone from the first attempt, so it now answers 404 — + // which counts as deleted. + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any) => + String(url).includes('/v1/vaults/') + ? new Response('nf', { status: 404 }) + : new Response('{}'), + ); + + await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(200); + + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + // The agent object is the user's work; only what HyperDX made goes. + it('leaves an imported agent on Anthropic and removes what it provisioned', async () => { + const managedAgent = await seedAgent(); + await ManagedAgent.updateOne( + { _id: managedAgent._id }, + { imported: true }, + ); + const deleted: string[] = []; + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + if (init?.method === 'DELETE') deleted.push(String(url)); + return new Response('{}'); + }); + + await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(200); + + expect(deleted.some(u => u.endsWith('/v1/agents/agent_1'))).toBe(false); + expect(deleted.some(u => u.endsWith('/v1/vaults/vlt_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/environments/env_1'))).toBe( + true, + ); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + // The app's ky hook redirects to /login on any 401, so forwarding + // Anthropic's would read as an expired HyperDX session and log the user out. + it('does not forward an upstream 401, which would log the user out', async () => { + const managedAgent = await seedAgent(); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation( + async () => new Response('bad key', { status: 401 }), + ); + + const resp = await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(502); + + expect(resp.body.message).toBeTruthy(); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it('refuses to delete an agent an alert still targets', async () => { + const managedAgent = await seedAgent(); + await Alert.create({ + team: team._id, + source: AlertSource.SAVED_SEARCH, + savedSearch: randomMongoId(), + channel: { type: 'agent', agentId: managedAgent._id.toString() }, + interval: '15m', + threshold: 1, + thresholdType: AlertThresholdType.ABOVE, + }); + fetchSpy = jest.spyOn(global, 'fetch'); + + const resp = await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(409); + + expect(resp.body.message).toMatch(/1 alert/); + // Nothing was torn down on Anthropic, and the record survives. + expect(fetchSpy).not.toHaveBeenCalled(); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it("refuses to delete another team's agent", async () => { + const managedAgent = await seedAgent(); + await ManagedAgent.updateOne( + { _id: managedAgent._id }, + { team: '000000000000000000000001' }, + ); + fetchSpy = jest.spyOn(global, 'fetch'); + + await agent + .delete(`/managed-agents/${managedAgent._id.toString()}`) + .expect(404); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + }); +}); diff --git a/packages/api/src/routers/api/index.ts b/packages/api/src/routers/api/index.ts index 9e7b0c0322..9e7c395983 100644 --- a/packages/api/src/routers/api/index.ts +++ b/packages/api/src/routers/api/index.ts @@ -1,6 +1,7 @@ import aiRouter from './ai'; import alertsRouter from './alerts'; import dashboardRouter from './dashboards'; +import managedAgentsRouter from './managedAgents'; import meRouter from './me'; import prometheusRouter from './prometheus'; import rootRouter from './root'; @@ -11,6 +12,7 @@ export default { aiRouter, alertsRouter, dashboardRouter, + managedAgentsRouter, meRouter, prometheusRouter, rootRouter, diff --git a/packages/api/src/routers/api/managedAgents.ts b/packages/api/src/routers/api/managedAgents.ts new file mode 100644 index 0000000000..a95dbd461f --- /dev/null +++ b/packages/api/src/routers/api/managedAgents.ts @@ -0,0 +1,219 @@ +import express from 'express'; +import { pick } from 'lodash'; +import mongoose from 'mongoose'; +import { serializeError } from 'serialize-error'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import * as config from '@/config'; +import Alert from '@/models/alert'; +import ManagedAgent from '@/models/managedAgent'; +import { + AnthropicApiError, + deleteAnthropicAgent, + importAnthropicAgent, + provisionClickStackAgent, +} from '@/services/anthropicAgents'; +import logger from '@/utils/logger'; + +const router = express.Router(); + +// Feature-flagged: the whole surface 404s unless explicitly enabled. +router.use((req, res, next) => { + if (!config.IS_MANAGED_AGENTS_ENABLED) { + return res.sendStatus(404); + } + next(); +}); + +// The Anthropic API key is resolved from the environment in the open-source +// distribution (see getTeamAnthropicKey / resolveEnvAnthropicKey). Per-team, +// UI-managed key storage is a downstream-distribution concern registered via +// the `resolveAnthropicKey` extension seam — it is intentionally not part of +// the OSS surface. + +// Only statuses HyperDX itself authored reach the browser. Forwarding +// Anthropic's 401 would log the user out of HyperDX entirely: the app's ky +// hook redirects to /login on any 401, so a bad server-side Anthropic key +// would read as an expired HyperDX session. +const HYPERDX_AUTHORED_STATUSES = new Set([400, 404, 409]); +const clientFacingStatus = (status: number) => + HYPERDX_AUTHORED_STATUSES.has(status) ? status : 502; + +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + // The agent's vault holds the creating user's ClickStack access key, so + // who created it is part of what the agent *is*, not just trivia. + const agents = await ManagedAgent.find({ team: teamId }, { __v: 0 }) + .populate<{ + createdBy?: { name?: string; email?: string }; + }>('createdBy', 'name email') + .sort({ createdAt: -1 }); + res.json({ + data: agents.map(agent => ({ + ...agent.toJSON(), + createdBy: agent.createdBy + ? pick(agent.createdBy, ['name', 'email']) + : undefined, + })), + }); + } catch (e) { + next(e); + } +}); + +router.post( + '/', + validateRequest({ + body: z.object({ + name: z.string().min(1).max(120), + model: z.string().min(1), + instructions: z.string().max(2000).optional(), + }), + }), + async (req, res, next) => { + try { + if (!config.IS_MANAGED_AGENT_CREATE_ENABLED) { + return res.status(403).json({ + message: + 'Provisioning new agents is disabled on this deployment. Import an existing Anthropic agent instead, or set HDX_MANAGED_AGENTS_ALLOW_CREATE=true.', + }); + } + const teamId = req.user?.team; + const userId = req.user?._id; + const userAccessKey = req.user?.accessKey; + if (teamId == null || userId == null || !userAccessKey) { + return res.sendStatus(403); + } + const { name, model, instructions } = req.body; + const agent = await provisionClickStackAgent({ + teamId, + userId, + userAccessKey, + name, + model, + instructions, + }); + res.json({ data: agent }); + } catch (e) { + if (e instanceof AnthropicApiError) { + logger.error( + { error: serializeError(e) }, + 'Managed agent provisioning failed', + ); + const status = clientFacingStatus(e.status); + return res.status(status).json({ message: e.message }); + } + next(e); + } + }, +); + +// Links an agent that already exists on Anthropic, so someone who provisioned +// one by hand can still use it as an alert target. +router.post( + '/import', + validateRequest({ + body: z.object({ + name: z.string().max(120).optional(), + anthropicAgentId: z.string().min(1).max(200), + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const userId = req.user?._id; + const userAccessKey = req.user?.accessKey; + if (teamId == null || userId == null || !userAccessKey) { + return res.sendStatus(403); + } + const { agent, verified } = await importAnthropicAgent({ + teamId, + userId, + userAccessKey, + ...req.body, + }); + res.json({ data: agent, verified }); + } catch (e) { + if (e instanceof AnthropicApiError) { + logger.error( + { error: serializeError(e) }, + 'Managed agent import failed', + ); + const status = clientFacingStatus(e.status); + return res.status(status).json({ message: e.message }); + } + next(e); + } + }, +); + +router.delete( + '/:id', + validateRequest({ params: z.object({ id: z.string() }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + if (!mongoose.Types.ObjectId.isValid(req.params.id)) { + return res.status(400).json({ message: 'Invalid id' }); + } + const agent = await ManagedAgent.findOne({ + _id: req.params.id, + team: teamId, + }); + if (!agent) return res.status(404).json({ message: 'Agent not found' }); + + // Mirrors the webhook route: deleting a referenced target would leave + // those alerts erroring on every evaluation with no way to notice. + // Both shapes are checked because pre-multi-channel alerts only have + // the singular `channel`. + const referencingAlerts = await Alert.countDocuments({ + team: teamId, + $or: [ + { 'channel.agentId': req.params.id }, + { 'channels.agentId': req.params.id }, + ], + }); + if (referencingAlerts > 0) { + return res.status(409).json({ + message: `Cannot delete agent: ${referencingAlerts} alert(s) still reference it. Please update or remove those alerts first.`, + }); + } + + // The local record is the only pointer to the Anthropic resources, so it + // is dropped only once they are actually gone. Keeping it on failure + // leaves the user something to retry instead of an invisible agent — and + // a live vault holding their ClickStack access key. An imported agent + // object belongs to the user, so only its vault and environment go. + try { + await deleteAnthropicAgent( + teamId, + agent.imported ? null : agent.anthropicAgentId, + { + vaultId: agent.vaultId, + environmentId: agent.environmentId, + }, + ); + } catch (e) { + if (e instanceof AnthropicApiError) { + logger.error( + { error: serializeError(e), agentId: agent._id.toString() }, + 'Managed agent deletion failed; keeping the local record', + ); + const status = clientFacingStatus(e.status); + return res.status(status).json({ message: e.message }); + } + throw e; + } + await agent.deleteOne(); + res.json({}); + } catch (e) { + next(e); + } + }, +); + +export default router; diff --git a/packages/api/src/services/__tests__/agentRunExtensions.test.ts b/packages/api/src/services/__tests__/agentRunExtensions.test.ts new file mode 100644 index 0000000000..074d47f2f8 --- /dev/null +++ b/packages/api/src/services/__tests__/agentRunExtensions.test.ts @@ -0,0 +1,194 @@ +import ManagedAgent from '@/models/managedAgent'; +import { + registerAgentRunExtension, + resetAgentRunExtensionsForTests, + runAnthropicKeyExtensions, + runProvisionExtensions, + runSessionStartExtensions, +} from '@/services/agentRunExtensions'; + +const sessionCtx = { + teamId: 't1', + agent: new ManagedAgent({ name: 'SRE Responder' }), + anthropicSessionId: 'sesn_1', + title: 'CPU alert', + prompt: '{"source":"clickstack"}', +}; + +const provisionCtx = { + teamId: 't1', + name: 'SRE Responder', + model: 'claude-opus-4-8', + mcpServerUrl: 'https://mcp.example.test/api/mcp', + defaultSystemPrompt: 'You are an SRE agent.', +}; + +describe('agentRunExtensions', () => { + afterEach(() => { + resetAgentRunExtensionsForTests(); + }); + + it('produces neutral results when nothing is registered', async () => { + expect(await runSessionStartExtensions(sessionCtx)).toEqual({ + prompt: '{"source":"clickstack"}', + runMetadata: undefined, + }); + expect(await runProvisionExtensions(provisionCtx)).toEqual({ + systemPrompt: 'You are an SRE agent.', + }); + }); + + it('merges contributions across extensions in registration order', async () => { + registerAgentRunExtension({ + name: 'a', + onSessionStart: async () => ({ + promptSuffix: '\nA-instructions', + runMetadata: { a: 1 }, + }), + }); + registerAgentRunExtension({ + name: 'b', + onSessionStart: async () => ({ + promptSuffix: '\nB-instructions', + runMetadata: { b: 2 }, + }), + }); + + expect(await runSessionStartExtensions(sessionCtx)).toEqual({ + prompt: '{"source":"clickstack"}\nA-instructions\nB-instructions', + runMetadata: { a: 1, b: 2 }, + }); + }); + + it('lets an extension replace the kickoff prompt wholesale; last override wins, suffixes still append', async () => { + registerAgentRunExtension({ + name: 'override-1', + onSessionStart: async () => ({ promptOverride: 'FIRST' }), + }); + registerAgentRunExtension({ + name: 'override-2', + onSessionStart: async ctx => ({ + // Overriders receive the OSS payload (ctx.prompt) so they can rebuild + // or tweak it; here we just prove the context reaches the override. + promptOverride: `SECOND (${ctx.title})`, + promptSuffix: '\nplus-suffix', + }), + }); + + expect((await runSessionStartExtensions(sessionCtx)).prompt).toBe( + 'SECOND (CPU alert)\nplus-suffix', + ); + }); + + it('lets an extension replace the provisioning system prompt; last defined wins', async () => { + registerAgentRunExtension({ + name: 'sys-1', + onProvisionAgent: async () => ({ systemPrompt: 'FIRST SYSTEM' }), + }); + registerAgentRunExtension({ + name: 'sys-2', + onProvisionAgent: async ctx => ({ + // ctx.defaultSystemPrompt is available for overriders that extend + // rather than rewrite; here we prove the context reaches the hook. + systemPrompt: `SECOND SYSTEM (for ${ctx.name})`, + }), + }); + + expect((await runProvisionExtensions(provisionCtx)).systemPrompt).toBe( + 'SECOND SYSTEM (for SRE Responder)', + ); + }); + + it('isolates a throwing extension (fail-open) and keeps later ones', async () => { + registerAgentRunExtension({ + name: 'boom', + onProvisionAgent: async () => { + throw new Error('boom'); + }, + onSessionStart: async () => { + throw new Error('boom'); + }, + }); + registerAgentRunExtension({ + name: 'ok', + onSessionStart: async () => ({ promptSuffix: 'OK' }), + }); + + expect((await runSessionStartExtensions(sessionCtx)).prompt).toBe( + '{"source":"clickstack"}OK', + ); + expect((await runProvisionExtensions(provisionCtx)).systemPrompt).toBe( + 'You are an SRE agent.', + ); + }); + + // A hook that never settles is the case fail-open would otherwise miss: + // this runs inside the check-alerts sweep, so an unbounded await stalls + // alert evaluation for every team until the process restarts. + it('isolates a hanging extension on a deadline and keeps later ones', async () => { + jest.useFakeTimers(); + try { + registerAgentRunExtension({ + name: 'hangs', + onSessionStart: () => new Promise(() => {}), + }); + registerAgentRunExtension({ + name: 'ok', + onSessionStart: async () => ({ promptSuffix: 'OK' }), + }); + + const pending = runSessionStartExtensions(sessionCtx); + await jest.advanceTimersByTimeAsync(5_000); + + expect((await pending).prompt).toBe('{"source":"clickstack"}OK'); + } finally { + jest.useRealTimers(); + } + }); + + it('treats a void hook result as no contribution', async () => { + registerAgentRunExtension({ + name: 'noop', + onSessionStart: async () => undefined, + onProvisionAgent: async () => undefined, + }); + expect(await runSessionStartExtensions(sessionCtx)).toEqual({ + prompt: '{"source":"clickstack"}', + runMetadata: undefined, + }); + expect((await runProvisionExtensions(provisionCtx)).systemPrompt).toBe( + 'You are an SRE agent.', + ); + }); + + it('skips extensions that do not implement a hook', async () => { + registerAgentRunExtension({ name: 'key-only' }); + expect((await runSessionStartExtensions(sessionCtx)).prompt).toBe( + '{"source":"clickstack"}', + ); + }); + + it('resolves the anthropic key from the last extension that returns one; null when none', async () => { + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBeNull(); + + registerAgentRunExtension({ + name: 'k1', + resolveAnthropicKey: async () => ({ apiKey: 'sk-a' }), + }); + registerAgentRunExtension({ + name: 'k2', + resolveAnthropicKey: async () => ({ apiKey: 'sk-b' }), + }); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBe('sk-b'); + }); + + it('is fail-open for key resolution (a throwing resolver yields null)', async () => { + registerAgentRunExtension({ + name: 'boom', + resolveAnthropicKey: async () => { + throw new Error('boom'); + }, + }); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBeNull(); + }); +}); diff --git a/packages/api/src/services/__tests__/anthropicAgents.int.test.ts b/packages/api/src/services/__tests__/anthropicAgents.int.test.ts new file mode 100644 index 0000000000..38b2636ab6 --- /dev/null +++ b/packages/api/src/services/__tests__/anthropicAgents.int.test.ts @@ -0,0 +1,785 @@ +import { AGENT_TOOLSET } from '@hyperdx/common-utils/dist/managedAgents'; +import mongoose from 'mongoose'; + +import { getServer } from '@/fixtures'; +import AgentRun from '@/models/agentRun'; +import ManagedAgent from '@/models/managedAgent'; +import { + registerAgentRunExtension, + resetAgentRunExtensionsForTests, + runSessionStartExtensions, +} from '@/services/agentRunExtensions'; +import { + AnthropicApiError, + deleteAnthropicAgent, + getTeamAnthropicKey, + provisionClickStackAgent, + startAgentSession, + verifyMcpReachable, +} from '@/services/anthropicAgents'; +import logger from '@/utils/logger'; + +// Anthropic requires an HTTPS MCP URL; provisioning resolves it from this env. +const MCP_URL = 'https://mcp.example.test/api/mcp'; + +// The team's Anthropic key is resolved via the resolveAnthropicKey extension +// seam (OSS falls back to env, which is unset in tests). Registering this +// resolver is how a downstream distribution injects a per-team key; afterEach +// resets the registry. +const registerTestAnthropicKey = (apiKey = 'sk-ant-key') => + registerAgentRunExtension({ + name: 'test-key', + resolveAnthropicKey: async () => ({ apiKey }), + }); + +// Sequenced Anthropic responses keyed by URL substring. +const mockAnthropic = () => + jest.spyOn(global, 'fetch').mockImplementation(async (url: any) => { + const u = String(url); + const body = (data: unknown) => new Response(JSON.stringify(data)); + // MCP reachability preflight (provisioning hits this before Anthropic). + if (u === MCP_URL) return body({ ok: true }); + if (u.endsWith('/v1/environments')) return body({ id: 'env_1' }); + if (u.endsWith('/v1/vaults')) return body({ id: 'vlt_1' }); + if (u.includes('/credentials')) return body({ id: 'cred_1' }); + if (u.endsWith('/v1/agents')) return body({ id: 'agent_1' }); + throw new Error(`unexpected fetch to ${u}`); + }); + +describe('anthropicAgents service', () => { + const server = getServer(); + const originalMcpUrl = process.env.HDX_MANAGED_AGENTS_MCP_URL; + let fetchSpy: jest.SpyInstance; + + beforeAll(async () => { + process.env.HDX_MANAGED_AGENTS_MCP_URL = MCP_URL; + await server.start(); + }); + afterEach(async () => { + fetchSpy?.mockRestore(); + resetAgentRunExtensionsForTests(); + await server.clearDBs(); + }); + afterAll(async () => { + process.env.HDX_MANAGED_AGENTS_MCP_URL = originalMcpUrl; + await server.stop(); + }); + + it('getTeamAnthropicKey returns null when no key is configured (no env, no resolver)', async () => { + const teamId = new mongoose.Types.ObjectId(); + expect(await getTeamAnthropicKey(teamId)).toBeNull(); + }); + + it('getTeamAnthropicKey returns the key from a registered resolver extension', async () => { + registerTestAnthropicKey('sk-ant-resolved'); + const teamId = new mongoose.Types.ObjectId(); + expect(await getTeamAnthropicKey(teamId)).toBe('sk-ant-resolved'); + }); + + it('registers no extensions by default (OSS ships an empty registration point)', async () => { + // Importing the service pulls in @/extensions as a side effect; the OSS + // stub must leave the registry empty so behaviour is unchanged. + expect( + await runSessionStartExtensions({ + teamId: 't', + agent: new ManagedAgent({ name: 'x' }), + anthropicSessionId: 's', + title: 't', + prompt: 'p', + }), + ).toEqual({ prompt: 'p', runMetadata: undefined }); + }); + + it('provisions env + vault + credential + agent and persists the record', async () => { + fetchSpy = mockAnthropic(); + const teamId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey(); + + const agent = await provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'user-access-key', + name: 'prod SRE', + model: 'claude-opus-4-8', + }); + + // Persisted record carries the Anthropic IDs. + expect(agent.anthropicAgentId).toBe('agent_1'); + expect(agent.vaultId).toBe('vlt_1'); + expect(agent.environmentId).toBe('env_1'); + const stored = await ManagedAgent.findById(agent._id); + expect(stored).not.toBeNull(); + + // The MCP URL must be byte-identical in the vault credential and the agent. + const calls = fetchSpy.mock.calls; + const bodyOf = (substr: string) => + JSON.parse( + String(calls.find(([u]) => String(u).includes(substr))![1].body), + ); + const credBody = bodyOf('/credentials'); + const agentBody = bodyOf('/v1/agents'); + expect(credBody.auth.mcp_server_url).toBe(MCP_URL); + expect(agentBody.mcp_servers[0].url).toBe(MCP_URL); + expect(credBody.auth.token).toBe('user-access-key'); + + // Only read tools are auto-approved. An unattended session can never + // answer an approval prompt, so leaving the default at always_ask is what + // stops the MCP server's save/delete/patch tools firing from an alert. + const mcpToolset = agentBody.tools.find( + (t: any) => t.type === 'mcp_toolset', + ); + expect(mcpToolset.default_config.permission_policy.type).toBe('always_ask'); + + const autoAllowed: string[] = mcpToolset.configs + .filter((c: any) => c.permission_policy.type === 'always_allow') + .map((c: any) => c.name); + expect(autoAllowed).toContain('clickstack_search'); + expect( + autoAllowed.filter((name: string) => + /^clickstack_(save|delete|patch)_/.test(name), + ), + ).toEqual([]); + + // Sent configured, not bare — AGENT_TOOLSET's docblock has the why, and + // agentToolPolicy.test.ts pins its contents. What matters here is that + // provisioning actually sends it. + const builtinToolset = agentBody.tools.find( + (t: any) => t.type === 'agent_toolset_20260401', + ); + expect(builtinToolset).toEqual(AGENT_TOOLSET); + + // Beta header is sent. + const agentHeaders = calls.find(([u]) => + String(u).endsWith('/v1/agents'), + )![1].headers; + expect(agentHeaders['anthropic-beta']).toBe('managed-agents-2026-04-01'); + }); + + it('throws AnthropicApiError (400) when no key is configured', async () => { + const teamId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + await expect( + provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'k', + name: 'x', + model: 'claude-opus-4-8', + }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('surfaces an Anthropic API failure as AnthropicApiError', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('bad key', { status: 401 })); + const teamId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey('sk-ant-bad'); + await expect( + provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'k', + name: 'x', + model: 'claude-opus-4-8', + }), + ).rejects.toBeInstanceOf(AnthropicApiError); + }); + + describe('deleteAnthropicAgent', () => { + const seedKey = async () => { + const teamId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey(); + return teamId; + }; + + it('treats a 404 as success without logging a failure (idempotent)', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('nf', { status: 404 })); + const teamId = await seedKey(); + + await expect( + deleteAnthropicAgent(teamId, 'agent_gone'), + ).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + // The caller drops the local record on success, and that record is the + // only pointer to these resources — so a failed teardown has to surface, + // not warn quietly. + it('throws, naming what is left on Anthropic, when a delete fails', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any) => + String(url).includes('/v1/vaults/') + ? new Response('boom', { status: 500 }) + : new Response('{}'), + ); + const teamId = await seedKey(); + + await expect( + deleteAnthropicAgent(teamId, 'agent_x', { + vaultId: 'vlt_1', + environmentId: 'env_1', + }), + ).rejects.toThrow(/vault/); + warn.mockRestore(); + }); + + it('throws rather than pretending when no Anthropic key is configured', async () => { + fetchSpy = jest.spyOn(global, 'fetch'); + const teamId = new mongoose.Types.ObjectId(); + + await expect( + deleteAnthropicAgent(teamId, 'agent_x', { vaultId: 'vlt_1' }), + ).rejects.toMatchObject({ status: 400 }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // Retrying after a partial failure must converge: the resources already + // gone answer 404, which counts as deleted. + it('resolves when every resource is already absent', async () => { + // A fresh Response per call: a single instance's body can only be read + // once, so the second delete would fail on a consumed body rather than + // the 404 under test. + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async () => new Response('nf', { status: 404 })); + const teamId = await seedKey(); + + await expect( + deleteAnthropicAgent(teamId, 'agent_x', { + vaultId: 'vlt_1', + environmentId: 'env_1', + }), + ).resolves.toBeUndefined(); + }); + + it('also deletes the vault and environment so the ClickStack credential is not orphaned', async () => { + const deleted: string[] = []; + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + if (init?.method === 'DELETE') deleted.push(String(url)); + return new Response('{}'); + }); + const teamId = await seedKey(); + + await deleteAnthropicAgent(teamId, 'agent_1', { + vaultId: 'vlt_1', + environmentId: 'env_1', + }); + + expect(deleted.some(u => u.endsWith('/v1/agents/agent_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/vaults/vlt_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/environments/env_1'))).toBe( + true, + ); + }); + }); + + describe('verifyMcpReachable', () => { + it('passes when the MCP server answers a non-401 response', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); + await expect( + verifyMcpReachable(MCP_URL, 'user-key'), + ).resolves.toBeUndefined(); + // Exercises the agent's exact path: bearer auth on the MCP URL. + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(MCP_URL); + expect(init?.headers).toMatchObject({ + authorization: 'Bearer user-key', + }); + }); + + it('throws (400) when the tunnel is unreachable', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('ECONNREFUSED')); + await expect( + verifyMcpReachable(MCP_URL, 'user-key'), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('throws (400) with an auth-specific message on 401', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(null, { status: 401 })); + await expect(verifyMcpReachable(MCP_URL, 'bad-key')).rejects.toThrow( + /rejected the access key/, + ); + }); + + it('throws (400) when the URL is not the MCP endpoint (404)', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(null, { status: 404 })); + await expect( + verifyMcpReachable(MCP_URL, 'user-key'), + ).rejects.toMatchObject({ status: 400 }); + }); + }); + + it('provisioning fails fast (no Anthropic calls) when MCP is unreachable', async () => { + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(null, { status: 404 })); + const teamId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey(); + + await expect( + provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'user-access-key', + name: 'x', + model: 'claude-opus-4-8', + }), + ).rejects.toMatchObject({ status: 400 }); + + // Only the MCP preflight ran — no environment/vault/agent was created. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toBe(MCP_URL); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + describe('provisionClickStackAgent system-prompt seam', () => { + const agentBodyFrom = (spy: jest.SpyInstance) => + JSON.parse( + String( + spy.mock.calls.find(([u]: any) => + String(u).endsWith('/v1/agents'), + )![1].body, + ), + ); + + const seedKeyForProvision = async () => { + const teamId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey(); + return { teamId, userId }; + }; + + it('provisions with the extension-resolved system prompt when one is registered', async () => { + fetchSpy = mockAnthropic(); + const { teamId, userId } = await seedKeyForProvision(); + registerAgentRunExtension({ + name: 'sys-swap', + onProvisionAgent: async ctx => ({ + systemPrompt: `CUSTOM SYSTEM PROMPT (extends: ${ctx.defaultSystemPrompt.slice(0, 10)}...)`, + }), + }); + + await provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'hdx_key', + name: 'SRE Responder', + model: 'claude-opus-4-8', + }); + + expect(agentBodyFrom(fetchSpy).system).toContain('CUSTOM SYSTEM PROMPT'); + }); + + // A team brief is what makes a second agent worth having; it must reach + // the Anthropic agent object, and must not displace the standing contract + // that keeps the agent read-only. + it('appends the team brief to the standing prompt', async () => { + fetchSpy = mockAnthropic(); + const { teamId, userId } = await seedKeyForProvision(); + + const agent = await provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'hdx_key', + name: 'DB specialist', + model: 'claude-opus-4-8', + instructions: 'Check ClickHouse replication lag first.', + }); + + const sent = agentBodyFrom(fetchSpy).system; + expect(sent).toContain('You are an SRE agent'); + expect(sent).toContain('Check ClickHouse replication lag first.'); + expect(sent).toContain('Do not make changes to production systems.'); + expect(agent.instructions).toBe( + 'Check ClickHouse replication lag first.', + ); + }); + + it('leaves the prompt untouched when no brief is given', async () => { + fetchSpy = mockAnthropic(); + const { teamId, userId } = await seedKeyForProvision(); + + const agent = await provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'hdx_key', + name: 'Generalist', + model: 'claude-opus-4-8', + instructions: ' ', + }); + + expect(agentBodyFrom(fetchSpy).system).not.toContain('Team instructions'); + expect(agent.instructions).toBeUndefined(); + }); + + it('provisions with the OSS default system prompt when no extension overrides it', async () => { + fetchSpy = mockAnthropic(); + const { teamId, userId } = await seedKeyForProvision(); + + await provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'hdx_key', + name: 'SRE Responder', + model: 'claude-opus-4-8', + }); + + expect(agentBodyFrom(fetchSpy).system).toContain('You are an SRE agent'); + }); + + it('rolls back the created environment and vault when agent creation fails', async () => { + const deleted: string[] = []; + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + const u = String(url); + const ok = (data: unknown) => new Response(JSON.stringify(data)); + if (u === MCP_URL) return ok({ ok: true }); + if (init?.method === 'DELETE') { + deleted.push(u); + return ok({}); + } + if (u.endsWith('/v1/environments')) return ok({ id: 'env_1' }); + if (u.endsWith('/v1/vaults')) return ok({ id: 'vlt_1' }); + if (u.includes('/credentials')) return ok({ id: 'cred_1' }); + // Agent creation fails (e.g. invalid model) after env + vault exist. + if (u.endsWith('/v1/agents')) + return new Response('bad model', { status: 400 }); + throw new Error(`unexpected fetch to ${u}`); + }); + const { teamId, userId } = await seedKeyForProvision(); + + await expect( + provisionClickStackAgent({ + teamId, + userId, + userAccessKey: 'hdx_key', + name: 'SRE Responder', + model: 'bad-model', + }), + ).rejects.toBeInstanceOf(AnthropicApiError); + + // The already-created vault and environment are best-effort deleted, and + // no local ManagedAgent record is persisted. + expect(deleted.some(u => u.endsWith('/v1/vaults/vlt_1'))).toBe(true); + expect(deleted.some(u => u.endsWith('/v1/environments/env_1'))).toBe( + true, + ); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + }); + + // Seeds a team with a key + provisioned agent so startAgentSession has + // something to work with. + const seedAgent = async () => { + const teamId = new mongoose.Types.ObjectId(); + registerTestAnthropicKey(); + const agent = await ManagedAgent.create({ + team: teamId, + name: 'prod SRE', + model: 'claude-opus-4-8', + anthropicAgentId: 'agent_1', + vaultId: 'vlt_1', + environmentId: 'env_1', + mcpServerUrl: MCP_URL, + }); + return { teamId, agentId: agent._id.toString() }; + }; + + describe('startAgentSession', () => { + const startArgs = ( + teamId: mongoose.Types.ObjectId, + agentId: string, + over = {}, + ) => ({ + agentId, + teamId, + alertId: 'alert_1', + eventId: 'evt_1', + title: 'CPU high', + prompt: 'investigate', + ...over, + }); + + const mockSessions = () => + jest.spyOn(global, 'fetch').mockImplementation(async (url: any) => { + const u = String(url); + const body = (data: unknown) => new Response(JSON.stringify(data)); + if (u.endsWith('/events')) return body({}); + if (u.endsWith('/v1/sessions')) return body({ id: 'sess_1' }); + throw new Error(`unexpected fetch to ${u}`); + }); + + // Like mockSessions but captures the kickoff events body and returns a + // fixed session id, so the extension seam's resolved prompt is observable. + let capturedEventsBody: any; + const mockSessionsCapturing = () => + jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + const u = String(url); + const body = (data: unknown) => new Response(JSON.stringify(data)); + if (u.endsWith('/events')) { + capturedEventsBody = JSON.parse(init.body); + return body({}); + } + if (u.endsWith('/v1/sessions')) return body({ id: 'sesn_test' }); + throw new Error(`unexpected fetch to ${u}`); + }); + + it('starts a session, injects the prompt, and persists a run', async () => { + fetchSpy = mockSessions(); + const { teamId, agentId } = await seedAgent(); + + const { run, deduped } = await startAgentSession( + startArgs(teamId, agentId), + ); + + expect(deduped).toBe(false); + expect(run).not.toBeNull(); + expect(run!.anthropicSessionId).toBe('sess_1'); + expect(run!.alertId).toBe('alert_1'); + // Per-event, per-cooldown-window key: eventId:. + // Per-alert, per-agent, per-window key — deliberately group-agnostic so + // a high-cardinality grouped alert cannot fan out one session per group. + expect(run!.dedupeKey).toMatch(/^alert_1:[0-9a-f]{24}:\d+$/); + + const calls = fetchSpy.mock.calls.map(([u]) => String(u)); + expect(calls).toContain('https://api.anthropic.com/v1/sessions'); + expect(calls.some(u => u.endsWith('/sessions/sess_1/events'))).toBe(true); + }); + + it('is idempotent per firing — a re-fire reuses the run, no second session', async () => { + fetchSpy = mockSessions(); + const { teamId, agentId } = await seedAgent(); + + const first = await startAgentSession(startArgs(teamId, agentId)); + const sessionPosts = () => + fetchSpy.mock.calls.filter(([u]: any) => + String(u).endsWith('/v1/sessions'), + ).length; + expect(sessionPosts()).toBe(1); + + const second = await startAgentSession(startArgs(teamId, agentId)); + expect(second.deduped).toBe(true); + expect(second.run!._id.toString()).toBe(first.run!._id.toString()); + expect(sessionPosts()).toBe(1); // no new session created + expect(await AgentRun.countDocuments({})).toBe(1); + }); + + // The sequential test above is served by the findOne fast path; this one + // exercises the unique index underneath it. + it('spends one session when two firings race, not one each', async () => { + fetchSpy = mockSessions(); + const { teamId, agentId } = await seedAgent(); + + const [first, second] = await Promise.all([ + startAgentSession(startArgs(teamId, agentId)), + startAgentSession(startArgs(teamId, agentId)), + ]); + + const sessionPosts = fetchSpy.mock.calls.filter(([u]: any) => + String(u).endsWith('/v1/sessions'), + ).length; + expect(sessionPosts).toBe(1); + expect(await AgentRun.countDocuments({})).toBe(1); + // Exactly one winner, and the loser resolves to the winner's run rather + // than erroring — the caller has an investigation either way. + expect([first.deduped, second.deduped].sort()).toEqual([false, true]); + expect(first.run!._id.toString()).toBe(second.run!._id.toString()); + }); + + // The other failure test kills the events POST, by which point a session + // exists. This kills session creation itself, so the reservation has to be + // released with nothing to tear down on Anthropic's side. + it('releases the reservation when session creation itself fails', async () => { + const { teamId, agentId } = await seedAgent(); + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any) => { + if (String(url).endsWith('/v1/sessions')) { + return new Response('nope', { status: 500 }); + } + throw new Error(`unexpected fetch to ${url}`); + }); + + await expect( + startAgentSession(startArgs(teamId, agentId)), + ).rejects.toBeInstanceOf(AnthropicApiError); + + // No reservation left to dedupe the next firing away, and no DELETE + // attempted for a session that never existed. + expect(await AgentRun.countDocuments({})).toBe(0); + expect( + fetchSpy.mock.calls.filter( + ([, init]: any) => init?.method === 'DELETE', + ), + ).toEqual([]); + }); + + it('collapses different events (groups) of the same alert into one session per window', async () => { + fetchSpy = mockSessions(); + const { teamId, agentId } = await seedAgent(); + + const first = await startAgentSession( + startArgs(teamId, agentId, { eventId: 'evt_group_a' }), + ); + const second = await startAgentSession( + startArgs(teamId, agentId, { eventId: 'evt_group_b' }), + ); + + expect(first.deduped).toBe(false); + expect(second.deduped).toBe(true); + expect(second.run!._id.toString()).toBe(first.run!._id.toString()); + expect(await AgentRun.countDocuments({})).toBe(1); + }); + + it('does not dedupe across different agents on the same alert', async () => { + fetchSpy = mockSessions(); + const { teamId, agentId } = await seedAgent(); + const other = await ManagedAgent.create({ + team: teamId, + name: 'second SRE', + model: 'claude-opus-4-8', + anthropicAgentId: 'agent_2', + vaultId: 'vlt_2', + environmentId: 'env_2', + mcpServerUrl: MCP_URL, + }); + + const first = await startAgentSession(startArgs(teamId, agentId)); + const second = await startAgentSession( + startArgs(teamId, other._id.toString()), + ); + + expect(first.deduped).toBe(false); + expect(second.deduped).toBe(false); + expect(await AgentRun.countDocuments({})).toBe(2); + }); + + it('throws (no session call) when the agent does not exist', async () => { + fetchSpy = mockSessions(); + const { teamId } = await seedAgent(); + const missingAgentId = new mongoose.Types.ObjectId().toString(); + + await expect( + startAgentSession(startArgs(teamId, missingAgentId)), + ).rejects.toThrow(/Managed agent not found/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws (no session call) when the agent belongs to another team', async () => { + fetchSpy = mockSessions(); + const { agentId } = await seedAgent(); + const otherTeamId = new mongoose.Types.ObjectId(); + + await expect( + startAgentSession(startArgs(otherTeamId, agentId)), + ).rejects.toThrow(/Managed agent not found/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('applies session-start extensions: prompt suffix sent, metadata persisted', async () => { + fetchSpy = mockSessionsCapturing(); + const { teamId, agentId } = await seedAgent(); + registerAgentRunExtension({ + name: 'fake-notebook', + onSessionStart: async ctx => ({ + promptSuffix: `\nNOTEBOOK for ${ctx.anthropicSessionId}`, + runMetadata: { notebookId: 'nb1' }, + }), + }); + + const { run } = await startAgentSession( + startArgs(teamId, agentId, { eventId: 'e-ext-1', prompt: '{"p":1}' }), + ); + + expect(run).not.toBeNull(); + expect(run!.metadata).toEqual({ notebookId: 'nb1' }); + const kickoffText = capturedEventsBody.events[0].content[0].text; + expect(kickoffText).toBe('{"p":1}\nNOTEBOOK for sesn_test'); + }); + + it('lets an extension replace the kickoff prompt wholesale', async () => { + fetchSpy = mockSessionsCapturing(); + const { teamId, agentId } = await seedAgent(); + registerAgentRunExtension({ + name: 'prompt-swap', + onSessionStart: async () => ({ promptOverride: 'CUSTOM PROMPT' }), + }); + + const { run } = await startAgentSession( + startArgs(teamId, agentId, { eventId: 'e-ext-3', prompt: '{"p":3}' }), + ); + + expect(run).not.toBeNull(); + const kickoffText = capturedEventsBody.events[0].content[0].text; + expect(kickoffText).toBe('CUSTOM PROMPT'); + }); + + it('starts the session unchanged when a session-start extension throws (fail-open)', async () => { + fetchSpy = mockSessionsCapturing(); + const { teamId, agentId } = await seedAgent(); + registerAgentRunExtension({ + name: 'broken', + onSessionStart: async () => { + throw new Error('boom'); + }, + }); + + const { run } = await startAgentSession( + startArgs(teamId, agentId, { eventId: 'e-ext-2', prompt: '{"p":2}' }), + ); + + expect(run).not.toBeNull(); + expect(run!.metadata).toBeUndefined(); + const kickoffText = capturedEventsBody.events[0].content[0].text; + expect(kickoffText).toBe('{"p":2}'); + }); + + it('cleans up the session when the kickoff events POST fails', async () => { + const deleted: string[] = []; + fetchSpy = jest + .spyOn(global, 'fetch') + .mockImplementation(async (url: any, init: any) => { + const u = String(url); + const body = (data: unknown) => new Response(JSON.stringify(data)); + if (init?.method === 'DELETE') { + deleted.push(u); + return body({}); + } + if (u.endsWith('/events')) + return new Response('boom', { status: 500 }); + if (u.endsWith('/v1/sessions')) return body({ id: 'sess_1' }); + throw new Error(`unexpected fetch to ${u}`); + }); + const { teamId, agentId } = await seedAgent(); + + await expect( + startAgentSession(startArgs(teamId, agentId)), + ).rejects.toBeInstanceOf(AnthropicApiError); + expect(deleted.some(u => u.endsWith('/v1/sessions/sess_1'))).toBe(true); + expect(await AgentRun.countDocuments({})).toBe(0); + }); + }); +}); diff --git a/packages/api/src/services/agentRunExtensions.ts b/packages/api/src/services/agentRunExtensions.ts new file mode 100644 index 0000000000..08fbeb5f82 --- /dev/null +++ b/packages/api/src/services/agentRunExtensions.ts @@ -0,0 +1,294 @@ +import type { ManagedAgentDocument } from '@/models/managedAgent'; +import { getCounter, withSpan } from '@/utils/instrumentation'; +import logger from '@/utils/logger'; + +// Extension seams for the managed-agent alert flow (anthropicAgents.ts). +// +// Downstream distributions register extensions from +// packages/api/src/extensions/index.ts — never by editing the core flow — +// so upstream merges stay conflict-free. Hooks are fail-open: a throwing +// extension is recorded (span + counter + log) and contributes nothing; +// the core flow never fails because of an extension. +// +// With zero extensions registered (the OSS default) the runners return +// neutral values and the core flow behaves exactly as before the seams. + +export interface AgentProvisionContext { + teamId: string; + name: string; + model: string; + mcpServerUrl: string; + // The OSS default system prompt, so overriders can extend rather than + // rewrite from scratch. + defaultSystemPrompt: string; +} + +/** + * @public Part of the extension seam contract consumed by downstream + * distributions; it has no in-repo importer by design (OSS ships no + * extensions), so it is marked `@public` for knip. + */ +export interface AgentProvisionResult { + // Replaces the agent's standing system prompt wholesale. Baked into the + // Anthropic agent object at provisioning time — affects NEW provisions only. + systemPrompt?: string; +} + +export interface AgentSessionStartContext { + teamId: string; + agent: ManagedAgentDocument; + anthropicSessionId: string; + title: string; + // The OSS-built kickoff payload (enriched alert JSON) — overriders can + // JSON.parse it, tweak the embedded instruction, and re-stringify. + prompt: string; +} + +/** + * @public Part of the extension seam contract consumed by downstream + * distributions; no in-repo importer by design. + */ +export interface AgentSessionStartResult { + // Replaces the kickoff payload wholesale (last registered override wins). + promptOverride?: string; + // Appended verbatim after the resolved kickoff payload. + promptSuffix?: string; + // Shallow-merged across extensions, then persisted as AgentRun.metadata so + // the extension can correlate the run later. A later extension's duplicate + // key wins silently, so keep keys extension-specific (e.g. `notebookId`). + runMetadata?: Record; +} + +export interface AgentKeyResolutionContext { + teamId: string; +} + +/** + * @public Part of the extension seam contract consumed by downstream + * distributions; no in-repo importer by design. + */ +export interface AgentKeyResolutionResult { + // The Anthropic API key to use for this team's managed-agent calls. + apiKey?: string; +} + +export interface AgentRunExtension { + // Stable identifier, used as a telemetry attribute. + name: string; + // At agent provisioning time, before the Anthropic agent is created. + onProvisionAgent?( + ctx: AgentProvisionContext, + ): Promise; + // After the Anthropic session exists, before the kickoff message is sent. + onSessionStart?( + ctx: AgentSessionStartContext, + ): Promise; + // Resolves the team's Anthropic API key. OSS resolves the key from env + // (see getTeamAnthropicKey); a downstream distribution can register this to + // provide a per-team key instead (last registered wins). Fail-open: a + // throwing resolver contributes nothing and the caller falls back to the env + // key — so a transient failure resolving a per-team key degrades to the + // operator's global key rather than failing closed. Implement the resolver + // robustly (return void to opt out, never throw for an expected "no key") + // if per-team key isolation must be strict. + resolveAnthropicKey?( + ctx: AgentKeyResolutionContext, + ): Promise; +} + +type HookName = + | 'on_provision_agent' + | 'on_session_start' + | 'on_resolve_anthropic_key'; + +const extensions: AgentRunExtension[] = []; + +const extensionEventsCounter = getCounter( + 'hyperdx.agent_run.extension_events', + { + description: 'Managed-agent extension hook outcomes', + }, +); + +export const registerAgentRunExtension = (ext: AgentRunExtension): void => { + extensions.push(ext); +}; + +// Registration is module-level state; suites reset it between tests. +export const resetAgentRunExtensionsForTests = (): void => { + extensions.length = 0; +}; + +// Session start runs inside the check-alerts sweep, whose queue has no timeout +// and whose cron waits for completion — so a hook that never settles would +// stall alert evaluation for every team, not just the one that triggered it. +// Fail-open has to cover hanging, not only throwing. +const EXTENSION_HOOK_TIMEOUT_MS = 5_000; + +// Key resolution gets longer: it is the one hook expected to make its own +// network call (a secret manager), and timing it out doesn't skip a +// contribution — it silently falls back to the deployment-wide key, which is +// the wrong credential rather than a missing embellishment. +const KEY_RESOLUTION_TIMEOUT_MS = 20_000; + +const timeoutFor = (hook: HookName) => + hook === 'on_resolve_anthropic_key' + ? KEY_RESOLUTION_TIMEOUT_MS + : EXTENSION_HOOK_TIMEOUT_MS; + +// Named for what it bounds, not what it does: `withDeadline` is already taken +// by the tile-query helper, which races a thunk against an absolute deadline +// and aborts its signal. This one takes a promise it cannot cancel. +const withHookTimeout = async ( + work: Promise, + extensionName: string, + timeoutMs: number, +): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error(`Extension "${extensionName}" exceeded ${timeoutMs}ms`), + ), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +// Runs one hook across every registered extension. Each invocation is its own +// wide event; an extension that throws or overruns its deadline contributes +// nothing rather than failing the core flow. +const runHook = async ( + hook: HookName, + pick: ( + ext: AgentRunExtension, + ) => ((ctx: TCtx) => Promise) | undefined, + ctx: TCtx, +): Promise => { + const results: TResult[] = []; + for (const ext of extensions) { + const fn = pick(ext); + if (!fn) continue; + try { + const result = await withSpan(`agent_extension.${hook}`, async span => { + span.setAttribute('agent_extension.name', ext.name); + return withHookTimeout( + Promise.resolve(fn.call(ext, ctx)), + ext.name, + timeoutFor(hook), + ); + }); + extensionEventsCounter.add(1, { hook, outcome: 'ok' }); + if (result) results.push(result); + } catch (e) { + extensionEventsCounter.add(1, { hook, outcome: 'error' }); + logger.error( + { + extension: ext.name, + hook, + error: e instanceof Error ? e.message : e, + }, + 'Agent run extension hook failed; continuing without its contribution', + ); + } + } + return results; +}; + +// Picks the winning override when several extensions return one: last +// registered wins, and the displacement is warned about so a surprising +// collision is diagnosable rather than silent. +const pickLastOverride = ( + hook: string, + overrides: string[], +): string | undefined => { + if (overrides.length > 1) { + logger.warn( + { hook, count: overrides.length }, + 'Multiple extensions returned an override; using the last registered', + ); + } + return overrides.at(-1); +}; + +// Resolves the standing system prompt for a new agent: the last extension to +// return one wins; with none registered the OSS default is used unchanged. +export const runProvisionExtensions = async ( + ctx: AgentProvisionContext, +): Promise<{ systemPrompt: string }> => { + const results = await runHook( + 'on_provision_agent', + ext => ext.onProvisionAgent, + ctx, + ); + const override = pickLastOverride( + 'on_provision_agent', + results + .map(r => r.systemPrompt) + .filter((p): p is string => typeof p === 'string' && p.length > 0), + ); + return { systemPrompt: override ?? ctx.defaultSystemPrompt }; +}; + +// Resolves the kickoff message: the last promptOverride (if any) replaces the +// OSS payload, then every extension's suffix is appended in registration +// order. With nothing registered the result is ctx.prompt unchanged. +export const runSessionStartExtensions = async ( + ctx: AgentSessionStartContext, +): Promise<{ + prompt: string; + runMetadata?: Record; +}> => { + const results = await runHook( + 'on_session_start', + ext => ext.onSessionStart, + ctx, + ); + const override = pickLastOverride( + 'on_session_start', + results + .map(r => r.promptOverride) + .filter((p): p is string => typeof p === 'string' && p.length > 0), + ); + const promptSuffix = results.map(r => r.promptSuffix ?? '').join(''); + const metadataEntries = results + .map(r => r.runMetadata) + .filter( + (m): m is Record => !!m && Object.keys(m).length > 0, + ); + return { + prompt: (override ?? ctx.prompt) + promptSuffix, + runMetadata: + metadataEntries.length > 0 + ? metadataEntries.reduce>( + (acc, m) => ({ ...acc, ...m }), + {}, + ) + : undefined, + }; +}; + +// Resolves the team's Anthropic API key via extensions: the last extension to +// return a non-empty key wins. With none registered (the OSS default) this +// returns null and the caller falls back to the env-configured key. +export const runAnthropicKeyExtensions = async ( + ctx: AgentKeyResolutionContext, +): Promise => { + const results = await runHook( + 'on_resolve_anthropic_key', + ext => ext.resolveAnthropicKey, + ctx, + ); + const keys = results + .map(r => r.apiKey) + .filter((k): k is string => typeof k === 'string' && k.length > 0); + return keys.at(-1) ?? null; +}; diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts new file mode 100644 index 0000000000..4f5c9bb59c --- /dev/null +++ b/packages/api/src/services/anthropicAgents.ts @@ -0,0 +1,775 @@ +// Loads downstream extension registrations (no-op in OSS) — see +// packages/api/src/extensions/index.ts for the contract. +import '@/extensions'; + +import { + AGENT_TOOLSET, + AUTO_ALLOWED_MCP_TOOLS, +} from '@hyperdx/common-utils/dist/managedAgents'; +import { serializeError } from 'serialize-error'; + +import * as config from '@/config'; +import type { ObjectId } from '@/models'; +import AgentRun, { AgentRunDocument } from '@/models/agentRun'; +import ManagedAgent, { ManagedAgentDocument } from '@/models/managedAgent'; +import { + runAnthropicKeyExtensions, + runProvisionExtensions, + runSessionStartExtensions, +} from '@/services/agentRunExtensions'; +import { isDuplicateKeyError } from '@/utils/errors'; +import { + getCounter, + setBusinessContext, + withOperationMetrics, +} from '@/utils/instrumentation'; +import logger from '@/utils/logger'; + +const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; +const ANTHROPIC_VERSION = '2023-06-01'; +const ANTHROPIC_BETA = 'managed-agents-2026-04-01'; + +// Every outbound Anthropic call is bounded so a hung/slow endpoint cannot stall +// the check-alerts sweep (agent sessions are started inline on that cron). +const ANTHROPIC_REQUEST_TIMEOUT_MS = 20_000; + +const SRE_SYSTEM_PROMPT = `You are an SRE agent for ClickStack/HyperDX. A ClickStack alert has fired. Investigate the root cause using the clickstack MCP server (logs, traces, metrics, and alert history). Reconstruct and re-run the alert's source query over its time range, inspect related logs, traces, and metrics, follow any linked runbook, and check recent deploys. Produce a concise, evidence-linked root-cause summary and suggested next steps. Do not make changes to production systems.`; + +// ClickStack MCP tools an alert-triggered agent may run without approval. +// An orphan is a vault or environment nothing points at any more — the vault +// case leaves a live ClickStack credential on Anthropic, so it needs to be +// alertable, not just greppable in the logs. +const orphanedResourceCounter = getCounter( + 'hyperdx.managed_agents.orphaned_resources', + { + description: + 'Count of Anthropic resources left behind by a failed managed-agent rollback or teardown.', + }, +); + +export class AnthropicApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'AnthropicApiError'; + this.status = status; + } +} + +// Single choke point for outbound calls — only ever talks to api.anthropic.com. +const anthropicRequest = async ( + apiKey: string, + method: 'GET' | 'POST' | 'DELETE', + path: string, + body?: unknown, +): Promise => { + let res: Response; + try { + res = await fetch(`${ANTHROPIC_API_BASE}${path}`, { + method, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + 'anthropic-beta': ANTHROPIC_BETA, + 'content-type': 'application/json', + }, + // Bounded so a hung request can't block the alert sweep forever. + signal: AbortSignal.timeout(ANTHROPIC_REQUEST_TIMEOUT_MS), + ...(body != null ? { body: JSON.stringify(body) } : {}), + }); + } catch (e) { + // Network failure or the timeout above (AbortSignal → TimeoutError). + // Surface as an AnthropicApiError (504) so callers handle transport + // failures the same way as HTTP errors. + throw new AnthropicApiError( + `Anthropic API ${method} ${path} failed: ${e instanceof Error ? e.message : String(e)}`, + 504, + ); + } + if (!res.ok) { + const text = await res.text(); + throw new AnthropicApiError( + `Anthropic API ${method} ${path} failed (${res.status}): ${text}`, + res.status, + ); + } + // DELETE may return an empty body. + const text = await res.text(); + return text ? JSON.parse(text) : {}; +}; + +// Resolves an Anthropic API key from the environment for managed-agent calls. +// Managed agents are Anthropic-specific, so AI_API_KEY is trusted only when the +// AI provider is EXPLICITLY Anthropic; a legacy setup (no AI_PROVIDER) uses only +// the unambiguous ANTHROPIC_API_KEY. This guarantees a non-Anthropic AI_API_KEY +// (e.g. an OpenAI key, or an ambiguous key in a misconfigured legacy setup) is +// never sent to api.anthropic.com. +const resolveEnvAnthropicKey = (): string | null => { + const key = + (config.AI_PROVIDER === config.AIProvider.Anthropic + ? config.AI_API_KEY + : '') || config.ANTHROPIC_API_KEY; + return key || null; +}; + +// Resolves the Anthropic API key for a team, or null if none is configured. +// OSS resolves it from the environment; a downstream distribution can register +// a `resolveAnthropicKey` extension to supply a per-team key, which takes +// precedence (see services/agentRunExtensions.ts). +export const getTeamAnthropicKey = async ( + teamId: ObjectId, +): Promise => { + const fromExtension = await runAnthropicKeyExtensions({ + teamId: teamId.toString(), + }); + return fromExtension ?? resolveEnvAnthropicKey(); +}; + +// Confirms the agent will actually be able to reach the ClickStack MCP server +// before we provision anything on Anthropic. The agent authenticates with the +// same bearer token, so we exercise that exact path (reachability + auth) by +// sending an MCP `initialize`. This catches the common setup mistakes up front — +// a dead/wrong tunnel (no response or 404), the URL pointed at the wrong port or +// path, or an access key the MCP server rejects (401) — instead of creating an +// agent that silently can't talk to ClickStack. +export const verifyMcpReachable = async ( + mcpServerUrl: string, + userAccessKey: string, +): Promise => { + const unreachable = (detail: string) => + new AnthropicApiError( + `Could not reach the ClickStack MCP server at ${mcpServerUrl}: ${detail}. ` + + `Check that the URL is a live HTTPS tunnel to your instance — the API port uses the path /mcp, the app port uses /api/mcp.`, + 400, + ); + + let res: Response; + try { + res = await fetch(mcpServerUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${userAccessKey}`, + 'content-type': 'application/json', + // The streamable-HTTP transport requires both content types in Accept. + accept: 'application/json, text/event-stream', + }, + // Bounded like the Anthropic calls, so a tunnel that accepts the + // connection but never responds can't hang the provisioning request. + signal: AbortSignal.timeout(ANTHROPIC_REQUEST_TIMEOUT_MS), + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'hyperdx-provision-check', version: '1.0.0' }, + }, + }), + }); + } catch (e) { + throw unreachable(e instanceof Error ? e.message : String(e)); + } + + if (res.status === 401) { + throw new AnthropicApiError( + `The ClickStack MCP server at ${mcpServerUrl} rejected the access key (401), so the agent would not be able to authenticate.`, + 400, + ); + } + // Auth passed (no 401) and the MCP endpoint answered. A non-OK status here + // means the URL is not the MCP endpoint (e.g. 404 from a wrong path or an + // offline tunnel). + if (!res.ok) { + throw unreachable(`it returned HTTP ${res.status}`); + } +}; + +// Anthropic requires the MCP server URL to be public HTTPS (its cloud sandbox +// reaches it directly). In real deployments FRONTEND_URL is already HTTPS; for +// local testing point HDX_MANAGED_AGENTS_MCP_URL at a public tunnel (e.g. +// ngrok) to your instance's /api/mcp. +const resolveMcpServerUrl = (): string => { + const mcpServerUrl = + config.getManagedAgentsMcpUrl() || `${config.FRONTEND_URL}/api/mcp`; + if (!mcpServerUrl.startsWith('https://')) { + throw new AnthropicApiError( + `The ClickStack MCP URL must use HTTPS for Anthropic (got "${mcpServerUrl}"). Set HDX_MANAGED_AGENTS_MCP_URL to a public HTTPS URL (e.g. an ngrok tunnel to your local instance's /api/mcp) and try again.`, + 400, + ); + } + return mcpServerUrl; +}; + +// The environment and vault a session needs, whoever wrote the agent itself. +// Rolls back its own resources if a later step fails, so a half-made pair is +// never left behind. The MCP URL is written identically here and on the agent — +// credential injection matches on that URL, so any drift would break auth. +const provisionAgentSupport = async ({ + apiKey, + name, + mcpServerUrl, + userAccessKey, +}: { + apiKey: string; + name: string; + mcpServerUrl: string; + userAccessKey: string; +}): Promise<{ environmentId: string; vaultId: string }> => { + let environmentId: string | undefined; + let vaultId: string | undefined; + try { + const environment = await anthropicRequest( + apiKey, + 'POST', + '/v1/environments', + { + name: `clickstack-sre-${name}`, + config: { type: 'cloud', networking: { type: 'unrestricted' } }, + }, + ); + environmentId = environment.id; + + const vault = await anthropicRequest(apiKey, 'POST', '/v1/vaults', { + display_name: `ClickStack: ${name}`, + }); + vaultId = vault.id; + + await anthropicRequest( + apiKey, + 'POST', + `/v1/vaults/${vault.id}/credentials`, + { + display_name: 'ClickStack Personal API Access Key', + auth: { + type: 'static_bearer', + mcp_server_url: mcpServerUrl, + token: userAccessKey, + }, + }, + ); + + return { environmentId: environment.id, vaultId: vault.id }; + } catch (e) { + reportOrphans( + await deleteAnthropicResources(apiKey, { environmentId, vaultId }), + ); + throw e; + } +}; + +// Provisions a ClickStack SRE agent: environment + vault (with the ClickStack +// MCP credential) + agent, then persists the references. +const provisionClickStackAgentImpl = async ({ + teamId, + userId, + userAccessKey, + name, + model, + instructions, +}: { + teamId: ObjectId; + userId: ObjectId; + userAccessKey: string; + name: string; + model: string; + /** Team-authored brief, appended to the standing prompt. */ + instructions?: string; +}) => { + const apiKey = await getTeamAnthropicKey(teamId); + if (!apiKey) { + throw new AnthropicApiError( + 'No Anthropic API key configured for this team', + 400, + ); + } + + const mcpServerUrl = resolveMcpServerUrl(); + + // Fail fast if the agent won't be able to talk to ClickStack, before creating + // any Anthropic resources. + await verifyMcpReachable(mcpServerUrl, userAccessKey); + + const { environmentId, vaultId } = await provisionAgentSupport({ + apiKey, + name, + mcpServerUrl, + userAccessKey, + }); + + // If agent creation or the local persist fails, best-effort delete what was + // created so a partial failure doesn't leave orphaned resources the user + // can't see or remove. + let agentId: string | undefined; + try { + // Extension seam: downstream may replace the standing system prompt + // wholesale (fail-open — the OSS default is used if nothing overrides). + // The prompt is baked into the Anthropic agent object, so a swap applies + // to newly provisioned agents only. + // Appended, not substituted: the standing prompt carries the operational + // contract (investigate through MCP, change nothing in production), which + // a team brief should refine rather than be able to drop. + const brief = instructions?.trim(); + const basePrompt = brief + ? `${SRE_SYSTEM_PROMPT}\n\nTeam instructions for this agent:\n${brief}` + : SRE_SYSTEM_PROMPT; + const { systemPrompt } = await runProvisionExtensions({ + teamId: teamId.toString(), + name, + model, + mcpServerUrl, + defaultSystemPrompt: basePrompt, + }); + + const agent = await anthropicRequest(apiKey, 'POST', '/v1/agents', { + name, + model, + system: systemPrompt, + mcp_servers: [{ type: 'url', name: 'clickstack', url: mcpServerUrl }], + tools: [ + AGENT_TOOLSET, + { + type: 'mcp_toolset', + mcp_server_name: 'clickstack', + // An unattended session can never answer an approval prompt, so + // anything left at `always_ask` simply never runs: only the + // allowlisted read tools fire from an alert, and the MCP server's + // save_*/delete_*/patch_* tools stall instead. `always_ask` is this + // toolset's default, but that is not a general rule — the built-in + // toolset defaults the other way, which is why AGENT_TOOLSET above + // spells its policy out. + default_config: { permission_policy: { type: 'always_ask' } }, + configs: AUTO_ALLOWED_MCP_TOOLS.map(toolName => ({ + name: toolName, + permission_policy: { type: 'always_allow' }, + })), + }, + ], + }); + agentId = agent.id; + + return await ManagedAgent.create({ + team: teamId, + name, + model, + ...(brief ? { instructions: brief } : {}), + anthropicAgentId: agent.id, + vaultId, + environmentId, + mcpServerUrl, + createdBy: userId, + }); + } catch (e) { + reportOrphans( + await deleteAnthropicResources(apiKey, { + agentId, + environmentId, + vaultId, + }), + ); + throw e; + } +}; + +// Links an agent that already exists on Anthropic (written by hand, or by +// another tool) so alerts can target it. Only the agent ID comes from the user: +// the environment and vault are provisioned here, exactly as for a HyperDX-made +// agent, because the vault credential has to match this instance's MCP URL and +// carry a working ClickStack key. Reusing whatever vault the user happens to +// have would make the most common failure a silent one — an agent that runs but +// can't read any data. +const importAnthropicAgentImpl = async ({ + teamId, + userId, + userAccessKey, + name, + anthropicAgentId, +}: { + teamId: ObjectId; + userId: ObjectId; + userAccessKey: string; + /** Optional label; defaults to the agent's own name on Anthropic. */ + name?: string; + anthropicAgentId: string; +}): Promise<{ agent: ManagedAgentDocument; verified: boolean }> => { + const apiKey = await getTeamAnthropicKey(teamId); + if (!apiKey) { + throw new AnthropicApiError( + 'No Anthropic API key is configured, so HyperDX could not start sessions for this agent. Set one and import again.', + 400, + ); + } + + const existing = await ManagedAgent.findOne({ + team: teamId, + anthropicAgentId, + }); + if (existing) { + throw new AnthropicApiError( + `That agent is already in the list as "${existing.name}".`, + 409, + ); + } + + const mcpServerUrl = resolveMcpServerUrl(); + await verifyMcpReachable(mcpServerUrl, userAccessKey); + + // Confirms the ID is real before it becomes an alert target, since the + // alternative is finding out at 3am when the alert fires. Only a definite + // "no such agent" blocks the import — any other failure (network, or an + // Anthropic API that does not serve this read) leaves the agent unverified + // rather than unusable. + // The name and model belong to the agent object on Anthropic, so they are + // read from it rather than retyped here — a copy would drift the moment the + // agent is edited there, and the model is only ever displayed: sessions run + // whatever the agent object says. + let verified = false; + let remoteName: string | undefined; + let model: string | undefined; + try { + const info = await anthropicRequest( + apiKey, + 'GET', + `/v1/agents/${encodeURIComponent(anthropicAgentId)}`, + ); + verified = true; + remoteName = typeof info?.name === 'string' ? info.name : undefined; + model = typeof info?.model === 'string' ? info.model : undefined; + } catch (e) { + if (e instanceof AnthropicApiError && e.status === 404) { + throw new AnthropicApiError( + `Anthropic has no agent with ID "${anthropicAgentId}". Copy it from the create response, or from the agent's page in the Claude console.`, + 400, + ); + } + logger.warn( + { error: serializeError(e), anthropicAgentId }, + 'Could not verify an imported agent with Anthropic; importing unverified', + ); + } + + // Falls back to the ID so the row is never nameless when the read failed. + const label = name?.trim() || remoteName || anthropicAgentId; + + const { environmentId, vaultId } = await provisionAgentSupport({ + apiKey, + name: label, + mcpServerUrl, + userAccessKey, + }); + + // Same rollback contract as provisioning: the record is the only pointer to + // the vault holding the user's ClickStack key, so a failed write must not + // leave that vault behind. The agent object is the user's and is never torn + // down here. + let agent: ManagedAgentDocument; + try { + agent = await ManagedAgent.create({ + team: teamId, + name: label, + ...(model ? { model } : {}), + anthropicAgentId, + vaultId, + environmentId, + mcpServerUrl, + imported: true, + createdBy: userId, + }); + } catch (e) { + reportOrphans( + await deleteAnthropicResources(apiKey, { environmentId, vaultId }), + ); + throw e; + } + return { agent, verified }; +}; + +// A failed rollback leaves resources nothing points at — the caller is about to +// throw the original error, so log the leftovers loudly rather than discard the +// list the way the delete path reports it to the user. +const reportOrphans = (orphaned: string[]) => { + if (orphaned.length > 0) { + orphanedResourceCounter.add(orphaned.length); + logger.error( + { orphaned }, + 'Anthropic resources were left behind by a failed rollback', + ); + } +}; + +// Best-effort teardown of an agent's supporting Anthropic resources (vault +// first, then environment). Used both to roll back a failed provisioning run +// and to fully tear an agent down on delete — the vault holds the team's +// ClickStack bearer credential, so it must not be left behind. Each deletion is +// independent and swallows its own error so it never masks the caller's flow. +const deleteAnthropicResources = async ( + apiKey: string, + { + agentId, + environmentId, + vaultId, + }: { agentId?: string; environmentId?: string; vaultId?: string }, +): Promise => { + // Labels of resources still on Anthropic after this ran, so a caller that + // must not report success (a user-initiated delete) can say what is left. + const orphaned: string[] = []; + const cleanup = async (label: string, path: string) => { + try { + await anthropicRequest(apiKey, 'DELETE', path); + } catch (e) { + // Already gone is the desired end state, which also makes a retry after + // a partial failure safe. + if (e instanceof AnthropicApiError && e.status === 404) { + return; + } + logger.warn( + { error: serializeError(e), resource: label }, + 'Failed to delete Anthropic resource; it may be left orphaned', + ); + orphaned.push(label); + } + }; + if (agentId) await cleanup('agent', `/v1/agents/${agentId}`); + if (vaultId) + await cleanup( + 'vault (holds the ClickStack access key)', + `/v1/vaults/${vaultId}`, + ); + if (environmentId) + await cleanup('environment', `/v1/environments/${environmentId}`); + return orphaned; +}; + +// Best-effort delete of a session we created but couldn't fully record, so it +// doesn't keep running (and consuming quota) untracked. +const deleteSessionBestEffort = async ( + apiKey: string, + sessionId: string, +): Promise => { + try { + await anthropicRequest(apiKey, 'DELETE', `/v1/sessions/${sessionId}`); + } catch (e) { + orphanedResourceCounter.add(1); + logger.warn( + { error: serializeError(e), sessionId }, + 'Failed to delete an untracked agent session; it may keep running', + ); + } +}; + +// Best-effort deletion of an agent on Anthropic: the agent object plus its +// supporting vault and environment. Deleting the vault matters for credential +// hygiene — it holds the team's ClickStack bearer token, which would otherwise +// sit on Anthropic indefinitely after the agent is removed. Failures are logged +// but do not block local removal (the user can also delete from the Claude +// console). +// `anthropicAgentId` is null for an imported agent: the agent object is the +// user's, so only the environment and vault HyperDX created are torn down. +const deleteAnthropicAgentImpl = async ( + teamId: ObjectId, + anthropicAgentId: string | null, + resources: { vaultId?: string; environmentId?: string } = {}, +): Promise => { + const apiKey = await getTeamAnthropicKey(teamId); + // Throwing rather than returning quietly: the caller drops the local record + // on success, and a record dropped without the remote teardown orphans the + // vault holding the team's ClickStack credential with nothing left pointing + // at it. + if (!apiKey) { + throw new AnthropicApiError( + 'No Anthropic API key is configured, so the agent could not be deleted on Anthropic. Set the key and retry, or delete it from the Claude console first.', + 400, + ); + } + + const orphaned = await deleteAnthropicResources(apiKey, { + ...(anthropicAgentId ? { agentId: anthropicAgentId } : {}), + ...resources, + }); + if (orphaned.length > 0) { + orphanedResourceCounter.add(orphaned.length); + throw new AnthropicApiError( + `Anthropic still holds: ${orphaned.join(', ')}. The agent was kept so you can retry; deleting again resumes where this left off.`, + 502, + ); + } +}; + +// Alert notifications are level-triggered (they fire every evaluation window +// while the threshold is breached), but we don't want a fresh agent session +// every minute. Dedupe per alert-event per window: re-fires inside the window +// reuse the run; a firing in a later window (a persisting or recurring +// incident) gets a fresh investigation. ponytail: fixed 1h cooldown; tie to the +// alert interval only if a fixed window proves too coarse. +// Placeholder while a run reserves its dedupe key, before the Anthropic +// session exists. Never surfaced: nothing outside this module reads the field. +const PENDING_SESSION_ID = 'pending'; + +const AGENT_DEDUPE_WINDOW_MS = 60 * 60 * 1000; + +// Kicks off a managed-agent investigation for a firing alert: starts an +// Anthropic session against the referenced agent, injects the alert prompt, +// and records an AgentRun for dedupe. The investigation runs (and its result +// lives) in the Anthropic session — the agent's own configuration decides +// where findings go. `deduped` is true when the firing was already handled +// within the dedupe window and the existing run is returned instead. +const startAgentSessionImpl = async ({ + agentId, + teamId, + alertId, + eventId, + title, + prompt, +}: { + agentId: string; + teamId: ObjectId; + alertId?: string; + eventId: string; + title: string; + prompt: string; +}): Promise<{ run: AgentRunDocument | null; deduped: boolean }> => { + setBusinessContext({ teamId: teamId.toString() }); + + // Dedupe per alert per agent per cooldown window (see + // AGENT_DEDUPE_WINDOW_MS). Deliberately NOT per event: eventId hashes the + // group, so a grouped alert with N breaching groups would otherwise start N + // Anthropic sessions every window — unbounded spend on high-cardinality + // group-bys. One investigation per alert per window covers the incident (the + // payload names the triggering group); eventId is only the fallback for a + // caller with no alertId. The findOne is the cheap common-case check; the + // unique index on the reservation below is the real race guard. + const dedupeWindow = Math.floor(Date.now() / AGENT_DEDUPE_WINDOW_MS); + // `||`, not `??`: the caller passes '' for an alert without an id, and an + // empty discriminator would collapse every such alert into one dedupe bucket. + const dedupeKey = `${alertId || eventId}:${agentId}:${dedupeWindow}`; + const existing = await AgentRun.findOne({ team: teamId, dedupeKey }); + if (existing) return { run: existing, deduped: true }; + + // Team-scoped lookup: an alert can only reference its own team's agents. + const agent = await ManagedAgent.findOne({ _id: agentId, team: teamId }); + if (!agent) { + throw new Error( + `Managed agent not found. The agent may have been deleted — update the alert's notification channel.`, + ); + } + + const apiKey = await getTeamAnthropicKey(teamId); + if (!apiKey) { + throw new AnthropicApiError( + 'No Anthropic API key configured for this team', + 400, + ); + } + + // Claim the key before spending, so a concurrent firing loses here rather + // than after paying for a session it discards. Session id filled in below. + let run: AgentRunDocument; + try { + run = await AgentRun.create({ + team: teamId, + managedAgent: agent._id, + anthropicSessionId: PENDING_SESSION_ID, + alertId, + dedupeKey, + title, + }); + } catch (e) { + // Lost the race — the winner owns the investigation. + if (isDuplicateKeyError(e)) { + return { + run: await AgentRun.findOne({ team: teamId, dedupeKey }), + deduped: true, + }; + } + throw e; + } + + let session: { id: string } | undefined; + try { + session = await anthropicRequest(apiKey, 'POST', '/v1/sessions', { + agent: agent.anthropicAgentId, + environment_id: agent.environmentId, + vault_ids: [agent.vaultId], + title, + }); + + // Extension seam: downstream may replace the kickoff payload wholesale, + // append instructions, and stash run metadata (fail-open — a broken + // extension contributes nothing and the investigation proceeds). + const ext = await runSessionStartExtensions({ + teamId: teamId.toString(), + agent, + anthropicSessionId: session!.id, + title, + prompt, + }); + + await anthropicRequest( + apiKey, + 'POST', + `/v1/sessions/${session!.id}/events`, + { + events: [ + { + type: 'user.message', + content: [{ type: 'text', text: ext.prompt }], + }, + ], + }, + ); + + run.anthropicSessionId = session!.id; + if (ext.runMetadata) run.metadata = ext.runMetadata; + await run.save(); + return { run, deduped: false }; + } catch (e) { + // Clean up in both directions: the session would otherwise keep running + // untracked, and the reservation would dedupe away the next evaluation's + // retry of an investigation that never actually started. + if (session) await deleteSessionBestEffort(apiKey, session.id); + // Releasing the reservation matters more than the session cleanup: a row + // left behind dedupes this alert away for the rest of the window, so the + // next firing silently never investigates. Count it like any other + // leftover rather than swallowing it. + await AgentRun.deleteOne({ _id: run._id }).catch(cleanupError => { + orphanedResourceCounter.add(1); + logger.error( + { error: serializeError(cleanupError), dedupeKey }, + 'Failed to release an agent-run reservation; this alert will not investigate again until the dedupe window rolls', + ); + }); + throw e; + } +}; + +// Wrapped rather than instrumented inline: each of these fans out across +// several Anthropic (and MCP) endpoints, and observability.md names external +// dependency calls as the withOperationMetrics case. The wrapper supplies the +// success/error outcome and the duration histogram. +export const provisionClickStackAgent = ( + ...args: Parameters +) => + withOperationMetrics('managed_agent.provision', () => + provisionClickStackAgentImpl(...args), + ); + +export const importAnthropicAgent = ( + ...args: Parameters +) => + withOperationMetrics('managed_agent.import', () => + importAnthropicAgentImpl(...args), + ); + +export const deleteAnthropicAgent = ( + ...args: Parameters +) => + withOperationMetrics('managed_agent.delete', () => + deleteAnthropicAgentImpl(...args), + ); + +export const startAgentSession = ( + ...args: Parameters +) => + withOperationMetrics('managed_agent.session_start', () => + startAgentSessionImpl(...args), + ); diff --git a/packages/common-utils/src/managedAgents.ts b/packages/common-utils/src/managedAgents.ts new file mode 100644 index 0000000000..0577e95950 --- /dev/null +++ b/packages/common-utils/src/managedAgents.ts @@ -0,0 +1,97 @@ +/** + * MCP tools a managed agent may call without an approval prompt. + * + * Read-only only: the same MCP server also exposes clickstack_save_*, + * clickstack_delete_* and clickstack_patch_dashboard, which must never fire + * from an unattended investigation — no one is there to answer the prompt. + * clickstack_sql is safe to include because that tool pins ClickHouse + * `readonly=2`. + * + * Shared so the API's provisioning and the manual setup snippet the app hands + * users cannot drift apart: an agent created either way gets the same posture. + */ +export const AUTO_ALLOWED_MCP_TOOLS = [ + 'clickstack_describe_metric', + 'clickstack_describe_source', + 'clickstack_emerging_signals', + 'clickstack_event_deltas', + 'clickstack_event_patterns', + 'clickstack_get_alert', + 'clickstack_get_dashboard', + 'clickstack_get_dashboard_tile', + 'clickstack_get_saved_search', + 'clickstack_get_webhook', + 'clickstack_list_metrics', + 'clickstack_list_sources', + 'clickstack_query_tile', + 'clickstack_query_tiles', + 'clickstack_search', + 'clickstack_search_dashboards', + 'clickstack_sql', + 'clickstack_table', + 'clickstack_timeseries', + 'clickstack_trace_top_time_consuming_operations', + 'clickstack_trace_waterfall', +]; + +type PermissionPolicy = { + readonly type: 'always_allow' | 'always_ask' | 'auto'; +}; + +type AgentToolConfig = { + readonly name: string; + readonly enabled?: boolean; + readonly permission_policy?: PermissionPolicy; +}; + +/** + * Anthropic's built-in toolset, configured for an unattended investigation. + * + * Fails closed, mirroring the mcp_toolset beside it: this toolset's API + * default is `always_allow` for every tool, so an inherited default would hand + * a shell and a URL fetcher to a session nobody is watching — and a tool added + * by a future toolset version would arrive auto-approved. Each tool below is a + * decision; anything new needs one before it can run. + * + * Nothing here ever answers an approval prompt, so `always_ask` means "never + * runs" and `auto` means "runs only when Anthropic's per-call evaluation + * clears it; an indeterminate call stops that investigation". + */ +const ALWAYS_ALLOW = { type: 'always_allow' } as const; + +export const AGENT_TOOLSET: { + readonly type: string; + readonly default_config: { readonly permission_policy: PermissionPolicy }; + readonly configs: readonly AgentToolConfig[]; +} = Object.freeze({ + type: 'agent_toolset_20260401', + default_config: Object.freeze({ + permission_policy: Object.freeze({ type: 'always_ask' as const }), + }), + configs: Object.freeze( + [ + // Analysis over what the MCP tools return. The container is per-session + // and holds only what the agent puts in it, so these reach nothing. + { name: 'read', permission_policy: ALWAYS_ALLOW }, + { name: 'write', permission_policy: ALWAYS_ALLOW }, + { name: 'edit', permission_policy: ALWAYS_ALLOW }, + { name: 'glob', permission_policy: ALWAYS_ALLOW }, + { name: 'grep', permission_policy: ALWAYS_ALLOW }, + // An alert's note is documented as the place to link a runbook, and both + // the standing prompt and the kickoff payload tell the agent to follow + // it — so it needs some way to fetch one. `auto` puts every fetch + // through Anthropic's evaluation rather than trusting the URL. + { + name: 'web_fetch', + permission_policy: Object.freeze({ type: 'auto' as const }), + }, + // Untargeted egress that nothing asks the agent to do. + { name: 'web_search', enabled: false }, + // A shell with network access is an egress path per-call evaluation + // cannot reliably judge: the file tools above could stage a script and + // the shell would be asked to approve running that, not what is inside + // it. Investigation needs querying, which the MCP tools do. + { name: 'bash', enabled: false }, + ].map(c => Object.freeze(c)), + ), +}); From 3aac712a9cf2859df3af6f85e7977e70b5ce088a Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 15:46:20 +1000 Subject: [PATCH 2/8] fix(api): fail closed on team key resolution and on racing imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the managed-agent surface. A per-team Anthropic key resolver that throws or overruns its deadline used to fall through to the deployment-wide key. That is the wrong account to fail into: the team's sessions would spend there, and provisioning would leave a vault holding their ClickStack credential there too. Key resolution now distinguishes a resolver that failed from one that has nothing to say — the first stops the operation, the second still falls back to the environment as intended. The prompt seams keep failing open, where a missing contribution costs nothing. Importing an agent checked for an existing record and then provisioned, which two simultaneous imports could both pass. Each would create an environment and a vault holding a live ClickStack key, and only one record would survive to tear either down. A unique index on team + anthropicAgentId makes the loser fail, and it rolls its own resources back and reports the same 409 the preflight check does. --- packages/api/src/models/managedAgent.ts | 6 ++++ .../api/__tests__/managedAgents.int.test.ts | 26 ++++++++++++++ .../__tests__/agentRunExtensions.test.ts | 30 +++++++++++++--- .../api/src/services/agentRunExtensions.ts | 35 ++++++++++++------- packages/api/src/services/anthropicAgents.ts | 20 +++++++++-- 5 files changed, 98 insertions(+), 19 deletions(-) diff --git a/packages/api/src/models/managedAgent.ts b/packages/api/src/models/managedAgent.ts index 691ea93761..203d483f1a 100644 --- a/packages/api/src/models/managedAgent.ts +++ b/packages/api/src/models/managedAgent.ts @@ -59,6 +59,12 @@ const ManagedAgentSchema = new Schema( { timestamps: true }, ); +// Import checks for an existing record before provisioning, which is a +// check-then-act: two simultaneous imports of the same Anthropic agent would +// both pass and each leave behind a vault holding a live ClickStack key, with +// only one of them reachable to tear down. The index makes the loser fail. +ManagedAgentSchema.index({ team: 1, anthropicAgentId: 1 }, { unique: true }); + export type ManagedAgentDocument = mongoose.HydratedDocument; export default mongoose.model( diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts index 1551fac3a2..7ee9a4ed97 100644 --- a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -237,6 +237,32 @@ describe('managed agents router', () => { expect(await ManagedAgent.countDocuments({})).toBe(1); }); + // The preflight check is check-then-act. Two imports of the same agent can + // both pass it, and each provisions a vault holding a live ClickStack key + // — only one of which stays reachable to tear down. + it('lets only one of two concurrent imports of the same agent win', async () => { + const deleted: string[] = []; + fetchSpy = mockAnthropic((url, init) => { + if (init?.method === 'DELETE') { + deleted.push(url); + return new Response('{}'); + } + return null; + }); + + const [a, b] = await Promise.all([ + agent.post('/managed-agents/import').send(body), + agent.post('/managed-agents/import').send(body), + ]); + + expect([a.status, b.status].sort()).toEqual([200, 409]); + expect(await ManagedAgent.countDocuments({})).toBe(1); + // The loser tore its own environment and vault down rather than leaving + // a credential behind that nothing points at. + expect(deleted.some(u => u.includes('/v1/vaults/'))).toBe(true); + expect(deleted.some(u => u.includes('/v1/environments/'))).toBe(true); + }); + it('rolls the vault back if the environment or credential fails', async () => { const deleted: string[] = []; fetchSpy = mockAnthropic((url, init) => { diff --git a/packages/api/src/services/__tests__/agentRunExtensions.test.ts b/packages/api/src/services/__tests__/agentRunExtensions.test.ts index 074d47f2f8..3d9abce8c3 100644 --- a/packages/api/src/services/__tests__/agentRunExtensions.test.ts +++ b/packages/api/src/services/__tests__/agentRunExtensions.test.ts @@ -169,7 +169,10 @@ describe('agentRunExtensions', () => { }); it('resolves the anthropic key from the last extension that returns one; null when none', async () => { - expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBeNull(); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toEqual({ + key: null, + resolverFailed: false, + }); registerAgentRunExtension({ name: 'k1', @@ -179,16 +182,35 @@ describe('agentRunExtensions', () => { name: 'k2', resolveAnthropicKey: async () => ({ apiKey: 'sk-b' }), }); - expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBe('sk-b'); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toEqual({ + key: 'sk-b', + }); }); - it('is fail-open for key resolution (a throwing resolver yields null)', async () => { + // Unlike the prompt seams, this one fails closed: the caller must be able to + // tell a broken resolver from one that simply has no key for this team, or + // the team's agents run on the operator's Anthropic account. + it('reports a throwing key resolver as a failure, not an absence', async () => { registerAgentRunExtension({ name: 'boom', resolveAnthropicKey: async () => { throw new Error('boom'); }, }); - expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toBeNull(); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toEqual({ + key: null, + resolverFailed: true, + }); + }); + + it('treats a resolver that opts out as an absence, not a failure', async () => { + registerAgentRunExtension({ + name: 'no-key-for-this-team', + resolveAnthropicKey: async () => undefined, + }); + expect(await runAnthropicKeyExtensions({ teamId: 't1' })).toEqual({ + key: null, + resolverFailed: false, + }); }); }); diff --git a/packages/api/src/services/agentRunExtensions.ts b/packages/api/src/services/agentRunExtensions.ts index 08fbeb5f82..e48235f054 100644 --- a/packages/api/src/services/agentRunExtensions.ts +++ b/packages/api/src/services/agentRunExtensions.ts @@ -85,12 +85,12 @@ export interface AgentRunExtension { ): Promise; // Resolves the team's Anthropic API key. OSS resolves the key from env // (see getTeamAnthropicKey); a downstream distribution can register this to - // provide a per-team key instead (last registered wins). Fail-open: a - // throwing resolver contributes nothing and the caller falls back to the env - // key — so a transient failure resolving a per-team key degrades to the - // operator's global key rather than failing closed. Implement the resolver - // robustly (return void to opt out, never throw for an expected "no key") - // if per-team key isolation must be strict. + // provide a per-team key instead (last registered wins). Unlike the prompt + // seams this one fails CLOSED: a resolver that throws or overruns its + // deadline stops the operation rather than letting it run on the + // deployment's own Anthropic account. Return void to opt out of resolving a + // given team — that is an absence, not a failure, and does fall back to the + // env key. resolveAnthropicKey?( ctx: AgentKeyResolutionContext, ): Promise; @@ -172,8 +172,9 @@ const runHook = async ( ext: AgentRunExtension, ) => ((ctx: TCtx) => Promise) | undefined, ctx: TCtx, -): Promise => { +): Promise<{ results: TResult[]; failed: boolean }> => { const results: TResult[] = []; + let failed = false; for (const ext of extensions) { const fn = pick(ext); if (!fn) continue; @@ -189,6 +190,7 @@ const runHook = async ( extensionEventsCounter.add(1, { hook, outcome: 'ok' }); if (result) results.push(result); } catch (e) { + failed = true; extensionEventsCounter.add(1, { hook, outcome: 'error' }); logger.error( { @@ -200,7 +202,7 @@ const runHook = async ( ); } } - return results; + return { results, failed }; }; // Picks the winning override when several extensions return one: last @@ -224,7 +226,7 @@ const pickLastOverride = ( export const runProvisionExtensions = async ( ctx: AgentProvisionContext, ): Promise<{ systemPrompt: string }> => { - const results = await runHook( + const { results } = await runHook( 'on_provision_agent', ext => ext.onProvisionAgent, ctx, @@ -247,7 +249,7 @@ export const runSessionStartExtensions = async ( prompt: string; runMetadata?: Record; }> => { - const results = await runHook( + const { results } = await runHook( 'on_session_start', ext => ext.onSessionStart, ctx, @@ -281,8 +283,8 @@ export const runSessionStartExtensions = async ( // returns null and the caller falls back to the env-configured key. export const runAnthropicKeyExtensions = async ( ctx: AgentKeyResolutionContext, -): Promise => { - const results = await runHook( +): Promise<{ key: string | null; resolverFailed?: boolean }> => { + const { results, failed } = await runHook( 'on_resolve_anthropic_key', ext => ext.resolveAnthropicKey, ctx, @@ -290,5 +292,12 @@ export const runAnthropicKeyExtensions = async ( const keys = results .map(r => r.apiKey) .filter((k): k is string => typeof k === 'string' && k.length > 0); - return keys.at(-1) ?? null; + const key = keys.at(-1); + if (key) return { key }; + // Key resolution is the one seam that does not fail open. Falling back to + // the deployment's own key after a registered resolver failed would run the + // team's agents — and their spend, and a vault holding their ClickStack + // credential — on the operator's Anthropic account. A resolver that has + // nothing to say returns void and is not a failure. + return { key: null, resolverFailed: failed }; }; diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts index 4f5c9bb59c..c1b82a0e85 100644 --- a/packages/api/src/services/anthropicAgents.ts +++ b/packages/api/src/services/anthropicAgents.ts @@ -116,13 +116,20 @@ const resolveEnvAnthropicKey = (): string | null => { // OSS resolves it from the environment; a downstream distribution can register // a `resolveAnthropicKey` extension to supply a per-team key, which takes // precedence (see services/agentRunExtensions.ts). +// +// A registered resolver that fails is an error, not an absence: falling back +// to the deployment key would put the team's spend and its ClickStack +// credential on the operator's Anthropic account. Callers surface this as "no +// key", so the operation stops instead of running against the wrong account. export const getTeamAnthropicKey = async ( teamId: ObjectId, ): Promise => { - const fromExtension = await runAnthropicKeyExtensions({ + const { key, resolverFailed } = await runAnthropicKeyExtensions({ teamId: teamId.toString(), }); - return fromExtension ?? resolveEnvAnthropicKey(); + if (key) return key; + if (resolverFailed) return null; + return resolveEnvAnthropicKey(); }; // Confirms the agent will actually be able to reach the ClickStack MCP server @@ -479,6 +486,15 @@ const importAnthropicAgentImpl = async ({ reportOrphans( await deleteAnthropicResources(apiKey, { environmentId, vaultId }), ); + // Lost a race with a concurrent import of the same agent. Its resources + // are torn down above, so report it the way the preflight check does + // rather than surfacing a duplicate-key error. + if (isDuplicateKeyError(e)) { + throw new AnthropicApiError( + 'That agent was just added to the list.', + 409, + ); + } throw e; } return { agent, verified }; From 4c8c7e2b9547d52d5f3b16a0270ee96951c3e3ba Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 22:40:30 +1000 Subject: [PATCH 3/8] fix(api): stop the import body overriding the caller's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/import` spread the request body over the authenticated team, user and access key. validateRequest only validates — unlike processRequest it never replaces req.body with the stripped parse — so extra keys survived and won, letting any authenticated caller write an agent record into another team's list backed by a vault credential holding a token of their choosing. The handler names the two fields it wants instead, matching the create route beside it. Teardown also interpolated the agent id into the Anthropic path raw while the import read escaped the same value. An id containing `../` normalises away before the request leaves and would have pointed a DELETE at another team's resource on the shared account. The id is now constrained to a flat identifier at the edge and escaped at every interpolation. Import ignored the agent's own mcp_servers while binding the vault credential to this instance's MCP URL, so an agent pointed elsewhere imported cleanly and then ran with no ClickStack access — a failure that only shows up when an alert fires. It is rejected now, naming both URLs. Also adds HDX_MCP_ALLOWED_HOSTS. The MCP endpoint is mounted on the API app, so a deployment serving the API on its own hostname could not reach it; the allowlist only ever covered the app origin. The comment claiming the allowlist was feature-gated was wrong too — FRONTEND_URL is added either way. --- packages/api/.env.development | 3 + packages/api/src/config.ts | 6 ++ packages/api/src/mcp/__tests__/app.test.ts | 12 ++++ packages/api/src/mcp/app.ts | 32 +++++++--- .../api/__tests__/managedAgents.int.test.ts | 59 +++++++++++++++++++ packages/api/src/routers/api/managedAgents.ts | 15 ++++- packages/api/src/services/anthropicAgents.ts | 31 +++++++++- 7 files changed, 144 insertions(+), 14 deletions(-) diff --git a/packages/api/.env.development b/packages/api/.env.development index ddf03becea..2eb0e732c7 100644 --- a/packages/api/.env.development +++ b/packages/api/.env.development @@ -31,4 +31,7 @@ ANTHROPIC_API_KEY="your-anthropic-api-key-here" # Anthropic's sandbox connects to the MCP server directly, so this must be a # public HTTPS tunnel to your local /api/mcp. # HDX_MANAGED_AGENTS_MCP_URL="https://.ngrok-free.app/api/mcp" +# Extra hostnames the MCP endpoint answers to, comma-separated. Needed when the +# API is served on a hostname of its own rather than through the app origin. +# HDX_MCP_ALLOWED_HOSTS="api.example.com" ENABLE_PROMQL=true diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 4e1755271e..db38801e36 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -108,3 +108,9 @@ export const IS_MANAGED_AGENT_CREATE_ENABLED = // load, so test suites can set it per-suite without mocking this module. export const getManagedAgentsMcpUrl = (): string | undefined => process.env.HDX_MANAGED_AGENTS_MCP_URL; + +// Extra hostnames the MCP endpoint will answer to, comma-separated. The +// endpoint is mounted on the API app, so a deployment that serves the API on +// its own hostname needs that host allowlisted — FRONTEND_URL alone does not +// cover it, and the SDK's default is localhost only. +export const MCP_ALLOWED_HOSTS = (env.HDX_MCP_ALLOWED_HOSTS as string) || ''; diff --git a/packages/api/src/mcp/__tests__/app.test.ts b/packages/api/src/mcp/__tests__/app.test.ts index 9843c624d6..155d714f8b 100644 --- a/packages/api/src/mcp/__tests__/app.test.ts +++ b/packages/api/src/mcp/__tests__/app.test.ts @@ -39,6 +39,18 @@ describe('buildAllowedHosts', () => { expect(hosts).not.toContain('localhost:30287'); }); + it('adds bare hostnames from the extra-hosts list', () => { + // The MCP endpoint is mounted on the API app, so a deployment serving the + // API on its own hostname has to be able to name it. + const hosts = buildAllowedHosts( + ['https://app.example.com'], + 'api.example.com, mcp.example.com', + ); + expect(hosts).toContain('api.example.com'); + expect(hosts).toContain('mcp.example.com'); + expect(hosts).toContain('app.example.com'); + }); + it('ignores undefined and malformed URLs rather than throwing', () => { expect(() => buildAllowedHosts([undefined, 'not a url', ''])).not.toThrow(); expect(buildAllowedHosts([undefined, 'not a url'])).toEqual([ diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index c4541e51b6..78fb0ffaca 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -17,7 +17,10 @@ import { userAgentClientInfo } from './utils/mcpClient'; // any remote MCP client) reach us over a public tunnel/domain, so allowlist the // configured public hosts alongside localhost. Matching is port-agnostic, so we // pass bare hostnames. -export const buildAllowedHosts = (urls: (string | undefined)[]): string[] => { +export const buildAllowedHosts = ( + urls: (string | undefined)[], + extraHosts = '', +): string[] => { const hosts = ['localhost', '127.0.0.1', '[::1]']; for (const url of urls) { if (!url) continue; @@ -27,18 +30,29 @@ export const buildAllowedHosts = (urls: (string | undefined)[]): string[] => { // ignore a malformed URL — it just won't be allowlisted } } + // Bare hostnames rather than URLs, so an operator can name the API's own + // host without inventing a scheme for it. + for (const host of extraHosts.split(',')) { + const trimmed = host.trim(); + if (trimmed) hosts.push(trimmed); + } return hosts; }; const app = createMcpExpressApp({ - // The managed-agents URL is only added when the feature is on, so a - // deployment that never enables it keeps the SDK's narrower default. - allowedHosts: buildAllowedHosts([ - config.IS_MANAGED_AGENTS_ENABLED - ? config.getManagedAgentsMcpUrl() - : undefined, - config.FRONTEND_URL, - ]), + // FRONTEND_URL is always allowlisted — the endpoint is reachable through the + // app origin in every deployment. The managed-agents URL is added only when + // that feature is on, and HDX_MCP_ALLOWED_HOSTS covers a deployment that + // serves the API on a hostname of its own. + allowedHosts: buildAllowedHosts( + [ + config.IS_MANAGED_AGENTS_ENABLED + ? config.getManagedAgentsMcpUrl() + : undefined, + config.FRONTEND_URL, + ], + config.MCP_ALLOWED_HOSTS, + ), }); const mcpRateLimiter = rateLimiter({ diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts index 7ee9a4ed97..9dfb501426 100644 --- a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -237,6 +237,65 @@ describe('managed agents router', () => { expect(await ManagedAgent.countDocuments({})).toBe(1); }); + // validateRequest validates without replacing req.body, so anything the + // handler spreads from it outranks what the session established. + it('ignores team, user and access key supplied in the request body', async () => { + const victimTeam = randomMongoId(); + const credentials: string[] = []; + fetchSpy = mockAnthropic((url, init) => { + if (url.includes('/credentials')) { + credentials.push(String(JSON.parse(String(init?.body)).auth.token)); + return new Response('{"id":"cred_1"}'); + } + return null; + }); + + await agent + .post('/managed-agents/import') + .send({ + ...body, + teamId: victimTeam, + userAccessKey: 'attacker-chosen-token', + }) + .expect(200); + + // Landed in the caller's own team, with the caller's own key. + expect(await ManagedAgent.countDocuments({ team: victimTeam })).toBe(0); + expect(await ManagedAgent.countDocuments({ team: team._id })).toBe(1); + expect(credentials).not.toContain('attacker-chosen-token'); + }); + + it('refuses an agent pointed at a different MCP server', async () => { + fetchSpy = mockAnthropic(url => { + if (url.includes('/v1/agents/')) { + return new Response( + JSON.stringify({ + id: 'agent_x', + name: 'Elsewhere', + mcp_servers: [{ url: 'https://other-instance.test/api/mcp' }], + }), + ); + } + return null; + }); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(400); + + expect(resp.body.message).toMatch(/other-instance\.test/); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + + it('rejects an agent id that is not a flat identifier', async () => { + await agent + .post('/managed-agents/import') + .send({ ...body, anthropicAgentId: '../vaults/vlt_other' }) + .expect(400); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + // The preflight check is check-then-act. Two imports of the same agent can // both pass it, and each provisions a vault holding a live ClickStack key // — only one of which stays reachable to tear down. diff --git a/packages/api/src/routers/api/managedAgents.ts b/packages/api/src/routers/api/managedAgents.ts index a95dbd461f..7c72d52935 100644 --- a/packages/api/src/routers/api/managedAgents.ts +++ b/packages/api/src/routers/api/managedAgents.ts @@ -118,7 +118,13 @@ router.post( validateRequest({ body: z.object({ name: z.string().max(120).optional(), - anthropicAgentId: z.string().min(1).max(200), + // Anthropic agent ids are opaque but flat. Constraining the shape here + // keeps path separators out of the id before it ever reaches a URL. + anthropicAgentId: z + .string() + .min(1) + .max(200) + .regex(/^[A-Za-z0-9_-]+$/, 'Invalid agent ID'), }), }), async (req, res, next) => { @@ -129,11 +135,16 @@ router.post( if (teamId == null || userId == null || !userAccessKey) { return res.sendStatus(403); } + // Named explicitly, never spread: validateRequest only validates, it + // does not replace req.body with the stripped parse, so a spread would + // let the caller's body override the authenticated team and key above. + const { name, anthropicAgentId } = req.body; const { agent, verified } = await importAnthropicAgent({ teamId, userId, userAccessKey, - ...req.body, + ...(name ? { name } : {}), + anthropicAgentId, }); res.json({ data: agent, verified }); } catch (e) { diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts index c1b82a0e85..ab2f79a438 100644 --- a/packages/api/src/services/anthropicAgents.ts +++ b/packages/api/src/services/anthropicAgents.ts @@ -433,6 +433,7 @@ const importAnthropicAgentImpl = async ({ let verified = false; let remoteName: string | undefined; let model: string | undefined; + let agentMcpUrl: string | undefined; try { const info = await anthropicRequest( apiKey, @@ -442,6 +443,9 @@ const importAnthropicAgentImpl = async ({ verified = true; remoteName = typeof info?.name === 'string' ? info.name : undefined; model = typeof info?.model === 'string' ? info.model : undefined; + agentMcpUrl = Array.isArray(info?.mcp_servers) + ? info.mcp_servers.find((m: unknown) => (m as { url?: string })?.url)?.url + : undefined; } catch (e) { if (e instanceof AnthropicApiError && e.status === 404) { throw new AnthropicApiError( @@ -455,6 +459,19 @@ const importAnthropicAgentImpl = async ({ ); } + // The vault credential provisioned below is bound to this instance's MCP + // URL, and Anthropic supplies it by matching that URL. An agent pointed at a + // different instance or a stale tunnel imports cleanly, dials its own URL, + // and gets no credential — a silent loss of ClickStack access that only + // shows up when an alert fires. Outside the try: this is a rejection, not a + // failed verification, so the catch above must not turn it into a warning. + if (typeof agentMcpUrl === 'string' && agentMcpUrl !== mcpServerUrl) { + throw new AnthropicApiError( + `That agent points at ${agentMcpUrl}, but this instance provisions its credential for ${mcpServerUrl}. It would run with no ClickStack access. Point the agent at this URL, or set HDX_MANAGED_AGENTS_MCP_URL to the one it already uses.`, + 400, + ); + } + // Falls back to the ID so the row is never nameless when the read failed. const label = name?.trim() || remoteName || anthropicAgentId; @@ -545,14 +562,22 @@ const deleteAnthropicResources = async ( orphaned.push(label); } }; - if (agentId) await cleanup('agent', `/v1/agents/${agentId}`); + // Escaped, not interpolated raw: the agent id is whatever the importer + // typed, and `../` in a path segment is resolved away by URL normalisation + // before the request leaves — which would point a DELETE at a resource this + // team does not own on the shared Anthropic account. + if (agentId) + await cleanup('agent', `/v1/agents/${encodeURIComponent(agentId)}`); if (vaultId) await cleanup( 'vault (holds the ClickStack access key)', - `/v1/vaults/${vaultId}`, + `/v1/vaults/${encodeURIComponent(vaultId)}`, ); if (environmentId) - await cleanup('environment', `/v1/environments/${environmentId}`); + await cleanup( + 'environment', + `/v1/environments/${encodeURIComponent(environmentId)}`, + ); return orphaned; }; From a8966b3c3399096d2b099a4810148386b73dd3f5 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 22:51:00 +1000 Subject: [PATCH 4/8] feat(api): return the MCP URL the instance provisions credentials for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list endpoint now reports the URL a vault credential gets bound to. A client that derives it from its own origin gets it wrong the moment an operator sets HDX_MANAGED_AGENTS_MCP_URL, and an agent built against the wrong URL is rejected on import — so the client should be told rather than guess. --- packages/api/src/routers/api/managedAgents.ts | 14 ++++++++++++++ packages/api/src/services/anthropicAgents.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/api/managedAgents.ts b/packages/api/src/routers/api/managedAgents.ts index 7c72d52935..c3ebeed42a 100644 --- a/packages/api/src/routers/api/managedAgents.ts +++ b/packages/api/src/routers/api/managedAgents.ts @@ -13,6 +13,7 @@ import { deleteAnthropicAgent, importAnthropicAgent, provisionClickStackAgent, + resolveMcpServerUrl, } from '@/services/anthropicAgents'; import logger from '@/utils/logger'; @@ -58,6 +59,19 @@ router.get('/', async (req, res, next) => { ? pick(agent.createdBy, ['name', 'email']) : undefined, })), + // The URL this instance binds a vault credential to. Returned so the + // client never has to guess it from its own origin — an operator who + // sets HDX_MANAGED_AGENTS_MCP_URL makes that guess wrong, and an agent + // built against the wrong URL is rejected on import. Omitted rather + // than fatal when it cannot be resolved: listing existing agents does + // not depend on being able to provision a new one. + ...(() => { + try { + return { mcpServerUrl: resolveMcpServerUrl() }; + } catch { + return {}; + } + })(), }); } catch (e) { next(e); diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts index ab2f79a438..d9b938ff3d 100644 --- a/packages/api/src/services/anthropicAgents.ts +++ b/packages/api/src/services/anthropicAgents.ts @@ -196,7 +196,7 @@ export const verifyMcpReachable = async ( // reaches it directly). In real deployments FRONTEND_URL is already HTTPS; for // local testing point HDX_MANAGED_AGENTS_MCP_URL at a public tunnel (e.g. // ngrok) to your instance's /api/mcp. -const resolveMcpServerUrl = (): string => { +export const resolveMcpServerUrl = (): string => { const mcpServerUrl = config.getManagedAgentsMcpUrl() || `${config.FRONTEND_URL}/api/mcp`; if (!mcpServerUrl.startsWith('https://')) { From c9048596ce97368639f2367f7a2b2ae293d20398 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 23:10:35 +1000 Subject: [PATCH 5/8] fix(api): reject an imported agent that cannot reach ClickStack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP URL check only fired on a mismatch, so an agent with no mcp_servers at all passed it: the import provisioned a credential for this instance and the agent, having nowhere to send it, ran with no ClickStack access. A verified agent now has to name this instance's URL, and the absent case gets its own message. Also marks which AnthropicApiErrors carry an upstream response body. Callers redact those before showing them, which was swallowing the authored ones — "no Anthropic API key configured for this team" reached the user as "the Anthropic API request failed", describing a request that never happened. --- .../api/__tests__/managedAgents.int.test.ts | 45 +++++++++++++++++-- packages/api/src/services/anthropicAgents.ts | 21 +++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts index 9dfb501426..b986dcc5b7 100644 --- a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -121,7 +121,19 @@ describe('managed agents router', () => { if (!u.startsWith('https://api.anthropic.com')) { return new Response('{}'); // the MCP initialize probe } - return handler(u, init) ?? new Response('{"id":"generated"}'); + const handled = handler(u, init); + if (handled) return handled; + // A correctly-configured agent points at this instance's MCP server; + // import rejects one that doesn't, so the default has to. + if (init?.method !== 'DELETE' && /\/v1\/agents\/[^/]+$/.test(u)) { + return new Response( + JSON.stringify({ + id: 'agent_remote', + mcp_servers: [{ url: MCP_URL }], + }), + ); + } + return new Response('{"id":"generated"}'); }); it('links the agent and provisions the vault and environment it needs', async () => { @@ -133,7 +145,12 @@ describe('managed agents router', () => { if (url.endsWith('/v1/vaults')) return new Response('{"id":"vlt_new"}'); if (url.includes('/v1/agents/')) return new Response( - '{"id":"agent_ext","name":"Hand-rolled responder","model":"claude-opus-4-8"}', + JSON.stringify({ + id: 'agent_ext', + name: 'Hand-rolled responder', + model: 'claude-opus-4-8', + mcp_servers: [{ url: MCP_URL }], + }), ); return new Response('{}'); }); @@ -178,7 +195,12 @@ describe('managed agents router', () => { it('prefers a name the user supplied', async () => { fetchSpy = mockAnthropic(url => url.includes('/v1/agents/') - ? new Response('{"name":"Anthropic name"}') + ? new Response( + JSON.stringify({ + name: 'Anthropic name', + mcp_servers: [{ url: MCP_URL }], + }), + ) : null, ); @@ -288,6 +310,23 @@ describe('managed agents router', () => { expect(await ManagedAgent.countDocuments({})).toBe(0); }); + it('refuses a verified agent with no MCP server at all', async () => { + fetchSpy = mockAnthropic(url => { + if (url.includes('/v1/agents/')) { + return new Response(JSON.stringify({ id: 'agent_x', name: 'Bare' })); + } + return null; + }); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(400); + + expect(resp.body.message).toMatch(/no MCP server/); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + it('rejects an agent id that is not a flat identifier', async () => { await agent .post('/managed-agents/import') diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts index d9b938ff3d..e2c4321282 100644 --- a/packages/api/src/services/anthropicAgents.ts +++ b/packages/api/src/services/anthropicAgents.ts @@ -49,10 +49,17 @@ const orphanedResourceCounter = getCounter( export class AnthropicApiError extends Error { status: number; - constructor(message: string, status: number) { + /** + * True only when the message carries an upstream response body. Callers + * redact those before showing them; the ones we author (no key configured, + * agent points elsewhere) are the actionable half and must survive. + */ + fromUpstream: boolean; + constructor(message: string, status: number, fromUpstream = false) { super(message); this.name = 'AnthropicApiError'; this.status = status; + this.fromUpstream = fromUpstream; } } @@ -84,6 +91,7 @@ const anthropicRequest = async ( throw new AnthropicApiError( `Anthropic API ${method} ${path} failed: ${e instanceof Error ? e.message : String(e)}`, 504, + true, ); } if (!res.ok) { @@ -91,6 +99,7 @@ const anthropicRequest = async ( throw new AnthropicApiError( `Anthropic API ${method} ${path} failed (${res.status}): ${text}`, res.status, + true, ); } // DELETE may return an empty body. @@ -465,9 +474,15 @@ const importAnthropicAgentImpl = async ({ // and gets no credential — a silent loss of ClickStack access that only // shows up when an alert fires. Outside the try: this is a rejection, not a // failed verification, so the catch above must not turn it into a warning. - if (typeof agentMcpUrl === 'string' && agentMcpUrl !== mcpServerUrl) { + // `verified` means the agent object was read, so its toolset is knowable and + // a mismatch is a decision we can make. An agent with no MCP server at all + // is rejected for the same reason as a wrong one: the credential this + // instance provisions would go unused. + if (verified && agentMcpUrl !== mcpServerUrl) { throw new AnthropicApiError( - `That agent points at ${agentMcpUrl}, but this instance provisions its credential for ${mcpServerUrl}. It would run with no ClickStack access. Point the agent at this URL, or set HDX_MANAGED_AGENTS_MCP_URL to the one it already uses.`, + agentMcpUrl + ? `That agent points at ${agentMcpUrl}, but this instance provisions its credential for ${mcpServerUrl}. It would run with no ClickStack access. Point the agent at this URL, or set HDX_MANAGED_AGENTS_MCP_URL to the one it already uses.` + : `That agent has no MCP server configured, so it has no way to reach ClickStack. Add ${mcpServerUrl} to its mcp_servers and import it again.`, 400, ); } From 77f7ba5c808dad68c1bafce59e5da9b8141d9545 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 14 Sep 2026 23:19:12 +1000 Subject: [PATCH 6/8] test(api): pin the empty mcp_servers case on import --- .../api/__tests__/managedAgents.int.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts index b986dcc5b7..f92cfd839e 100644 --- a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -327,6 +327,22 @@ describe('managed agents router', () => { expect(await ManagedAgent.countDocuments({})).toBe(0); }); + it('refuses a verified agent whose mcp_servers list is empty', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response(JSON.stringify({ id: 'agent_x', mcp_servers: [] })) + : null, + ); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(400); + + expect(resp.body.message).toMatch(/no MCP server/); + expect(await ManagedAgent.countDocuments({})).toBe(0); + }); + it('rejects an agent id that is not a flat identifier', async () => { await agent .post('/managed-agents/import') From b6a5b13634b75715fe5920f45d733ec8c6028d86 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 15 Sep 2026 14:29:44 +1000 Subject: [PATCH 7/8] chore: add a changeset for the managed-agent endpoints --- .changeset/managed-agent-provisioning.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/managed-agent-provisioning.md diff --git a/.changeset/managed-agent-provisioning.md b/.changeset/managed-agent-provisioning.md new file mode 100644 index 0000000000..362e9555ac --- /dev/null +++ b/.changeset/managed-agent-provisioning.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/api': minor +--- + +feat: provision and manage Claude agents for a team + +A team can own Claude managed agents through `/api/managed-agents`. Provisioning creates the Anthropic pieces a session needs — an environment, a vault holding the creating user's ClickStack access key, and the agent itself — and rolls back whatever it made if a later step fails. An agent written by hand on Anthropic can be imported by its ID instead, and is rejected if it points at a different MCP server or none at all, since the credential provisioned for it would go unused and the agent would run with no ClickStack access. Deleting one tears down the remote resources before the local record and keeps the record if any of that fails, so a partial teardown can be retried; it is refused while an alert still targets the agent. + +The agent's tool policy is written out rather than inherited. Anthropic's built-in toolset auto-approves every tool by default, so it is configured tool by tool: no shell, no web search, and a runbook fetch evaluated per call. Only the read-only ClickStack MCP tools are auto-approved, which matters because nothing answers an approval prompt in an unattended session — anything left asking simply never runs. + +Everything is behind `HDX_MANAGED_AGENTS_ENABLED`, which 404s the whole surface when unset. Provisioning needs a second opt-in, `HDX_MANAGED_AGENTS_ALLOW_CREATE`, since it spends on the deployment's Anthropic account; importing works with the first flag alone. `HDX_MCP_ALLOWED_HOSTS` allowlists extra hostnames for the MCP endpoint, for a deployment that serves the API on a host of its own. From f464d70579ec920f45aa04fddf81c4df4c42ac00 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 15 Sep 2026 16:04:29 +1000 Subject: [PATCH 8/8] fix(api): make the managed-agent flags reachable and the import check correct The shipped docker-compose file lists environment variables explicitly, and none of the managed-agent ones were in it, so a compose deployment could not turn the feature on at all. HDX_MCP_ALLOWED_HOSTS was in the same position, which made the only escape hatch for the MCP host allowlist unreachable. The import check also compared only the first MCP server an agent lists, so an agent reaching this instance alongside anything else was refused for pointing somewhere it also points. Ours only has to be among them. And the router forwarded Anthropic's own response text for the statuses it passes through. It already knew which errors came from upstream; it just wasn't asking. --- docker-compose.yml | 13 +++++++ .../api/__tests__/managedAgents.int.test.ts | 38 +++++++++++++++++++ packages/api/src/routers/api/managedAgents.ts | 12 ++++-- packages/api/src/services/anthropicAgents.ts | 15 ++++++-- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index feda9debd4..a98a85bfae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,19 @@ services: # NEXT_PUBLIC_ENABLE_PROMQL: 'true' # Uncomment to enable the alert detail page (in development) # NEXT_PUBLIC_ENABLE_ALERT_DETAILS: 'true' + # Extra hostnames the MCP endpoint answers to, comma-separated. Needed + # when clients reach /api/mcp on a host other than HYPERDX_APP_URL's. + HDX_MCP_ALLOWED_HOSTS: ${HDX_MCP_ALLOWED_HOSTS:-} + # Uncomment the next four lines to enable AI agent alert investigations. + # Provisioning spends on your Anthropic account, so it needs the second + # flag as well; importing an existing agent needs only the first. + # HDX_MANAGED_AGENTS_ENABLED: 'true' + # NEXT_PUBLIC_HDX_MANAGED_AGENTS_ENABLED: 'true' + # HDX_MANAGED_AGENTS_ALLOW_CREATE: 'true' + # NEXT_PUBLIC_HDX_MANAGED_AGENTS_ALLOW_CREATE: 'true' + # Public HTTPS URL of this instance's MCP server, if it is not + # HYPERDX_APP_URL + /api/mcp. Anthropic's sandbox reaches it directly. + # HDX_MANAGED_AGENTS_MCP_URL: 'https://example.com/api/mcp' DEFAULT_CONNECTIONS: '[{"name":"Local ClickHouse","host":"http://ch-server:8123","username":"default","password":""}]' diff --git a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts index f92cfd839e..079b8e0141 100644 --- a/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts +++ b/packages/api/src/routers/api/__tests__/managedAgents.int.test.ts @@ -310,6 +310,44 @@ describe('managed agents router', () => { expect(await ManagedAgent.countDocuments({})).toBe(0); }); + it('accepts an agent that lists this instance alongside other MCP servers', async () => { + fetchSpy = mockAnthropic(url => + url.includes('/v1/agents/') + ? new Response( + JSON.stringify({ + id: 'agent_multi', + mcp_servers: [ + { url: 'https://mcp.github.example/mcp' }, + { url: MCP_URL }, + ], + }), + ) + : null, + ); + + await agent.post('/managed-agents/import').send(body).expect(200); + expect(await ManagedAgent.countDocuments({})).toBe(1); + }); + + it('redacts an upstream Anthropic body but keeps our own message', async () => { + // Provisioning, not the verify read: a non-404 on the read imports + // unverified by design, so it never reaches the client as an error. + fetchSpy = mockAnthropic(url => + url.endsWith('/v1/environments') + ? new Response('{"detail":"upstream secret"}', { status: 400 }) + : null, + ); + + const resp = await agent + .post('/managed-agents/import') + .send(body) + .expect(400); + + // 400 passes through as a status, but the body is Anthropic's, not ours. + expect(resp.body.message).not.toContain('upstream secret'); + expect(resp.body.message).toContain('Anthropic API request failed'); + }); + it('refuses a verified agent with no MCP server at all', async () => { fetchSpy = mockAnthropic(url => { if (url.includes('/v1/agents/')) { diff --git a/packages/api/src/routers/api/managedAgents.ts b/packages/api/src/routers/api/managedAgents.ts index c3ebeed42a..970abe2d4f 100644 --- a/packages/api/src/routers/api/managedAgents.ts +++ b/packages/api/src/routers/api/managedAgents.ts @@ -41,6 +41,12 @@ const HYPERDX_AUTHORED_STATUSES = new Set([400, 404, 409]); const clientFacingStatus = (status: number) => HYPERDX_AUTHORED_STATUSES.has(status) ? status : 502; +// An AnthropicApiError either carries an upstream response body or a message +// we wrote. Only the second is safe to show, and only the second is useful: +// upstream text is the caller's own request echoed back at them. +const clientFacingMessage = (e: AnthropicApiError) => + e.fromUpstream ? 'The Anthropic API request failed.' : e.message; + router.get('/', async (req, res, next) => { try { const teamId = req.user?.team; @@ -118,7 +124,7 @@ router.post( 'Managed agent provisioning failed', ); const status = clientFacingStatus(e.status); - return res.status(status).json({ message: e.message }); + return res.status(status).json({ message: clientFacingMessage(e) }); } next(e); } @@ -168,7 +174,7 @@ router.post( 'Managed agent import failed', ); const status = clientFacingStatus(e.status); - return res.status(status).json({ message: e.message }); + return res.status(status).json({ message: clientFacingMessage(e) }); } next(e); } @@ -229,7 +235,7 @@ router.delete( 'Managed agent deletion failed; keeping the local record', ); const status = clientFacingStatus(e.status); - return res.status(status).json({ message: e.message }); + return res.status(status).json({ message: clientFacingMessage(e) }); } throw e; } diff --git a/packages/api/src/services/anthropicAgents.ts b/packages/api/src/services/anthropicAgents.ts index e2c4321282..b174a475e8 100644 --- a/packages/api/src/services/anthropicAgents.ts +++ b/packages/api/src/services/anthropicAgents.ts @@ -443,6 +443,7 @@ const importAnthropicAgentImpl = async ({ let remoteName: string | undefined; let model: string | undefined; let agentMcpUrl: string | undefined; + let pointsHere = false; try { const info = await anthropicRequest( apiKey, @@ -452,9 +453,15 @@ const importAnthropicAgentImpl = async ({ verified = true; remoteName = typeof info?.name === 'string' ? info.name : undefined; model = typeof info?.model === 'string' ? info.model : undefined; - agentMcpUrl = Array.isArray(info?.mcp_servers) - ? info.mcp_servers.find((m: unknown) => (m as { url?: string })?.url)?.url - : undefined; + const servers: unknown[] = Array.isArray(info?.mcp_servers) + ? info.mcp_servers + : []; + const urls = servers + .map(m => (m as { url?: string })?.url) + .filter((u): u is string => typeof u === 'string' && u.length > 0); + // An agent may reach several MCP servers; ours only has to be one of them. + pointsHere = urls.includes(mcpServerUrl); + agentMcpUrl = urls[0]; } catch (e) { if (e instanceof AnthropicApiError && e.status === 404) { throw new AnthropicApiError( @@ -478,7 +485,7 @@ const importAnthropicAgentImpl = async ({ // a mismatch is a decision we can make. An agent with no MCP server at all // is rejected for the same reason as a wrong one: the credential this // instance provisions would go unused. - if (verified && agentMcpUrl !== mcpServerUrl) { + if (verified && !pointsHere) { throw new AnthropicApiError( agentMcpUrl ? `That agent points at ${agentMcpUrl}, but this instance provisions its credential for ${mcpServerUrl}. It would run with no ClickStack access. Point the agent at this URL, or set HDX_MANAGED_AGENTS_MCP_URL to the one it already uses.`