@checkstack/ai-backend 0.1.6 → 0.3.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 +99 -0
- package/package.json +6 -4
- package/src/agent-runner.test.ts +24 -24
- package/src/chat/agent-loop.test.ts +10 -10
- package/src/chat/auto-apply.test.ts +2 -2
- package/src/chat/chat-service.streamturn.test.ts +16 -1
- package/src/chat/system-prompt.test.ts +11 -0
- package/src/chat/system-prompt.ts +34 -5
- package/src/extension-points.ts +89 -0
- package/src/generated/docs-index.ts +18 -3
- package/src/hardening/handler-authz.test.ts +11 -11
- package/src/index.ts +46 -1
- package/src/mcp/server.test.ts +13 -13
- package/src/propose-apply/service.test.ts +13 -13
- package/src/registry-wiring.test.ts +17 -9
- package/src/registry-wiring.ts +29 -1
- package/src/resolver.test.ts +8 -8
- package/src/system-signals-contributor.test.ts +162 -0
- package/src/system-signals-contributor.ts +129 -0
- package/src/tool-name.test.ts +42 -0
- package/src/tool-name.ts +37 -0
- package/src/tool-registry.ts +14 -4
- package/src/tools/docs-tools.test.ts +1 -1
- package/src/tools/system-issues.test.ts +236 -0
- package/src/tools/system-issues.ts +209 -0
- package/src/tools/tool-set.e2e.test.ts +1 -1
- package/tsconfig.json +6 -0
package/src/registry-wiring.ts
CHANGED
|
@@ -2,8 +2,11 @@ import type { PluginMetadata } from "@checkstack/common";
|
|
|
2
2
|
import type {
|
|
3
3
|
AiToolExtensionPoint,
|
|
4
4
|
AiToolProjectionExtensionPoint,
|
|
5
|
+
SystemSignalsContributor,
|
|
6
|
+
SystemSignalsExtensionPoint,
|
|
5
7
|
} from "./extension-points";
|
|
6
8
|
import { buildProjectedTool } from "./projection";
|
|
9
|
+
import { toProviderToolName } from "./tool-name";
|
|
7
10
|
import type { AiToolRegistry } from "./tool-registry";
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -57,7 +60,10 @@ export function createRegistryExtensionPoints({
|
|
|
57
60
|
const tool = buildProjectedTool(input);
|
|
58
61
|
registry.register(tool);
|
|
59
62
|
exposedProjections.push({
|
|
60
|
-
|
|
63
|
+
// Match the registry's canonical (provider-safe) key so the chat
|
|
64
|
+
// read-loop and MCP transport resolve this route by the same name the
|
|
65
|
+
// model is given and echoes back.
|
|
66
|
+
toolName: toProviderToolName(tool.name),
|
|
61
67
|
pluginId: input.sourcePluginMetadata.pluginId,
|
|
62
68
|
procedureKey: input.procedureKey,
|
|
63
69
|
});
|
|
@@ -66,3 +72,25 @@ export function createRegistryExtensionPoints({
|
|
|
66
72
|
|
|
67
73
|
return { toolExtensionPoint, projectionExtensionPoint, exposedProjections };
|
|
68
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the {@link SystemSignalsExtensionPoint} implementation that accumulates
|
|
78
|
+
* every registered contributor into a shared array, mirroring how {@link
|
|
79
|
+
* createRegistryExtensionPoints} accumulates `exposedProjections`. The returned
|
|
80
|
+
* `contributors` array is the SAME reference the `system.issues` tool reads at
|
|
81
|
+
* execute time, so contributors registered from any plugin's `init` are visible
|
|
82
|
+
* to the tool by the time it runs.
|
|
83
|
+
*/
|
|
84
|
+
export function createSystemSignalsExtensionPoint(): {
|
|
85
|
+
systemSignalsExtensionPoint: SystemSignalsExtensionPoint;
|
|
86
|
+
/** Every contributor registered via the point (populated lazily at init). */
|
|
87
|
+
contributors: SystemSignalsContributor[];
|
|
88
|
+
} {
|
|
89
|
+
const contributors: SystemSignalsContributor[] = [];
|
|
90
|
+
const systemSignalsExtensionPoint: SystemSignalsExtensionPoint = {
|
|
91
|
+
contribute: (contributor) => {
|
|
92
|
+
contributors.push(contributor);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
return { systemSignalsExtensionPoint, contributors };
|
|
96
|
+
}
|
package/src/resolver.test.ts
CHANGED
|
@@ -26,24 +26,24 @@ describe("createAiToolResolver.resolveTools", () => {
|
|
|
26
26
|
test("a principal lacking automation.manage never sees automation.propose", () => {
|
|
27
27
|
const registry = createAiToolRegistry();
|
|
28
28
|
registry.register(
|
|
29
|
-
tool("
|
|
29
|
+
tool("automation_propose", ["automation.automation.manage"]),
|
|
30
30
|
);
|
|
31
|
-
registry.register(tool("
|
|
31
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
32
32
|
const resolver = createAiToolResolver({ registry });
|
|
33
33
|
|
|
34
34
|
const principal = userWith(["incident.incident.read"]);
|
|
35
35
|
const names = resolver.resolveTools(principal).map((t) => t.name);
|
|
36
36
|
|
|
37
|
-
expect(names).toEqual(["
|
|
38
|
-
expect(names).not.toContain("
|
|
37
|
+
expect(names).toEqual(["incident_list"]);
|
|
38
|
+
expect(names).not.toContain("automation_propose");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test("an admin (accessRules ['*']) sees all tools", () => {
|
|
42
42
|
const registry = createAiToolRegistry();
|
|
43
43
|
registry.register(
|
|
44
|
-
tool("
|
|
44
|
+
tool("automation_propose", ["automation.automation.manage"]),
|
|
45
45
|
);
|
|
46
|
-
registry.register(tool("
|
|
46
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
47
47
|
const resolver = createAiToolResolver({ registry });
|
|
48
48
|
|
|
49
49
|
const names = resolver
|
|
@@ -51,12 +51,12 @@ describe("createAiToolResolver.resolveTools", () => {
|
|
|
51
51
|
.map((t) => t.name)
|
|
52
52
|
.sort();
|
|
53
53
|
|
|
54
|
-
expect(names).toEqual(["
|
|
54
|
+
expect(names).toEqual(["automation_propose", "incident_list"]);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
test("a service principal (no access rules) sees no tools", () => {
|
|
58
58
|
const registry = createAiToolRegistry();
|
|
59
|
-
registry.register(tool("
|
|
59
|
+
registry.register(tool("incident_list", ["incident.incident.read"]));
|
|
60
60
|
const resolver = createAiToolResolver({ registry });
|
|
61
61
|
|
|
62
62
|
const service: AuthUser = { type: "service", pluginId: "automation" };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
2
|
+
import { access } from "@checkstack/common";
|
|
3
|
+
import type { AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import type { SystemSignal, SystemSignalsMap } from "@checkstack/catalog-common";
|
|
5
|
+
import {
|
|
6
|
+
createGatedSystemSignalsContributor,
|
|
7
|
+
type SystemAccessResolver,
|
|
8
|
+
} from "./system-signals-contributor";
|
|
9
|
+
|
|
10
|
+
// A read rule for a fictional "demo" plugin: id "thing.read", qualified
|
|
11
|
+
// "demo.thing.read", qualified resource type "demo.thing".
|
|
12
|
+
const rule = access("thing", "read", "View things", { pluginId: "demo" });
|
|
13
|
+
|
|
14
|
+
const signal = (source: string): SystemSignal => ({
|
|
15
|
+
source,
|
|
16
|
+
tone: "error",
|
|
17
|
+
label: "Problem",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const globalSignals: SystemSignalsMap = {
|
|
21
|
+
sysA: [signal("demo")],
|
|
22
|
+
sysB: [signal("demo")],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** A resolver that returns a fixed accessible subset and records its args. */
|
|
26
|
+
function stubResolver(accessible: string[]): {
|
|
27
|
+
resolver: SystemAccessResolver;
|
|
28
|
+
calls: Array<Parameters<SystemAccessResolver["accessibleSystemIds"]>[0]>;
|
|
29
|
+
} {
|
|
30
|
+
const calls: Array<
|
|
31
|
+
Parameters<SystemAccessResolver["accessibleSystemIds"]>[0]
|
|
32
|
+
> = [];
|
|
33
|
+
return {
|
|
34
|
+
calls,
|
|
35
|
+
resolver: {
|
|
36
|
+
accessibleSystemIds: async (args) => {
|
|
37
|
+
calls.push(args);
|
|
38
|
+
return accessible.filter((id) => args.systemIds.includes(id));
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const userWith = (accessRules: string[]): AuthUser => ({
|
|
45
|
+
type: "user",
|
|
46
|
+
id: "u1",
|
|
47
|
+
accessRules,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("createGatedSystemSignalsContributor", () => {
|
|
51
|
+
test("global-rule principal sees every system; resolver is not consulted", async () => {
|
|
52
|
+
const read = mock(async () => globalSignals);
|
|
53
|
+
const { resolver, calls } = stubResolver([]);
|
|
54
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
55
|
+
sourceId: "demo",
|
|
56
|
+
accessRule: rule,
|
|
57
|
+
resolver,
|
|
58
|
+
readSignals: read,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await contributor.read({
|
|
62
|
+
principal: userWith(["demo.thing.read"]),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.accessible).toBe(true);
|
|
66
|
+
expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
|
|
67
|
+
expect(read).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(calls).toHaveLength(0); // global access skips the team resolver
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("wildcard grant is treated as global", async () => {
|
|
72
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
73
|
+
sourceId: "demo",
|
|
74
|
+
accessRule: rule,
|
|
75
|
+
resolver: stubResolver([]).resolver,
|
|
76
|
+
readSignals: async () => globalSignals,
|
|
77
|
+
});
|
|
78
|
+
const result = await contributor.read({ principal: userWith(["*"]) });
|
|
79
|
+
expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("service principals are trusted (treated as global)", async () => {
|
|
83
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
84
|
+
sourceId: "demo",
|
|
85
|
+
accessRule: rule,
|
|
86
|
+
resolver: stubResolver([]).resolver,
|
|
87
|
+
readSignals: async () => globalSignals,
|
|
88
|
+
});
|
|
89
|
+
const principal: AuthUser = { type: "service", pluginId: "svc" };
|
|
90
|
+
const result = await contributor.read({ principal });
|
|
91
|
+
expect(result.accessible).toBe(true);
|
|
92
|
+
expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a manage grant satisfies the read rule (escalation) and sees all", async () => {
|
|
96
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
97
|
+
sourceId: "demo",
|
|
98
|
+
accessRule: rule,
|
|
99
|
+
resolver: stubResolver([]).resolver,
|
|
100
|
+
readSignals: async () => globalSignals,
|
|
101
|
+
});
|
|
102
|
+
const result = await contributor.read({
|
|
103
|
+
principal: userWith(["demo.thing.manage"]),
|
|
104
|
+
});
|
|
105
|
+
expect(Object.keys(result.signals).sort()).toEqual(["sysA", "sysB"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("non-global user is filtered to team-granted systems (correct resourceType)", async () => {
|
|
109
|
+
const read = mock(async () => globalSignals);
|
|
110
|
+
const { resolver, calls } = stubResolver(["sysB"]);
|
|
111
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
112
|
+
sourceId: "demo",
|
|
113
|
+
accessRule: rule,
|
|
114
|
+
resolver,
|
|
115
|
+
readSignals: read,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await contributor.read({ principal: userWith([]) });
|
|
119
|
+
|
|
120
|
+
expect(result.accessible).toBe(true);
|
|
121
|
+
expect(Object.keys(result.signals)).toEqual(["sysB"]); // sysA filtered out
|
|
122
|
+
expect(read).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(calls).toHaveLength(1);
|
|
124
|
+
expect(calls[0]).toMatchObject({
|
|
125
|
+
userId: "u1",
|
|
126
|
+
userType: "user",
|
|
127
|
+
resourceType: "demo.thing", // qualifyResourceType(pluginId, resource)
|
|
128
|
+
action: "read",
|
|
129
|
+
hasGlobalAccess: false,
|
|
130
|
+
});
|
|
131
|
+
expect(calls[0].systemIds.sort()).toEqual(["sysA", "sysB"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("non-global user with no team grants is reported inaccessible", async () => {
|
|
135
|
+
const { resolver } = stubResolver([]); // grants nothing
|
|
136
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
137
|
+
sourceId: "demo",
|
|
138
|
+
accessRule: rule,
|
|
139
|
+
resolver,
|
|
140
|
+
readSignals: async () => globalSignals,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const result = await contributor.read({ principal: userWith([]) });
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({ accessible: false, signals: {} });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("globally-clear source: accessible with no signals, resolver not called", async () => {
|
|
149
|
+
const { resolver, calls } = stubResolver([]);
|
|
150
|
+
const contributor = createGatedSystemSignalsContributor({
|
|
151
|
+
sourceId: "demo",
|
|
152
|
+
accessRule: rule,
|
|
153
|
+
resolver,
|
|
154
|
+
readSignals: async () => ({}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = await contributor.read({ principal: userWith([]) });
|
|
158
|
+
|
|
159
|
+
expect(result).toEqual({ accessible: true, signals: {} });
|
|
160
|
+
expect(calls).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
2
|
+
import type { AccessRule } from "@checkstack/common";
|
|
3
|
+
import { isAccessRuleSatisfied, qualifyResourceType } from "@checkstack/common";
|
|
4
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
5
|
+
import type { SystemSignalsMap } from "@checkstack/catalog-common";
|
|
6
|
+
import {
|
|
7
|
+
principalGrantedRuleIds,
|
|
8
|
+
type SystemSignalsContributor,
|
|
9
|
+
} from "./extension-points";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the subset of `systemIds` a non-global principal may see for a
|
|
13
|
+
* resource type, applying the SAME team/instance grants the RPC middleware
|
|
14
|
+
* enforces for list/record endpoints (via auth's `getAccessibleResourceIds`).
|
|
15
|
+
*/
|
|
16
|
+
export interface SystemAccessResolver {
|
|
17
|
+
accessibleSystemIds(args: {
|
|
18
|
+
userId: string;
|
|
19
|
+
userType: "user" | "application";
|
|
20
|
+
resourceType: string;
|
|
21
|
+
systemIds: string[];
|
|
22
|
+
action: "read" | "manage";
|
|
23
|
+
hasGlobalAccess: boolean;
|
|
24
|
+
}): Promise<string[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a {@link SystemAccessResolver} backed by the auth plugin's
|
|
29
|
+
* `getAccessibleResourceIds` S2S query - the exact primitive the RPC middleware
|
|
30
|
+
* uses to filter list/record endpoints by team grants. A plugin gets its
|
|
31
|
+
* `rpcClient` from `coreServices.rpcClient` in `init`.
|
|
32
|
+
*/
|
|
33
|
+
export function createSystemAccessResolver(
|
|
34
|
+
rpcClient: RpcClient,
|
|
35
|
+
): SystemAccessResolver {
|
|
36
|
+
const authClient = rpcClient.forPlugin(AuthApi);
|
|
37
|
+
return {
|
|
38
|
+
accessibleSystemIds: ({
|
|
39
|
+
userId,
|
|
40
|
+
userType,
|
|
41
|
+
resourceType,
|
|
42
|
+
systemIds,
|
|
43
|
+
action,
|
|
44
|
+
hasGlobalAccess,
|
|
45
|
+
}) =>
|
|
46
|
+
authClient.getAccessibleResourceIds({
|
|
47
|
+
userId,
|
|
48
|
+
userType,
|
|
49
|
+
resourceType,
|
|
50
|
+
resourceIds: systemIds,
|
|
51
|
+
action,
|
|
52
|
+
hasGlobalAccess,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a {@link SystemSignalsContributor} from a GLOBAL signal reader, applying
|
|
59
|
+
* the per-source access gate centrally so every source enforces it identically
|
|
60
|
+
* (and the same way the matching bulk RPC does):
|
|
61
|
+
*
|
|
62
|
+
* - A principal holding the global rule - including trusted service principals,
|
|
63
|
+
* which {@link principalGrantedRuleIds} maps to the wildcard - sees every
|
|
64
|
+
* system the source reports.
|
|
65
|
+
* - A real user / application WITHOUT the global rule sees only the systems its
|
|
66
|
+
* TEAM grants allow, via {@link SystemAccessResolver} (the same instance/team
|
|
67
|
+
* filtering the bulk RPC applies), so `system.issues` neither under- nor
|
|
68
|
+
* over-reports relative to the per-domain UI.
|
|
69
|
+
* - Any other principal without the global rule (anonymous, or a service lacking
|
|
70
|
+
* the wildcard) sees nothing.
|
|
71
|
+
*
|
|
72
|
+
* `readSignals` MUST return problem signals for ALL systems globally; the gate
|
|
73
|
+
* filters them. It is not called for a principal that can see nothing, so a
|
|
74
|
+
* no-access principal triggers no query.
|
|
75
|
+
*/
|
|
76
|
+
export function createGatedSystemSignalsContributor({
|
|
77
|
+
sourceId,
|
|
78
|
+
accessRule,
|
|
79
|
+
resolver,
|
|
80
|
+
readSignals,
|
|
81
|
+
}: {
|
|
82
|
+
sourceId: string;
|
|
83
|
+
accessRule: AccessRule;
|
|
84
|
+
resolver: SystemAccessResolver;
|
|
85
|
+
readSignals: () => Promise<SystemSignalsMap>;
|
|
86
|
+
}): SystemSignalsContributor {
|
|
87
|
+
const resourceType = qualifyResourceType(
|
|
88
|
+
accessRule.pluginId,
|
|
89
|
+
accessRule.resource,
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
sourceId,
|
|
93
|
+
read: async ({ principal }: { principal: AuthUser }) => {
|
|
94
|
+
const hasGlobalAccess = isAccessRuleSatisfied(
|
|
95
|
+
principalGrantedRuleIds(principal),
|
|
96
|
+
accessRule,
|
|
97
|
+
);
|
|
98
|
+
if (hasGlobalAccess) {
|
|
99
|
+
return { accessible: true, signals: await readSignals() };
|
|
100
|
+
}
|
|
101
|
+
// Only real users / applications can carry per-team instance grants.
|
|
102
|
+
if (principal.type !== "user" && principal.type !== "application") {
|
|
103
|
+
return { accessible: false, signals: {} };
|
|
104
|
+
}
|
|
105
|
+
const signals = await readSignals();
|
|
106
|
+
const systemIds = Object.keys(signals);
|
|
107
|
+
if (systemIds.length === 0) {
|
|
108
|
+
// Source is globally clear: nothing to report and nothing to hide.
|
|
109
|
+
return { accessible: true, signals: {} };
|
|
110
|
+
}
|
|
111
|
+
const accessibleIds = new Set(
|
|
112
|
+
await resolver.accessibleSystemIds({
|
|
113
|
+
userId: principal.id,
|
|
114
|
+
userType: principal.type,
|
|
115
|
+
resourceType,
|
|
116
|
+
systemIds,
|
|
117
|
+
action: "read",
|
|
118
|
+
hasGlobalAccess: false,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const filtered: SystemSignalsMap = {};
|
|
122
|
+
for (const [systemId, sigs] of Object.entries(signals)) {
|
|
123
|
+
if (accessibleIds.has(systemId)) filtered[systemId] = sigs;
|
|
124
|
+
}
|
|
125
|
+
// Accessible iff the principal may see at least one affected system.
|
|
126
|
+
return { accessible: accessibleIds.size > 0, signals: filtered };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PROVIDER_TOOL_NAME_PATTERN, toProviderToolName } from "./tool-name";
|
|
3
|
+
|
|
4
|
+
describe("toProviderToolName", () => {
|
|
5
|
+
test("maps the '.' namespace separator to '_'", () => {
|
|
6
|
+
expect(toProviderToolName("incident.list")).toBe("incident_list");
|
|
7
|
+
expect(toProviderToolName("catalog.listSystems")).toBe(
|
|
8
|
+
"catalog_listSystems",
|
|
9
|
+
);
|
|
10
|
+
expect(toProviderToolName("dependency.list")).toBe("dependency_list");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("maps every '.' in a multi-dot name", () => {
|
|
14
|
+
expect(toProviderToolName("a.b.c")).toBe("a_b_c");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("leaves an already provider-safe name unchanged", () => {
|
|
18
|
+
expect(toProviderToolName("incident_list")).toBe("incident_list");
|
|
19
|
+
expect(toProviderToolName("get-status")).toBe("get-status");
|
|
20
|
+
expect(toProviderToolName("Tool_123")).toBe("Tool_123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the result always matches the provider pattern", () => {
|
|
24
|
+
for (const name of ["incident.list", "a.b.c", "get-status", "x"]) {
|
|
25
|
+
expect(PROVIDER_TOOL_NAME_PATTERN.test(toProviderToolName(name))).toBe(
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("throws on an illegal character rather than silently rewriting it", () => {
|
|
32
|
+
expect(() => toProviderToolName("foo$bar")).toThrow(/Invalid AI tool name/);
|
|
33
|
+
expect(() => toProviderToolName("with space")).toThrow(
|
|
34
|
+
/Invalid AI tool name/,
|
|
35
|
+
);
|
|
36
|
+
expect(() => toProviderToolName("emoji😀")).toThrow(/Invalid AI tool name/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("throws on an empty name", () => {
|
|
40
|
+
expect(() => toProviderToolName("")).toThrow(/Invalid AI tool name/);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/tool-name.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider tool-name constraint.
|
|
3
|
+
*
|
|
4
|
+
* LLM providers (and the MCP spec) require tool names to match
|
|
5
|
+
* `^[a-zA-Z0-9_-]+$`.
|
|
6
|
+
*/
|
|
7
|
+
export const PROVIDER_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a canonical tool name to its provider-safe form.
|
|
11
|
+
*
|
|
12
|
+
* Tool names are qualified as `<plugin>.<tool>` - the "." is the namespace
|
|
13
|
+
* separator of the naming convention, which the provider rejects. It is mapped
|
|
14
|
+
* deterministically to "_" (so `incident.list` -> `incident_list`).
|
|
15
|
+
*
|
|
16
|
+
* Any OTHER disallowed character is an authoring mistake in the tool
|
|
17
|
+
* definition, not stray input, so it is NOT silently rewritten: this throws so
|
|
18
|
+
* the bad name surfaces at registration (startup) instead of being masked.
|
|
19
|
+
*
|
|
20
|
+
* The mapping is applied at the single registration chokepoint (the tool
|
|
21
|
+
* registry) and the projection-routing table, so the registry key, the name
|
|
22
|
+
* sent to the model / MCP client, and the name the model echoes back in a tool
|
|
23
|
+
* call are all identical - the round-trip (name-out === name-in) holds without
|
|
24
|
+
* any reverse lookup.
|
|
25
|
+
*/
|
|
26
|
+
export function toProviderToolName(name: string): string {
|
|
27
|
+
const normalized = name.replaceAll(".", "_");
|
|
28
|
+
if (!PROVIDER_TOOL_NAME_PATTERN.test(normalized)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid AI tool name "${name}": after normalizing the "." separator to ` +
|
|
31
|
+
`"_" it must match ${String(PROVIDER_TOOL_NAME_PATTERN)} (letters, ` +
|
|
32
|
+
`digits, "_" and "-" only). Rename the tool to use only those ` +
|
|
33
|
+
`characters, with "." reserved for the <plugin>.<tool> separator.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
2
2
|
import type { AiTool } from "@checkstack/ai-common";
|
|
3
|
+
import { toProviderToolName } from "./tool-name";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A tool whose executors run with a Checkstack {@link AuthUser} principal and
|
|
@@ -21,7 +22,12 @@ export type RegisteredAiTool<TInput = unknown, TOutput = unknown> = AiTool<
|
|
|
21
22
|
* registry, so no capability is implemented twice.
|
|
22
23
|
*
|
|
23
24
|
* Tool names are already fully qualified (`<plugin>.<tool>`) by the extension
|
|
24
|
-
* points before they reach `register`.
|
|
25
|
+
* points before they reach `register`. `register` then maps the name to its
|
|
26
|
+
* provider-safe form (see {@link toProviderToolName}) and uses that as the
|
|
27
|
+
* canonical key, so every consumer (serializer, chat SDK tools, MCP
|
|
28
|
+
* `tools/list`, and the tool-call resolution path) sees and resolves the same
|
|
29
|
+
* provider-safe name. A name with an illegal character (beyond the "."
|
|
30
|
+
* separator) is rejected here rather than rewritten.
|
|
25
31
|
*/
|
|
26
32
|
export interface AiToolRegistry {
|
|
27
33
|
register(tool: RegisteredAiTool): void;
|
|
@@ -35,12 +41,16 @@ export function createAiToolRegistry(): AiToolRegistry {
|
|
|
35
41
|
|
|
36
42
|
return {
|
|
37
43
|
register(tool: RegisteredAiTool): void {
|
|
38
|
-
|
|
44
|
+
// Map to the provider-safe name (e.g. `incident.list` -> `incident_list`)
|
|
45
|
+
// and key the registry on it, so the name sent to the model and the name
|
|
46
|
+
// it echoes back both match this entry. Throws on an illegal name.
|
|
47
|
+
const name = toProviderToolName(tool.name);
|
|
48
|
+
if (tools.has(name)) {
|
|
39
49
|
throw new Error(
|
|
40
|
-
`AI tool ${
|
|
50
|
+
`AI tool ${name} already registered — likely a duplicate registration.`,
|
|
41
51
|
);
|
|
42
52
|
}
|
|
43
|
-
tools.set(tool.name,
|
|
53
|
+
tools.set(name, name === tool.name ? tool : { ...tool, name });
|
|
44
54
|
},
|
|
45
55
|
|
|
46
56
|
getTools(): RegisteredAiTool[] {
|
|
@@ -120,7 +120,7 @@ describe("docs tools registration + resolution", () => {
|
|
|
120
120
|
.resolveTools(userWith(["ai.chat.read"]))
|
|
121
121
|
.map((t) => t.name)
|
|
122
122
|
.sort();
|
|
123
|
-
expect(names).toEqual(["
|
|
123
|
+
expect(names).toEqual(["ai_getDoc", "ai_searchDocs"]);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
test("a principal without ai.chat.read sees neither docs tool", () => {
|