@checkstack/ai-backend 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
type SafeDatabase,
|
|
5
|
+
} from "@checkstack/backend-api";
|
|
6
|
+
import {
|
|
7
|
+
aiAccessRules,
|
|
8
|
+
aiContract,
|
|
9
|
+
pluginMetadata,
|
|
10
|
+
OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID,
|
|
11
|
+
} from "@checkstack/ai-common";
|
|
12
|
+
import {
|
|
13
|
+
integrationProviderExtensionPoint,
|
|
14
|
+
connectionStoreRef,
|
|
15
|
+
} from "@checkstack/integration-backend";
|
|
16
|
+
import type { IntegrationProvider } from "@checkstack/integration-backend";
|
|
17
|
+
import type { OpenAiCompatibleConnection } from "@checkstack/ai-common";
|
|
18
|
+
import {
|
|
19
|
+
aiToolExtensionPoint,
|
|
20
|
+
aiToolProjectionExtensionPoint,
|
|
21
|
+
} from "./extension-points";
|
|
22
|
+
import { createAiToolRegistry } from "./tool-registry";
|
|
23
|
+
import { createAiToolResolver } from "./resolver";
|
|
24
|
+
import { createRegistryExtensionPoints } from "./registry-wiring";
|
|
25
|
+
import { buildCompositeTools } from "./tools/composite-tools";
|
|
26
|
+
import { createOpenAiCompatibleProvider } from "./openai-provider";
|
|
27
|
+
import { createAiRouter } from "./router";
|
|
28
|
+
import { createMcpRequestHandler } from "./mcp/server";
|
|
29
|
+
import type { McpExecutableTool } from "./mcp/server";
|
|
30
|
+
import { createMcpToolInvoker } from "./mcp/tool-invoker";
|
|
31
|
+
import { createMcpConnectionRegistry } from "./mcp/connection-registry";
|
|
32
|
+
import { createAiToolCallStore } from "./propose-apply/store";
|
|
33
|
+
import { hashToolArgs } from "./propose-apply/args-hash";
|
|
34
|
+
import { createProposeApplyService } from "./propose-apply/service";
|
|
35
|
+
import { createAgentRunner, aiAgentRunnerRef } from "./agent-runner";
|
|
36
|
+
import { createAiConversationStore } from "./chat/conversation-store";
|
|
37
|
+
import { createChatService } from "./chat/chat-service";
|
|
38
|
+
import type {
|
|
39
|
+
ChatConnectionResolver,
|
|
40
|
+
ChatService,
|
|
41
|
+
} from "./chat/chat-service";
|
|
42
|
+
import { createChatRequestHandler } from "./chat/chat-handler";
|
|
43
|
+
import { createChatReadInvoker } from "./chat/read-invoker";
|
|
44
|
+
import { enforceToolBudget } from "./rate-limit/tool-budget";
|
|
45
|
+
import { OpenAiCompatibleConnectionSchema } from "./openai-provider";
|
|
46
|
+
import * as schema from "./schema";
|
|
47
|
+
|
|
48
|
+
/** How often the background sweep flips expired `proposed` rows to `expired`. */
|
|
49
|
+
const PROPOSAL_SWEEP_INTERVAL_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
52
|
+
// AI Platform Plugin — Phase 1 (spine + integration)
|
|
53
|
+
//
|
|
54
|
+
// Delivers the transport-agnostic tool registry (the spine), the two
|
|
55
|
+
// extension points, the principal -> allowed-tools resolver, the shared
|
|
56
|
+
// zod -> JSON-Schema serializer, the OpenAI-compatible integration provider,
|
|
57
|
+
// and a handful of read-only projected tools. Transports (MCP, chat) and
|
|
58
|
+
// mutating propose/apply land in later phases.
|
|
59
|
+
//
|
|
60
|
+
// State & scale: the only state Phase 1 introduces is the in-memory tool
|
|
61
|
+
// REGISTRY, which is rebuilt identically from buffered extension-point
|
|
62
|
+
// registrations on every pod at boot (it is derived, deterministic, and not a
|
|
63
|
+
// queryable source of truth) — every pod resolves the same tool set for the
|
|
64
|
+
// same principal. No durable AI state ships in this phase.
|
|
65
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
66
|
+
|
|
67
|
+
export default createBackendPlugin({
|
|
68
|
+
metadata: pluginMetadata,
|
|
69
|
+
|
|
70
|
+
register(env) {
|
|
71
|
+
const registry = createAiToolRegistry();
|
|
72
|
+
const resolver = createAiToolResolver({ registry });
|
|
73
|
+
|
|
74
|
+
env.registerAccessRules(aiAccessRules);
|
|
75
|
+
|
|
76
|
+
const { toolExtensionPoint, projectionExtensionPoint, exposedProjections } =
|
|
77
|
+
createRegistryExtensionPoints({ registry });
|
|
78
|
+
|
|
79
|
+
// Path 1 — hand-authored composite tools.
|
|
80
|
+
env.registerExtensionPoint(aiToolExtensionPoint, toolExtensionPoint);
|
|
81
|
+
// Path 2 — opt-in projection of an existing oRPC procedure. Plugins call
|
|
82
|
+
// `expose(...)` from their OWN init; ai-backend collects the routing in
|
|
83
|
+
// afterPluginsReady (below) without importing any plugin's `*-common`.
|
|
84
|
+
env.registerExtensionPoint(
|
|
85
|
+
aiToolProjectionExtensionPoint,
|
|
86
|
+
projectionExtensionPoint,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Live MCP connection registry — the ONE allowed pod-local thing
|
|
90
|
+
// (declareNonReactiveState({ reason: "bookkeeping" }), decision 9). Created
|
|
91
|
+
// in register() and reused by the HTTP handler; never a source of truth.
|
|
92
|
+
const mcpConnections = createMcpConnectionRegistry();
|
|
93
|
+
|
|
94
|
+
// Shared, register-scope refs so BOTH init (which builds the MCP + chat
|
|
95
|
+
// handlers) and afterPluginsReady (which fills routing once every plugin has
|
|
96
|
+
// exposed its projections) reach the same instances. mcpExecutableTools is
|
|
97
|
+
// the SAME array the MCP handler holds (it reads it lazily on first request).
|
|
98
|
+
const mcpExecutableTools: McpExecutableTool[] = [];
|
|
99
|
+
let chatService: ChatService | undefined;
|
|
100
|
+
|
|
101
|
+
env.registerInit({
|
|
102
|
+
schema,
|
|
103
|
+
deps: {
|
|
104
|
+
logger: coreServices.logger,
|
|
105
|
+
rpc: coreServices.rpc,
|
|
106
|
+
auth: coreServices.auth,
|
|
107
|
+
eventBus: coreServices.eventBus,
|
|
108
|
+
},
|
|
109
|
+
init: async ({ logger, database, rpc, auth, eventBus }) => {
|
|
110
|
+
logger.debug("🔌 Initializing AI Backend...");
|
|
111
|
+
|
|
112
|
+
const db = database as SafeDatabase<typeof schema>;
|
|
113
|
+
|
|
114
|
+
// Loopback base URL for the user-scoped RPC clients (chat + router
|
|
115
|
+
// propose/apply) and the MCP/read invokers: they re-enter `/api` as the
|
|
116
|
+
// ORIGINATING user so handler authz + per-resource/team scope apply.
|
|
117
|
+
const internalUrl = process.env.INTERNAL_URL || "http://localhost:3000";
|
|
118
|
+
|
|
119
|
+
// Phase 3: the audit log + propose/apply token store (the proposed row
|
|
120
|
+
// IS the token). Shared Postgres — scale-correct across pods.
|
|
121
|
+
const store = createAiToolCallStore({ db });
|
|
122
|
+
const proposeApply = createProposeApplyService({
|
|
123
|
+
registry,
|
|
124
|
+
resolver,
|
|
125
|
+
store,
|
|
126
|
+
eventBus,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Phase 4: durable conversation + message persistence (shared Postgres,
|
|
130
|
+
// continuable from any pod — state-and-scale §9).
|
|
131
|
+
const conversations = createAiConversationStore({ db });
|
|
132
|
+
|
|
133
|
+
// Lists selectable AI integrations for the chat picker (§14.6). Reads
|
|
134
|
+
// the REDACTED connection config (configPreview) — the apiKey is never
|
|
135
|
+
// present here, only the non-secret model UX metadata.
|
|
136
|
+
const openAiProviderId = `${pluginMetadata.pluginId}.${OPENAI_COMPATIBLE_PROVIDER_LOCAL_ID}`;
|
|
137
|
+
const chatIntegrationLister = {
|
|
138
|
+
async list() {
|
|
139
|
+
const connectionStore = await env.getService(connectionStoreRef);
|
|
140
|
+
const connections =
|
|
141
|
+
await connectionStore.listConnections(openAiProviderId);
|
|
142
|
+
return connections.flatMap((conn) => {
|
|
143
|
+
const preview = conn.configPreview;
|
|
144
|
+
const defaultModel = preview.defaultModel;
|
|
145
|
+
if (typeof defaultModel !== "string") return [];
|
|
146
|
+
const availableModels = Array.isArray(preview.availableModels)
|
|
147
|
+
? preview.availableModels.filter(
|
|
148
|
+
(m): m is string => typeof m === "string",
|
|
149
|
+
)
|
|
150
|
+
: undefined;
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
connectionId: conn.id,
|
|
154
|
+
name: conn.name,
|
|
155
|
+
defaultModel,
|
|
156
|
+
availableModels,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Register ALL hand-authored composite tools through the SAME extension
|
|
164
|
+
// point external plugins use, from the SINGLE shared `buildCompositeTools`
|
|
165
|
+
// factory. The factory is the one source of truth for the composite set
|
|
166
|
+
// (the two propose tools + docs grounding + script context/test +
|
|
167
|
+
// capability catalog), and the systemic-authz / e2e tests build their
|
|
168
|
+
// registry from the SAME factory, so a new fan-out read tool added here
|
|
169
|
+
// is automatically covered by the authz guard. The underlying RPC calls
|
|
170
|
+
// go through the trusted service client; the resolver gate + the
|
|
171
|
+
// propose/apply re-check (mutate tools) and the in-`execute` per-context
|
|
172
|
+
// re-check (composite read tools) are the authorization authority.
|
|
173
|
+
const toolExt = env.getExtensionPoint(aiToolExtensionPoint);
|
|
174
|
+
for (const tool of buildCompositeTools()) {
|
|
175
|
+
toolExt.registerTool(tool, pluginMetadata);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
// Register the OpenAI-compatible integration provider so it appears in
|
|
180
|
+
// the generic Connections settings UI (DynamicForm-driven). Done at
|
|
181
|
+
// init so `integrationProviderExtensionPoint` is resolvable.
|
|
182
|
+
const integrationExt = env.getExtensionPoint(
|
|
183
|
+
integrationProviderExtensionPoint,
|
|
184
|
+
);
|
|
185
|
+
integrationExt.addProvider(
|
|
186
|
+
createOpenAiCompatibleProvider() as IntegrationProvider<unknown>,
|
|
187
|
+
pluginMetadata,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Read-only PROJECTIONS are exposed by their OWNING plugins (incident /
|
|
191
|
+
// healthcheck / anomaly) via aiToolProjectionExtensionPoint from their
|
|
192
|
+
// own init. ai-backend collects their routing in afterPluginsReady once
|
|
193
|
+
// every plugin has registered (see below) - it imports no plugin commons.
|
|
194
|
+
// `mcpExecutableTools` (register-scope) is filled there; the MCP handler
|
|
195
|
+
// created below holds this same array and reads it lazily on first call.
|
|
196
|
+
|
|
197
|
+
// Expose the AI introspection + propose/apply + conversation contract.
|
|
198
|
+
rpc.registerRouter(
|
|
199
|
+
createAiRouter({
|
|
200
|
+
resolver,
|
|
201
|
+
proposeApply,
|
|
202
|
+
conversations,
|
|
203
|
+
integrations: chatIntegrationLister,
|
|
204
|
+
internalUrl,
|
|
205
|
+
}),
|
|
206
|
+
aiContract,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Per-principal tool RATE-LIMIT BUDGET (§14.5): a shared-Postgres
|
|
210
|
+
// rolling-window counter over ai_tool_calls. Enforced on tool execution
|
|
211
|
+
// by BOTH transports (MCP tools/call below + the chat agent loop), so
|
|
212
|
+
// the cap holds across all pods.
|
|
213
|
+
const enforceBudget = async (principal: {
|
|
214
|
+
kind: "user" | "application";
|
|
215
|
+
id: string;
|
|
216
|
+
}): Promise<void> => {
|
|
217
|
+
await enforceToolBudget({ db, principal });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Mount the read-only Streamable-HTTP MCP endpoint at /api/ai/mcp.
|
|
221
|
+
// Authn: the bearer OAuth token resolves to a narrowed principal via
|
|
222
|
+
// the platform auth strategy; tools/call re-enters the live router so
|
|
223
|
+
// handler authz holds. The endpoint is always mounted; whether a token
|
|
224
|
+
// can be MINTED is gated by the auth-backend MCP/OAuth enable toggle.
|
|
225
|
+
const mcpHandler = createMcpRequestHandler({
|
|
226
|
+
tools: mcpExecutableTools,
|
|
227
|
+
resolver,
|
|
228
|
+
invoker: createMcpToolInvoker({ internalUrl }),
|
|
229
|
+
auth,
|
|
230
|
+
connections: mcpConnections,
|
|
231
|
+
enforceBudget,
|
|
232
|
+
recordExecuted: async ({ principal, toolName, argsHash }) => {
|
|
233
|
+
await store.recordExecuted({
|
|
234
|
+
principal,
|
|
235
|
+
transport: "mcp",
|
|
236
|
+
toolName,
|
|
237
|
+
argsHash,
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
rpc.registerHttpHandler(mcpHandler, "/mcp");
|
|
242
|
+
|
|
243
|
+
// Phase 4: the streaming chat agent loop at /api/ai/chat. Credentials
|
|
244
|
+
// are resolved BACKEND-side from the integration connection store and
|
|
245
|
+
// never leave the backend. Read tools re-enter the live router as the
|
|
246
|
+
// logged-in user (handler authz holds); mutating/destructive tools go
|
|
247
|
+
// through propose/apply and surface a confirm card.
|
|
248
|
+
const chatConnectionResolver: ChatConnectionResolver = {
|
|
249
|
+
async resolve({ connectionId }) {
|
|
250
|
+
const connectionStore = await env.getService(connectionStoreRef);
|
|
251
|
+
const conn =
|
|
252
|
+
await connectionStore.getConnectionWithCredentials(connectionId);
|
|
253
|
+
if (!conn) return;
|
|
254
|
+
const parsed = OpenAiCompatibleConnectionSchema.safeParse(
|
|
255
|
+
conn.config,
|
|
256
|
+
);
|
|
257
|
+
if (!parsed.success) return;
|
|
258
|
+
return parsed.data satisfies OpenAiCompatibleConnection;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Headless agent runner (the engine behind the automation "AI Action").
|
|
263
|
+
// Exposed as a service so automation-backend can drive a bounded agent
|
|
264
|
+
// task as the run's `runAs` principal without ai-backend depending on
|
|
265
|
+
// automation-backend. Reuses the same resolver + connection resolution
|
|
266
|
+
// as chat; runs no human-gated propose/apply (the action auto-applies
|
|
267
|
+
// non-destructive tools through the principal's own client).
|
|
268
|
+
env.registerService(
|
|
269
|
+
aiAgentRunnerRef,
|
|
270
|
+
createAgentRunner({
|
|
271
|
+
resolver,
|
|
272
|
+
resolveConnection: (connectionId) =>
|
|
273
|
+
chatConnectionResolver.resolve({ connectionId }),
|
|
274
|
+
// Projected read tools route through the live router as the
|
|
275
|
+
// principal. `exposedProjections` is populated in afterPluginsReady,
|
|
276
|
+
// before any agent task can run, so this lookup is live at call time.
|
|
277
|
+
getProjectionRoute: (toolName) => {
|
|
278
|
+
const route = exposedProjections.find(
|
|
279
|
+
(r) => r.toolName === toolName,
|
|
280
|
+
);
|
|
281
|
+
return route
|
|
282
|
+
? { pluginId: route.pluginId, procedureKey: route.procedureKey }
|
|
283
|
+
: undefined;
|
|
284
|
+
},
|
|
285
|
+
// Audit every AI-action tool call into the durable AI tool-call log
|
|
286
|
+
// under the `automation` transport (best-effort).
|
|
287
|
+
recordToolCall: async ({
|
|
288
|
+
principal,
|
|
289
|
+
toolName,
|
|
290
|
+
effect,
|
|
291
|
+
input,
|
|
292
|
+
ok,
|
|
293
|
+
error,
|
|
294
|
+
}) => {
|
|
295
|
+
if (principal.type !== "user" && principal.type !== "application") {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const auditPrincipal = {
|
|
299
|
+
kind: principal.type,
|
|
300
|
+
id: principal.id,
|
|
301
|
+
} as const;
|
|
302
|
+
const argsHash = hashToolArgs(input);
|
|
303
|
+
try {
|
|
304
|
+
await (ok
|
|
305
|
+
? store.recordExecuted({
|
|
306
|
+
principal: auditPrincipal,
|
|
307
|
+
transport: "automation",
|
|
308
|
+
toolName,
|
|
309
|
+
argsHash,
|
|
310
|
+
})
|
|
311
|
+
: store.recordFailed({
|
|
312
|
+
principal: auditPrincipal,
|
|
313
|
+
transport: "automation",
|
|
314
|
+
toolName,
|
|
315
|
+
effect,
|
|
316
|
+
argsHash,
|
|
317
|
+
error: error ?? "unknown error",
|
|
318
|
+
}));
|
|
319
|
+
} catch {
|
|
320
|
+
// Best-effort audit; never break the agent loop.
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
chatService = createChatService({
|
|
326
|
+
resolver,
|
|
327
|
+
proposeApply,
|
|
328
|
+
conversations,
|
|
329
|
+
connections: chatConnectionResolver,
|
|
330
|
+
readInvoker: createChatReadInvoker({ internalUrl }),
|
|
331
|
+
recordExecuted: async ({
|
|
332
|
+
principal,
|
|
333
|
+
conversationId,
|
|
334
|
+
toolName,
|
|
335
|
+
argsHash,
|
|
336
|
+
}) => {
|
|
337
|
+
await store.recordExecuted({
|
|
338
|
+
principal,
|
|
339
|
+
transport: "chat",
|
|
340
|
+
conversationId,
|
|
341
|
+
toolName,
|
|
342
|
+
argsHash,
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
db,
|
|
346
|
+
logger,
|
|
347
|
+
internalUrl,
|
|
348
|
+
});
|
|
349
|
+
// Read-tool routing for the chat loop is seeded in afterPluginsReady
|
|
350
|
+
// (below), once every plugin has exposed its read projections.
|
|
351
|
+
rpc.registerHttpHandler(
|
|
352
|
+
createChatRequestHandler({ chatService, auth }),
|
|
353
|
+
"/chat",
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Background sweep: flip expired `proposed` rows to `expired`, keeping
|
|
357
|
+
// them as audit history (§13.4). This is purely audit hygiene — the
|
|
358
|
+
// apply path rejects an expired token even if the row is still
|
|
359
|
+
// `proposed`, so correctness never depends on the sweep. Runs on every
|
|
360
|
+
// pod against shared Postgres; the UPDATE is idempotent so concurrent
|
|
361
|
+
// sweeps are harmless. `unref()` so it never holds the process open.
|
|
362
|
+
const sweepTimer = setInterval(() => {
|
|
363
|
+
void store
|
|
364
|
+
.expireStaleProposals()
|
|
365
|
+
.catch((error: unknown) =>
|
|
366
|
+
logger.warn(
|
|
367
|
+
`[ai-backend] proposal sweep failed: ${String(error)}`,
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
}, PROPOSAL_SWEEP_INTERVAL_MS);
|
|
371
|
+
sweepTimer.unref?.();
|
|
372
|
+
|
|
373
|
+
logger.debug(
|
|
374
|
+
`🤖 AI Backend init complete with ${registry.getTools().length} tools; MCP endpoint at /api/ai/mcp`,
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
// Phase 3: every plugin has now run init, so all read projections have been
|
|
378
|
+
// exposed via aiToolProjectionExtensionPoint. Collect their routing into
|
|
379
|
+
// the shared `mcpExecutableTools` array (read lazily by the MCP handler on
|
|
380
|
+
// first request) and seed the chat loop's read-tool routing. This is the
|
|
381
|
+
// ONLY place ai-backend learns which plugins projected read tools - it
|
|
382
|
+
// never imports a plugin's `*-common`.
|
|
383
|
+
afterPluginsReady: async ({ logger }) => {
|
|
384
|
+
for (const route of exposedProjections) {
|
|
385
|
+
const tool = registry.getTool(route.toolName);
|
|
386
|
+
if (!tool) continue;
|
|
387
|
+
mcpExecutableTools.push({
|
|
388
|
+
tool,
|
|
389
|
+
pluginId: route.pluginId,
|
|
390
|
+
procedureKey: route.procedureKey,
|
|
391
|
+
});
|
|
392
|
+
if (tool.effect === "read") {
|
|
393
|
+
chatService?.readRouting.set(tool.name, {
|
|
394
|
+
pluginId: route.pluginId,
|
|
395
|
+
procedureKey: route.procedureKey,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
logger.debug(
|
|
400
|
+
`🤖 AI Backend wired ${mcpExecutableTools.length} projected read tool(s) from plugins: ${mcpExecutableTools.map((t) => t.tool.name).join(", ") || "(none)"}`,
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ─── Re-exports (public backend surface) ────────────────────────────────
|
|
408
|
+
export {
|
|
409
|
+
aiToolExtensionPoint,
|
|
410
|
+
aiToolProjectionExtensionPoint,
|
|
411
|
+
} from "./extension-points";
|
|
412
|
+
export type {
|
|
413
|
+
AiToolExtensionPoint,
|
|
414
|
+
AiToolProjectionExtensionPoint,
|
|
415
|
+
} from "./extension-points";
|
|
416
|
+
export {
|
|
417
|
+
createAiToolRegistry,
|
|
418
|
+
type AiToolRegistry,
|
|
419
|
+
type RegisteredAiTool,
|
|
420
|
+
} from "./tool-registry";
|
|
421
|
+
export {
|
|
422
|
+
createAiToolResolver,
|
|
423
|
+
isToolAllowed,
|
|
424
|
+
principalSatisfiesRules,
|
|
425
|
+
type AiToolResolver,
|
|
426
|
+
} from "./resolver";
|
|
427
|
+
export {
|
|
428
|
+
buildProjectedTool,
|
|
429
|
+
deferredProjectionExecute,
|
|
430
|
+
type ProjectToolInput,
|
|
431
|
+
} from "./projection";
|
|
432
|
+
export {
|
|
433
|
+
createAgentRunner,
|
|
434
|
+
aiAgentRunnerRef,
|
|
435
|
+
type AiAgentRunner,
|
|
436
|
+
type AgentTaskInput,
|
|
437
|
+
type AgentTaskResult,
|
|
438
|
+
type AgentTaskToolCall,
|
|
439
|
+
type AgentRunnerModelFns,
|
|
440
|
+
} from "./agent-runner";
|
|
441
|
+
export { serializeTool, serializeTools } from "./serializer";
|
|
442
|
+
export {
|
|
443
|
+
createOpenAiCompatibleProvider,
|
|
444
|
+
OpenAiCompatibleConnectionSchema,
|
|
445
|
+
} from "./openai-provider";
|
|
446
|
+
export {
|
|
447
|
+
createProposeApplyService,
|
|
448
|
+
ProposeApplyError,
|
|
449
|
+
type ProposeApplyService,
|
|
450
|
+
type ProposeApplyErrorCode,
|
|
451
|
+
type ProposeResult,
|
|
452
|
+
type ApplyResult,
|
|
453
|
+
type ProposalDescription,
|
|
454
|
+
} from "./propose-apply/service";
|
|
455
|
+
export {
|
|
456
|
+
createAiToolCallStore,
|
|
457
|
+
PROPOSAL_TTL_MS,
|
|
458
|
+
type AiToolCallStore,
|
|
459
|
+
type AuditPrincipal,
|
|
460
|
+
} from "./propose-apply/store";
|
|
461
|
+
export {
|
|
462
|
+
formatProposalToken,
|
|
463
|
+
parseProposalToken,
|
|
464
|
+
generateProposalNonce,
|
|
465
|
+
nonceMatches,
|
|
466
|
+
} from "./propose-apply/token";
|
|
467
|
+
export { hashToolArgs } from "./propose-apply/args-hash";
|
|
468
|
+
// Automation + health-check AI tools now live in their owning plugins
|
|
469
|
+
// (@checkstack/automation-backend / @checkstack/healthcheck-backend), registered
|
|
470
|
+
// via aiToolExtensionPoint, not in ai-backend's public surface.
|
|
471
|
+
export { buildCompositeTools } from "./tools/composite-tools";
|
|
472
|
+
// Script-context TOOLS now live per-plugin (healthcheck-backend /
|
|
473
|
+
// automation-backend); ai-backend keeps only the plugin-agnostic machinery
|
|
474
|
+
// (resolveScriptContext + descriptors below) for plugins to import.
|
|
475
|
+
export {
|
|
476
|
+
resolveScriptContext,
|
|
477
|
+
extractDeclareModuleBlock,
|
|
478
|
+
renderShellEnvDeclarations,
|
|
479
|
+
buildStarterExample,
|
|
480
|
+
ScriptContextExtractionError,
|
|
481
|
+
SCRIPT_CONTEXT_DESCRIPTORS,
|
|
482
|
+
HEALTHCHECK_SHELL_ENV,
|
|
483
|
+
AUTOMATION_SHELL_ENV,
|
|
484
|
+
type ScriptContextDescriptor,
|
|
485
|
+
type ResolvedScriptContext,
|
|
486
|
+
} from "./tools/script-context-extract";
|
|
487
|
+
// Capability catalog tools are now per-plugin (healthcheck-backend /
|
|
488
|
+
// automation-backend), registered via aiToolExtensionPoint.
|
|
489
|
+
// The capability-summary + field-diff helpers now live in @checkstack/ai-common
|
|
490
|
+
// (pure, shareable). Import them from there.
|
|
491
|
+
export { aiHooks, type AiToolCalledPayload } from "./hooks";
|
|
492
|
+
export {
|
|
493
|
+
aiToolCalls,
|
|
494
|
+
aiConversations,
|
|
495
|
+
aiMessages,
|
|
496
|
+
} from "./schema";
|
|
497
|
+
export {
|
|
498
|
+
createAiConversationStore,
|
|
499
|
+
type AiConversationStore,
|
|
500
|
+
type AiMessageRole,
|
|
501
|
+
} from "./chat/conversation-store";
|
|
502
|
+
export {
|
|
503
|
+
createChatService,
|
|
504
|
+
buildChatToolCallbacks,
|
|
505
|
+
toModelMessages,
|
|
506
|
+
type ChatService,
|
|
507
|
+
type ChatConnectionResolver,
|
|
508
|
+
type ChatRecordExecuted,
|
|
509
|
+
type ChatTurnInput,
|
|
510
|
+
type ChatDecisionInput,
|
|
511
|
+
} from "./chat/chat-service";
|
|
512
|
+
export { createChatRequestHandler } from "./chat/chat-handler";
|
|
513
|
+
export {
|
|
514
|
+
disposeAgentTool,
|
|
515
|
+
offeredTools,
|
|
516
|
+
type AgentToolDisposition,
|
|
517
|
+
} from "./chat/agent-loop";
|
|
518
|
+
export {
|
|
519
|
+
buildLanguageModel,
|
|
520
|
+
resolveModelId,
|
|
521
|
+
} from "./chat/llm-provider";
|
|
522
|
+
export {
|
|
523
|
+
checkToolBudget,
|
|
524
|
+
enforceToolBudget,
|
|
525
|
+
ToolBudgetExceededError,
|
|
526
|
+
TOOL_BUDGET_MAX_CALLS,
|
|
527
|
+
TOOL_BUDGET_WINDOW_MS,
|
|
528
|
+
} from "./rate-limit/tool-budget";
|
|
529
|
+
export {
|
|
530
|
+
checkSpendCap,
|
|
531
|
+
enforceSpendCap,
|
|
532
|
+
recordSpend,
|
|
533
|
+
SpendCapExceededError,
|
|
534
|
+
type SpendCheckResult,
|
|
535
|
+
type SpendUsageInput,
|
|
536
|
+
} from "./rate-limit/spend-ledger";
|
|
537
|
+
export {
|
|
538
|
+
scrubContent,
|
|
539
|
+
scrubModelMessages,
|
|
540
|
+
REDACTED,
|
|
541
|
+
} from "./chat/scrub-content";
|
|
542
|
+
export { aiSpend } from "./schema";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createMcpConnectionRegistry } from "./connection-registry";
|
|
3
|
+
|
|
4
|
+
describe("createMcpConnectionRegistry (pod-local bookkeeping)", () => {
|
|
5
|
+
test("opens, reads, and closes a connection", () => {
|
|
6
|
+
const reg = createMcpConnectionRegistry();
|
|
7
|
+
const conn = reg.open({ sessionId: "s1", principalId: "u1" });
|
|
8
|
+
expect(conn.sessionId).toBe("s1");
|
|
9
|
+
expect(reg.get("s1")?.principalId).toBe("u1");
|
|
10
|
+
expect(reg.size()).toBe(1);
|
|
11
|
+
reg.close("s1");
|
|
12
|
+
expect(reg.get("s1")).toBeUndefined();
|
|
13
|
+
expect(reg.size()).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("tracks multiple concurrent connections independently", () => {
|
|
17
|
+
const reg = createMcpConnectionRegistry();
|
|
18
|
+
reg.open({ sessionId: "a", principalId: "u1" });
|
|
19
|
+
reg.open({ sessionId: "b", principalId: "u2" });
|
|
20
|
+
expect(reg.size()).toBe(2);
|
|
21
|
+
reg.close("a");
|
|
22
|
+
expect(reg.size()).toBe(1);
|
|
23
|
+
expect(reg.get("b")?.principalId).toBe("u2");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live MCP / Streamable-HTTP connection registry.
|
|
3
|
+
*
|
|
4
|
+
* STATE & SCALE (the one allowed pod-local exception, decision 9): this map
|
|
5
|
+
* holds connections physically terminated on THIS pod. It is bookkeeping only,
|
|
6
|
+
* NEVER a queryable source of truth — the same exception class as the satellite
|
|
7
|
+
* WebSocket registry. A connection's authorization is re-derived on every
|
|
8
|
+
* request from the durable OAuth token (introspect + narrow), so nothing about
|
|
9
|
+
* a principal's rights lives here. Declared non-reactive:
|
|
10
|
+
* `declareNonReactiveState({ reason: "bookkeeping" })`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface McpConnection {
|
|
14
|
+
/** Opaque session id (MCP `Mcp-Session-Id`). */
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/** Bound principal id (the OAuth token's user), for diagnostics only. */
|
|
17
|
+
principalId: string;
|
|
18
|
+
/** When this connection was opened on this pod. */
|
|
19
|
+
openedAt: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpConnectionRegistry {
|
|
23
|
+
open(args: { sessionId: string; principalId: string }): McpConnection;
|
|
24
|
+
get(sessionId: string): McpConnection | undefined;
|
|
25
|
+
close(sessionId: string): void;
|
|
26
|
+
/** Count of connections terminated on THIS pod (diagnostics). */
|
|
27
|
+
size(): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createMcpConnectionRegistry(): McpConnectionRegistry {
|
|
31
|
+
// declareNonReactiveState({ reason: "bookkeeping" }) — pod-local only.
|
|
32
|
+
const connections = new Map<string, McpConnection>();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
open({ sessionId, principalId }) {
|
|
36
|
+
const conn: McpConnection = {
|
|
37
|
+
sessionId,
|
|
38
|
+
principalId,
|
|
39
|
+
openedAt: new Date(),
|
|
40
|
+
};
|
|
41
|
+
connections.set(sessionId, conn);
|
|
42
|
+
return conn;
|
|
43
|
+
},
|
|
44
|
+
get(sessionId) {
|
|
45
|
+
return connections.get(sessionId);
|
|
46
|
+
},
|
|
47
|
+
close(sessionId) {
|
|
48
|
+
connections.delete(sessionId);
|
|
49
|
+
},
|
|
50
|
+
size() {
|
|
51
|
+
return connections.size;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|