@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
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { access, definePluginMetadata, proc } from "@checkstack/common";
4
+ import { AiToolEffectSchema } from "@checkstack/ai-common";
5
+ import type { AnyContractProcedure } from "@orpc/contract";
6
+ import { createAiToolRegistry } from "./tool-registry";
7
+ import { createRegistryExtensionPoints } from "./registry-wiring";
8
+ import type { RegisteredAiTool } from "./tool-registry";
9
+
10
+ const sourcePluginMetadata = definePluginMetadata({ pluginId: "incident" });
11
+ const incidentRead = access("incident", "read", "View incidents");
12
+ const listIncidents = proc({
13
+ operationType: "query",
14
+ userType: "authenticated",
15
+ access: [incidentRead],
16
+ }).input(z.object({ status: z.string().optional() })) as AnyContractProcedure;
17
+
18
+ function handAuthoredTool(): RegisteredAiTool {
19
+ return {
20
+ // Unqualified name — the extension point must qualify it with the plugin id.
21
+ name: "propose",
22
+ description: "Propose an automation from natural language.",
23
+ effect: "mutate",
24
+ input: z.object({ prompt: z.string() }),
25
+ requiredAccessRules: ["automation.automation.manage"],
26
+ execute: () => Promise.resolve({}),
27
+ };
28
+ }
29
+
30
+ describe("createRegistryExtensionPoints (end-to-end registration)", () => {
31
+ test("registerTool qualifies an unqualified name with the plugin id", () => {
32
+ const registry = createAiToolRegistry();
33
+ const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
34
+
35
+ toolExtensionPoint.registerTool(
36
+ handAuthoredTool(),
37
+ definePluginMetadata({ pluginId: "automation" }),
38
+ );
39
+
40
+ expect(registry.hasTool("automation.propose")).toBe(true);
41
+ expect(registry.getTool("automation.propose")?.effect).toBe("mutate");
42
+ });
43
+
44
+ test("registerTool leaves an already-qualified name unchanged", () => {
45
+ const registry = createAiToolRegistry();
46
+ const { toolExtensionPoint } = createRegistryExtensionPoints({ registry });
47
+
48
+ toolExtensionPoint.registerTool(
49
+ { ...handAuthoredTool(), name: "automation.propose" },
50
+ definePluginMetadata({ pluginId: "different" }),
51
+ );
52
+
53
+ expect(registry.hasTool("automation.propose")).toBe(true);
54
+ expect(registry.hasTool("different.automation.propose")).toBe(false);
55
+ });
56
+
57
+ test("expose builds and registers a projected tool from a contract procedure", () => {
58
+ const registry = createAiToolRegistry();
59
+ const { projectionExtensionPoint } = createRegistryExtensionPoints({
60
+ registry,
61
+ });
62
+
63
+ projectionExtensionPoint.expose({
64
+ procedure: listIncidents,
65
+ sourcePluginMetadata,
66
+ procedureKey: "listIncidents",
67
+ name: "incident.list",
68
+ description: "List incidents.",
69
+ effect: "read",
70
+ execute: () => Promise.resolve({}),
71
+ });
72
+
73
+ const tool = registry.getTool("incident.list");
74
+ expect(tool).toBeDefined();
75
+ // Access rules read verbatim from the source procedure, qualified.
76
+ expect(tool?.requiredAccessRules).toEqual(["incident.incident.read"]);
77
+ expect(tool?.effect).toBe("read");
78
+ });
79
+
80
+ test("both paths populate the SAME registry (one spine, two paths)", () => {
81
+ const registry = createAiToolRegistry();
82
+ const { toolExtensionPoint, projectionExtensionPoint } =
83
+ createRegistryExtensionPoints({ registry });
84
+
85
+ toolExtensionPoint.registerTool(
86
+ handAuthoredTool(),
87
+ definePluginMetadata({ pluginId: "automation" }),
88
+ );
89
+ projectionExtensionPoint.expose({
90
+ procedure: listIncidents,
91
+ sourcePluginMetadata,
92
+ procedureKey: "listIncidents",
93
+ name: "incident.list",
94
+ description: "List incidents.",
95
+ effect: "read",
96
+ execute: () => Promise.resolve({}),
97
+ });
98
+
99
+ expect(registry.getTools().map((t) => t.name).sort()).toEqual([
100
+ "automation.propose",
101
+ "incident.list",
102
+ ]);
103
+ });
104
+
105
+ // §4.2 belt-and-suspenders: every registered tool carries a VALID effect.
106
+ // This is already guaranteed by the `AiToolEffect` type at compile time, but
107
+ // the runtime assertion documents the invariant the permission-mode gating
108
+ // keys on - an effect outside `read | mutate | destructive` would slip the
109
+ // 3-tier disposition logic.
110
+ test("every registered tool has a valid effect", () => {
111
+ const registry = createAiToolRegistry();
112
+ const { toolExtensionPoint, projectionExtensionPoint } =
113
+ createRegistryExtensionPoints({ registry });
114
+ toolExtensionPoint.registerTool(
115
+ handAuthoredTool(),
116
+ definePluginMetadata({ pluginId: "automation" }),
117
+ );
118
+ projectionExtensionPoint.expose({
119
+ procedure: listIncidents,
120
+ sourcePluginMetadata,
121
+ procedureKey: "listIncidents",
122
+ name: "incident.list",
123
+ description: "List incidents.",
124
+ effect: "read",
125
+ execute: () => Promise.resolve({}),
126
+ });
127
+ for (const tool of registry.getTools()) {
128
+ expect(AiToolEffectSchema.safeParse(tool.effect).success).toBe(true);
129
+ }
130
+ });
131
+ });
@@ -0,0 +1,68 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import type {
3
+ AiToolExtensionPoint,
4
+ AiToolProjectionExtensionPoint,
5
+ } from "./extension-points";
6
+ import { buildProjectedTool } from "./projection";
7
+ import type { AiToolRegistry } from "./tool-registry";
8
+
9
+ /**
10
+ * Build the two extension-point implementations that feed a tool {@link
11
+ * AiToolRegistry}. Factored out of the plugin `register()` so the exact
12
+ * production wiring (name qualification for hand-authored tools, `expose`
13
+ * building a projected tool from a contract procedure) is unit-testable
14
+ * end-to-end without standing up a full plugin environment.
15
+ *
16
+ * Both paths are the ONLY way a tool reaches the registry:
17
+ * - `registerTool` qualifies a hand-authored composite tool's name with the
18
+ * registering plugin id (unless already qualified).
19
+ * - `expose` builds a projected tool from an oRPC contract procedure via
20
+ * {@link buildProjectedTool} and registers it.
21
+ */
22
+ /**
23
+ * Routing metadata for a projected read tool, accumulated as plugins `expose`
24
+ * their own read procedures. The MCP transport and the chat read-loop re-enter
25
+ * the live router using `{ pluginId, procedureKey }`, so ai-backend collects this
26
+ * AFTER all plugins have registered (in `afterPluginsReady`) - it does not need
27
+ * to know which plugins exist or import their `*-common`.
28
+ */
29
+ export interface ExposedProjectionRoute {
30
+ toolName: string;
31
+ pluginId: string;
32
+ procedureKey: string;
33
+ }
34
+
35
+ export function createRegistryExtensionPoints({
36
+ registry,
37
+ }: {
38
+ registry: AiToolRegistry;
39
+ }): {
40
+ toolExtensionPoint: AiToolExtensionPoint;
41
+ projectionExtensionPoint: AiToolProjectionExtensionPoint;
42
+ /** Routing for every projection exposed via the point (populated lazily). */
43
+ exposedProjections: ExposedProjectionRoute[];
44
+ } {
45
+ const toolExtensionPoint: AiToolExtensionPoint = {
46
+ registerTool: (tool, metadata: PluginMetadata) => {
47
+ const qualifiedName = tool.name.includes(".")
48
+ ? tool.name
49
+ : `${metadata.pluginId}.${tool.name}`;
50
+ registry.register({ ...tool, name: qualifiedName });
51
+ },
52
+ };
53
+
54
+ const exposedProjections: ExposedProjectionRoute[] = [];
55
+ const projectionExtensionPoint: AiToolProjectionExtensionPoint = {
56
+ expose: (input) => {
57
+ const tool = buildProjectedTool(input);
58
+ registry.register(tool);
59
+ exposedProjections.push({
60
+ toolName: tool.name,
61
+ pluginId: input.sourcePluginMetadata.pluginId,
62
+ procedureKey: input.procedureKey,
63
+ });
64
+ },
65
+ };
66
+
67
+ return { toolExtensionPoint, projectionExtensionPoint, exposedProjections };
68
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import type { AuthUser } from "@checkstack/backend-api";
4
+ import { createAiToolRegistry, type RegisteredAiTool } from "./tool-registry";
5
+ import { createAiToolResolver, principalSatisfiesRules } from "./resolver";
6
+
7
+ function tool(
8
+ name: string,
9
+ requiredAccessRules: string[],
10
+ ): RegisteredAiTool {
11
+ return {
12
+ name,
13
+ description: name,
14
+ effect: "read",
15
+ input: z.object({}),
16
+ requiredAccessRules,
17
+ execute: () => Promise.resolve({}),
18
+ };
19
+ }
20
+
21
+ function userWith(accessRules: string[]): AuthUser {
22
+ return { type: "user", id: "u1", accessRules };
23
+ }
24
+
25
+ describe("createAiToolResolver.resolveTools", () => {
26
+ test("a principal lacking automation.manage never sees automation.propose", () => {
27
+ const registry = createAiToolRegistry();
28
+ registry.register(
29
+ tool("automation.propose", ["automation.automation.manage"]),
30
+ );
31
+ registry.register(tool("incident.list", ["incident.incident.read"]));
32
+ const resolver = createAiToolResolver({ registry });
33
+
34
+ const principal = userWith(["incident.incident.read"]);
35
+ const names = resolver.resolveTools(principal).map((t) => t.name);
36
+
37
+ expect(names).toEqual(["incident.list"]);
38
+ expect(names).not.toContain("automation.propose");
39
+ });
40
+
41
+ test("an admin (accessRules ['*']) sees all tools", () => {
42
+ const registry = createAiToolRegistry();
43
+ registry.register(
44
+ tool("automation.propose", ["automation.automation.manage"]),
45
+ );
46
+ registry.register(tool("incident.list", ["incident.incident.read"]));
47
+ const resolver = createAiToolResolver({ registry });
48
+
49
+ const names = resolver
50
+ .resolveTools(userWith(["*"]))
51
+ .map((t) => t.name)
52
+ .sort();
53
+
54
+ expect(names).toEqual(["automation.propose", "incident.list"]);
55
+ });
56
+
57
+ test("a service principal (no access rules) sees no tools", () => {
58
+ const registry = createAiToolRegistry();
59
+ registry.register(tool("incident.list", ["incident.incident.read"]));
60
+ const resolver = createAiToolResolver({ registry });
61
+
62
+ const service: AuthUser = { type: "service", pluginId: "automation" };
63
+ expect(resolver.resolveTools(service)).toEqual([]);
64
+ });
65
+
66
+ test("a tool requiring MULTIPLE rules needs ALL of them", () => {
67
+ const registry = createAiToolRegistry();
68
+ registry.register(
69
+ tool("multi", ["incident.incident.read", "catalog.system.read"]),
70
+ );
71
+ const resolver = createAiToolResolver({ registry });
72
+
73
+ expect(
74
+ resolver.resolveTools(userWith(["incident.incident.read"])),
75
+ ).toEqual([]);
76
+ expect(
77
+ resolver
78
+ .resolveTools(
79
+ userWith(["incident.incident.read", "catalog.system.read"]),
80
+ )
81
+ .map((t) => t.name),
82
+ ).toEqual(["multi"]);
83
+ });
84
+ });
85
+
86
+ describe("isAllowed mirrors autoAuthMiddleware's global-rule check", () => {
87
+ // Replicates the EXACT predicate autoAuthMiddleware applies to global rules
88
+ // (rpc.ts:258-260): rules.includes("*") || rules.includes(qualifiedId).
89
+ function middlewareWouldAllow(
90
+ rules: string[],
91
+ requiredAccessRules: string[],
92
+ ): boolean {
93
+ return requiredAccessRules.every(
94
+ (rule) => rules.includes("*") || rules.includes(rule),
95
+ );
96
+ }
97
+
98
+ const ruleUniverse = ["a.read", "b.read", "c.manage"];
99
+ const principalSets: string[][] = [
100
+ [],
101
+ ["*"],
102
+ ["a.read"],
103
+ ["a.read", "b.read"],
104
+ ["c.manage"],
105
+ ["a.read", "b.read", "c.manage"],
106
+ ];
107
+ const toolRuleSets: string[][] = [
108
+ [],
109
+ ["a.read"],
110
+ ["a.read", "b.read"],
111
+ ["c.manage"],
112
+ ["a.read", "c.manage"],
113
+ ruleUniverse,
114
+ ];
115
+
116
+ test("isAllowed == middleware decision for the full matrix", () => {
117
+ const registry = createAiToolRegistry();
118
+ const resolver = createAiToolResolver({ registry });
119
+
120
+ for (const rules of principalSets) {
121
+ for (const required of toolRuleSets) {
122
+ const principal = userWith(rules);
123
+ const t = tool("t", required);
124
+ const expected = middlewareWouldAllow(rules, required);
125
+ expect(resolver.isAllowed({ principal, tool: t })).toBe(expected);
126
+ expect(
127
+ principalSatisfiesRules({
128
+ principalAccessRules: rules,
129
+ requiredAccessRules: required,
130
+ }),
131
+ ).toBe(expected);
132
+ }
133
+ }
134
+ });
135
+
136
+ test("narrowing can never widen: a subset principal allows a subset of tools", () => {
137
+ const registry = createAiToolRegistry();
138
+ registry.register(tool("t1", ["a.read"]));
139
+ registry.register(tool("t2", ["b.read"]));
140
+ registry.register(tool("t3", ["c.manage"]));
141
+ const resolver = createAiToolResolver({ registry });
142
+
143
+ const full = userWith(["a.read", "b.read", "c.manage"]);
144
+ const narrowed = userWith(["a.read"]); // a narrowed token: strict subset
145
+
146
+ const fullNames = new Set(resolver.resolveTools(full).map((t) => t.name));
147
+ const narrowedNames = resolver.resolveTools(narrowed).map((t) => t.name);
148
+
149
+ // Every tool the narrowed principal sees is also seen by the full
150
+ // principal — narrowing only ever removes tools.
151
+ for (const name of narrowedNames) {
152
+ expect(fullNames.has(name)).toBe(true);
153
+ }
154
+ expect(narrowedNames).toEqual(["t1"]);
155
+ });
156
+ });
@@ -0,0 +1,78 @@
1
+ import type { AuthUser } from "@checkstack/backend-api";
2
+ import type { RegisteredAiTool } from "./tool-registry";
3
+ import type { AiToolRegistry } from "./tool-registry";
4
+
5
+ /**
6
+ * Resolves the subset of registered tools a principal may see / call.
7
+ *
8
+ * The predicate is intentionally IDENTICAL to the global-rule check
9
+ * `autoAuthMiddleware` applies (rpc.ts:258-260): a tool is allowed iff EVERY
10
+ * `requiredAccessRules` entry is satisfied by the principal's `accessRules`,
11
+ * with the `"*"` admin escape. This guarantees the resolver can never surface a
12
+ * tool the handler would then reject for a global rule, and — crucially — can
13
+ * never WIDEN a principal: a scope-narrowed principal carries a smaller
14
+ * `accessRules` set, and the intersection only ever shrinks the visible tools.
15
+ *
16
+ * Team reach is NOT pre-filtered here. Instance (team-scoped) rules are
17
+ * enforced per-call handler-side via the existing S2S `checkResourceTeamAccess`,
18
+ * so the resolver filters by the access-rule VOCABULARY only and the surfaced
19
+ * tool set matches exactly what the principal could invoke in the UI.
20
+ */
21
+ export interface AiToolResolver {
22
+ resolveTools(principal: AuthUser): RegisteredAiTool[];
23
+ isAllowed(args: { principal: AuthUser; tool: RegisteredAiTool }): boolean;
24
+ }
25
+
26
+ /**
27
+ * Pure predicate mirroring the middleware's global-rule check. Exported so
28
+ * tests can assert it equals `autoAuthMiddleware`'s behaviour for an arbitrary
29
+ * (rules, requiredAccessRules) matrix.
30
+ */
31
+ export function principalSatisfiesRules({
32
+ principalAccessRules,
33
+ requiredAccessRules,
34
+ }: {
35
+ principalAccessRules: readonly string[];
36
+ requiredAccessRules: readonly string[];
37
+ }): boolean {
38
+ return requiredAccessRules.every(
39
+ (rule) =>
40
+ principalAccessRules.includes("*") ||
41
+ principalAccessRules.includes(rule),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Single-tool authorization gate. Services bypass the registry entirely (they
47
+ * are trusted S2S callers and never drive the model); a service principal has
48
+ * no access-rule set, so it is never granted a tool here.
49
+ */
50
+ export function isToolAllowed({
51
+ principal,
52
+ tool,
53
+ }: {
54
+ principal: AuthUser;
55
+ tool: RegisteredAiTool;
56
+ }): boolean {
57
+ const principalAccessRules =
58
+ "accessRules" in principal ? (principal.accessRules ?? []) : [];
59
+ return principalSatisfiesRules({
60
+ principalAccessRules,
61
+ requiredAccessRules: tool.requiredAccessRules,
62
+ });
63
+ }
64
+
65
+ export function createAiToolResolver({
66
+ registry,
67
+ }: {
68
+ registry: AiToolRegistry;
69
+ }): AiToolResolver {
70
+ return {
71
+ isAllowed: isToolAllowed,
72
+ resolveTools(principal: AuthUser): RegisteredAiTool[] {
73
+ return registry
74
+ .getTools()
75
+ .filter((tool) => isToolAllowed({ principal, tool }));
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { coerceConversationModel } from "./router";
3
+ import type { ChatIntegrationLister } from "./router";
4
+
5
+ function lister(
6
+ integrations: Array<{
7
+ connectionId: string;
8
+ name: string;
9
+ defaultModel: string;
10
+ availableModels?: string[];
11
+ }>,
12
+ ): ChatIntegrationLister {
13
+ return { list: () => Promise.resolve(integrations) };
14
+ }
15
+
16
+ const conn = {
17
+ connectionId: "ai.openai-compatible.c1",
18
+ name: "OpenAI",
19
+ defaultModel: "gpt-4o-mini",
20
+ availableModels: ["gpt-4o-mini", "gpt-4o"],
21
+ };
22
+
23
+ describe("coerceConversationModel (P4 review item 1 — server-side model control)", () => {
24
+ test("honours an allowlisted model", async () => {
25
+ const model = await coerceConversationModel({
26
+ integrations: lister([conn]),
27
+ integrationId: conn.connectionId,
28
+ model: "gpt-4o",
29
+ });
30
+ expect(model).toBe("gpt-4o");
31
+ });
32
+
33
+ test("coerces an out-of-allowlist model to defaultModel (untrusted wire input)", async () => {
34
+ const model = await coerceConversationModel({
35
+ integrations: lister([conn]),
36
+ integrationId: conn.connectionId,
37
+ model: "evil-model",
38
+ });
39
+ expect(model).toBe("gpt-4o-mini");
40
+ });
41
+
42
+ test("an empty allowlist allows any model (free-text providers)", async () => {
43
+ const free = { ...conn, availableModels: undefined };
44
+ const model = await coerceConversationModel({
45
+ integrations: lister([free]),
46
+ integrationId: free.connectionId,
47
+ model: "llama3",
48
+ });
49
+ expect(model).toBe("llama3");
50
+ });
51
+
52
+ test("returns undefined when no model is requested", async () => {
53
+ const model = await coerceConversationModel({
54
+ integrations: lister([conn]),
55
+ integrationId: conn.connectionId,
56
+ model: undefined,
57
+ });
58
+ expect(model).toBeUndefined();
59
+ });
60
+
61
+ test("drops the model when the integration cannot be resolved (no validation possible)", async () => {
62
+ const model = await coerceConversationModel({
63
+ integrations: lister([conn]),
64
+ integrationId: "unknown.connection",
65
+ model: "gpt-4o",
66
+ });
67
+ expect(model).toBeUndefined();
68
+ });
69
+
70
+ test("drops the model when no integration id is given", async () => {
71
+ const model = await coerceConversationModel({
72
+ integrations: lister([conn]),
73
+ integrationId: undefined,
74
+ model: "gpt-4o",
75
+ });
76
+ expect(model).toBeUndefined();
77
+ });
78
+ });