@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/drizzle/0000_productive_jackpot.sql +26 -0
  3. package/drizzle/0001_puzzling_purple_man.sql +26 -0
  4. package/drizzle/0002_sparkling_paper_doll.sql +15 -0
  5. package/drizzle/0003_married_senator_kelly.sql +1 -0
  6. package/drizzle/0004_crazy_miek.sql +2 -0
  7. package/drizzle/0005_tearful_randall_flagg.sql +1 -0
  8. package/drizzle/meta/0000_snapshot.json +232 -0
  9. package/drizzle/meta/0001_snapshot.json +434 -0
  10. package/drizzle/meta/0002_snapshot.json +551 -0
  11. package/drizzle/meta/0003_snapshot.json +557 -0
  12. package/drizzle/meta/0004_snapshot.json +573 -0
  13. package/drizzle/meta/0005_snapshot.json +574 -0
  14. package/drizzle/meta/_journal.json +48 -0
  15. package/drizzle.config.ts +7 -0
  16. package/package.json +42 -0
  17. package/src/agent-runner.test.ts +262 -0
  18. package/src/agent-runner.ts +262 -0
  19. package/src/chat/agent-loop.test.ts +119 -0
  20. package/src/chat/agent-loop.ts +73 -0
  21. package/src/chat/auto-apply.test.ts +237 -0
  22. package/src/chat/chat-handler.ts +111 -0
  23. package/src/chat/chat-service.streamturn.test.ts +417 -0
  24. package/src/chat/chat-service.test.ts +250 -0
  25. package/src/chat/chat-service.ts +923 -0
  26. package/src/chat/classifier-service.ts +64 -0
  27. package/src/chat/classifier.logic.test.ts +92 -0
  28. package/src/chat/classifier.logic.ts +71 -0
  29. package/src/chat/conversation-store.it.test.ts +203 -0
  30. package/src/chat/conversation-store.test.ts +248 -0
  31. package/src/chat/conversation-store.ts +237 -0
  32. package/src/chat/decision.logic.test.ts +45 -0
  33. package/src/chat/decision.logic.ts +54 -0
  34. package/src/chat/llm-provider.test.ts +63 -0
  35. package/src/chat/llm-provider.ts +67 -0
  36. package/src/chat/model-error.logic.test.ts +60 -0
  37. package/src/chat/model-error.logic.ts +65 -0
  38. package/src/chat/normalize-messages.logic.test.ts +101 -0
  39. package/src/chat/normalize-messages.logic.ts +65 -0
  40. package/src/chat/permission-mode.logic.test.ts +70 -0
  41. package/src/chat/permission-mode.logic.ts +45 -0
  42. package/src/chat/read-invoker.ts +72 -0
  43. package/src/chat/replay.test.ts +174 -0
  44. package/src/chat/scrub-content.test.ts +183 -0
  45. package/src/chat/scrub-content.ts +154 -0
  46. package/src/chat/sdk-tools.test.ts +168 -0
  47. package/src/chat/sdk-tools.ts +181 -0
  48. package/src/chat/title-service.test.ts +146 -0
  49. package/src/chat/title-service.ts +111 -0
  50. package/src/chat/title.logic.test.ts +98 -0
  51. package/src/chat/title.logic.ts +102 -0
  52. package/src/extension-points.ts +41 -0
  53. package/src/generated/docs-index.ts +3020 -0
  54. package/src/hardening/handler-authz.test.ts +282 -0
  55. package/src/hardening/no-secret-leak.test.ts +303 -0
  56. package/src/hooks.ts +33 -0
  57. package/src/index.ts +542 -0
  58. package/src/mcp/connection-registry.test.ts +25 -0
  59. package/src/mcp/connection-registry.ts +54 -0
  60. package/src/mcp/mcp-conformance.it.test.ts +128 -0
  61. package/src/mcp/server.test.ts +285 -0
  62. package/src/mcp/server.ts +300 -0
  63. package/src/mcp/tool-invoker.ts +65 -0
  64. package/src/openai-provider.test.ts +64 -0
  65. package/src/openai-provider.ts +146 -0
  66. package/src/projection.test.ts +97 -0
  67. package/src/projection.ts +132 -0
  68. package/src/propose-apply/args-hash.test.ts +26 -0
  69. package/src/propose-apply/args-hash.ts +30 -0
  70. package/src/propose-apply/service.test.ts +423 -0
  71. package/src/propose-apply/service.ts +419 -0
  72. package/src/propose-apply/store.test.ts +136 -0
  73. package/src/propose-apply/store.ts +224 -0
  74. package/src/propose-apply/token.test.ts +52 -0
  75. package/src/propose-apply/token.ts +71 -0
  76. package/src/rate-limit/spend-ledger.it.test.ts +224 -0
  77. package/src/rate-limit/spend-ledger.test.ts +176 -0
  78. package/src/rate-limit/spend-ledger.ts +162 -0
  79. package/src/rate-limit/tool-budget.it.test.ts +173 -0
  80. package/src/rate-limit/tool-budget.test.ts +58 -0
  81. package/src/rate-limit/tool-budget.ts +107 -0
  82. package/src/registry-wiring.test.ts +131 -0
  83. package/src/registry-wiring.ts +68 -0
  84. package/src/resolver.test.ts +156 -0
  85. package/src/resolver.ts +78 -0
  86. package/src/router.test.ts +78 -0
  87. package/src/router.ts +345 -0
  88. package/src/schema.ts +284 -0
  89. package/src/serializer.test.ts +88 -0
  90. package/src/serializer.ts +42 -0
  91. package/src/tool-registry.ts +58 -0
  92. package/src/tools/composite-tools.ts +24 -0
  93. package/src/tools/docs-tools.test.ts +150 -0
  94. package/src/tools/docs-tools.ts +115 -0
  95. package/src/tools/probe-url.test.ts +51 -0
  96. package/src/tools/probe-url.ts +146 -0
  97. package/src/tools/rank-docs.test.ts +153 -0
  98. package/src/tools/rank-docs.ts +209 -0
  99. package/src/tools/script-context-extract.test.ts +93 -0
  100. package/src/tools/script-context-extract.ts +283 -0
  101. package/src/tools/ssrf-guard.test.ts +69 -0
  102. package/src/tools/ssrf-guard.ts +108 -0
  103. package/src/tools/tool-set.e2e.test.ts +64 -0
  104. package/src/user-rpc-client.test.ts +45 -0
  105. package/src/user-rpc-client.ts +60 -0
  106. 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
+ }