@cosmicdrift/kumiko-bundled-features 0.241.0 → 0.242.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.241.0",
3
+ "version": "0.242.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.241.0",
134
- "@cosmicdrift/kumiko-framework": "0.241.0",
135
- "@cosmicdrift/kumiko-headless": "0.241.0",
136
- "@cosmicdrift/kumiko-renderer": "0.241.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.241.0",
138
- "@cosmicdrift/kumiko-types": "0.241.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.242.0",
134
+ "@cosmicdrift/kumiko-framework": "0.242.0",
135
+ "@cosmicdrift/kumiko-headless": "0.242.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.242.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.242.0",
138
+ "@cosmicdrift/kumiko-types": "0.242.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.241.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.241.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.242.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.242.0"
169
169
  }
170
170
  }
@@ -128,6 +128,46 @@ describe("findAgentDocGaps", () => {
128
128
  expect(findAgentDocGaps([feature])).toHaveLength(0);
129
129
  });
130
130
 
131
+ test("R2: custom screen with agent.expose:false opt-out -> no gap", () => {
132
+ const feature = defineFeature("doc-gap-demo", (r) => {
133
+ r.screen({
134
+ id: "sysadmin-secrets",
135
+ type: "custom",
136
+ renderer: { react: "stub" },
137
+ agent: { expose: false },
138
+ });
139
+ });
140
+
141
+ expect(findAgentDocGaps([feature])).toHaveLength(0);
142
+ });
143
+
144
+ test("R2: custom screen with agent.expose:true but no description -> still a gap", () => {
145
+ const feature = defineFeature("doc-gap-demo", (r) => {
146
+ r.screen({
147
+ id: "widget-editor",
148
+ type: "custom",
149
+ renderer: { react: "stub" },
150
+ agent: { expose: true },
151
+ });
152
+ });
153
+
154
+ const gaps = findAgentDocGaps([feature]);
155
+ expect(gaps).toHaveLength(1);
156
+ expect(gaps[0]?.qn).toBe("doc-gap-demo:screen:widget-editor");
157
+ expect(gaps[0]?.kind).toBe(AgentDocGapKinds.customScreenWithoutDescription);
158
+ });
159
+
160
+ test("R2: the custom-screen gap message names both ways out", () => {
161
+ const feature = defineFeature("doc-gap-demo", (r) => {
162
+ r.screen({ id: "widget-editor", type: "custom", renderer: { react: "stub" } });
163
+ });
164
+
165
+ const gaps = findAgentDocGaps([feature]);
166
+ expect(gaps).toHaveLength(1);
167
+ expect(formatAgentDocGap(gaps[0]!)).toContain("agent: { expose: false }");
168
+ expect(formatAgentDocGap(gaps[0]!)).toContain("`description`");
169
+ });
170
+
131
171
  test("R2: non-custom screen without description -> no gap (only custom screens are linted)", () => {
132
172
  const feature = defineFeature("doc-gap-demo", (r) => {
133
173
  r.entity("widget", { fields: {}, description: "A widget." });
@@ -183,4 +183,40 @@ describe("buildAgentManifest", () => {
183
183
  const entityNames = manifestA.entities.map((e) => e.name);
184
184
  expect(entityNames).toEqual([...entityNames].sort());
185
185
  });
186
+
187
+ test("a nav pointing at an opted-out screen is dropped too, so the screen id never leaks via navs", () => {
188
+ const feature = defineFeature("nav-leak-test", (r) => {
189
+ r.screen({
190
+ id: "sysadmin-secrets",
191
+ type: "custom",
192
+ renderer: { react: "stub" },
193
+ description: "Webhook secrets.",
194
+ agent: { expose: false },
195
+ });
196
+ r.screen({
197
+ id: "public-board",
198
+ type: "custom",
199
+ renderer: { react: "stub" },
200
+ description: "Public board.",
201
+ });
202
+ r.nav({
203
+ id: "sysadmin-secrets",
204
+ label: "nav-leak-test:nav:sysadmin-secrets",
205
+ screen: "nav-leak-test:screen:sysadmin-secrets",
206
+ });
207
+ r.nav({
208
+ id: "public-board",
209
+ label: "nav-leak-test:nav:public-board",
210
+ screen: "nav-leak-test:screen:public-board",
211
+ });
212
+ });
213
+
214
+ const manifest = buildAgentManifest(createRegistry([feature]), {
215
+ locale: "en",
216
+ roles: ["admin"],
217
+ });
218
+
219
+ expect(manifest.navs.map((n) => n.screen)).toEqual(["nav-leak-test:screen:public-board"]);
220
+ expect(manifest.screens.map((s) => s.id)).toEqual(["nav-leak-test:screen:public-board"]);
221
+ });
186
222
  });
@@ -303,6 +303,57 @@ describe("buildToolCatalog — client tools", () => {
303
303
  expect(descriptor.screenIds.has("catalog-test:screen:widget-approve-form")).toBe(true);
304
304
  });
305
305
 
306
+ test("a screen with agent.expose:false is absent from the navigate enum and from the manifest", () => {
307
+ const feature = defineFeature("exposure-test", (r) => {
308
+ r.screen({
309
+ id: "sysadmin-secrets",
310
+ type: "custom",
311
+ renderer: { react: "stub" },
312
+ description: "Webhook secrets.",
313
+ agent: { expose: false },
314
+ });
315
+ r.screen({
316
+ id: "public-board",
317
+ type: "custom",
318
+ renderer: { react: "stub" },
319
+ description: "Public board.",
320
+ });
321
+ });
322
+ const registry = createRegistry([feature]);
323
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"] });
324
+ const catalog = buildToolCatalog(registry, manifest, { mode: "edit" });
325
+
326
+ expect(manifest.screens.map((s) => s.id)).toContain("exposure-test:screen:public-board");
327
+ expect(manifest.screens.map((s) => s.id)).not.toContain(
328
+ "exposure-test:screen:sysadmin-secrets",
329
+ );
330
+
331
+ const descriptor = catalog.dispatchTable.get("navigate");
332
+ if (descriptor?.kind !== "client" || descriptor.op !== "navigate")
333
+ throw new Error("wrong kind");
334
+ expect(descriptor.screenIds.has("exposure-test:screen:sysadmin-secrets")).toBe(false);
335
+ expect(descriptor.screenIds.has("exposure-test:screen:public-board")).toBe(true);
336
+
337
+ const navTool = catalog.tools.find((t) => t.name === "navigate");
338
+ const schema = navTool?.inputSchema ?? {};
339
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
340
+ const screenIdSchema = isRecord(properties["screenId"]) ? properties["screenId"] : {};
341
+ const enumValues = Array.isArray(screenIdSchema["enum"]) ? screenIdSchema["enum"] : [];
342
+ expect(enumValues).toContain("exposure-test:screen:public-board");
343
+ expect(enumValues).not.toContain("exposure-test:screen:sysadmin-secrets");
344
+ });
345
+
346
+ test("a screen without an agent slot stays in the navigate enum", () => {
347
+ const catalog = buildCatalog(ADMIN);
348
+ const descriptor = catalog.dispatchTable.get("navigate");
349
+ if (descriptor?.kind !== "client" || descriptor.op !== "navigate")
350
+ throw new Error("wrong kind");
351
+ // None of catalog-test's screens carry a description either — this is the
352
+ // regression guard against a screen without an `agent` slot silently
353
+ // fail-closing (the way a handler without a description would).
354
+ expect(descriptor.screenIds.has("catalog-test:screen:widget-detail")).toBe(true);
355
+ });
356
+
306
357
  test("open_form maps the actionForm handler and the entityEdit create/update handlers", () => {
307
358
  const catalog = buildCatalog(ADMIN);
308
359
  const descriptor = catalog.dispatchTable.get("open_form");
@@ -3,7 +3,13 @@ import type {
3
3
  QueryHandlerDef,
4
4
  WriteHandlerDef,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
- import { QnTypes, qn, resolveAgentExposure, toKebab } from "@cosmicdrift/kumiko-framework/engine";
6
+ import {
7
+ isAgentVisibleScreen,
8
+ QnTypes,
9
+ qn,
10
+ resolveAgentExposure,
11
+ toKebab,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
7
13
 
8
14
  export const AgentDocGapKinds = {
9
15
  handlerWithoutDescription: "handler-without-description",
@@ -56,13 +62,18 @@ function handlerDocGaps(
56
62
  function screenDocGaps(feature: FeatureDefinition): readonly AgentDocGap[] {
57
63
  const gaps: AgentDocGap[] = [];
58
64
  for (const [shortId, screen] of Object.entries(feature.screens ?? {})) {
59
- if (screen.type !== CUSTOM_SCREEN_TYPE || screen.description !== undefined) continue;
65
+ if (
66
+ screen.type !== CUSTOM_SCREEN_TYPE ||
67
+ screen.description !== undefined ||
68
+ !isAgentVisibleScreen(screen)
69
+ )
70
+ continue;
60
71
  gaps.push({
61
72
  qn: qn(toKebab(feature.name), QnTypes.screen, toKebab(shortId)),
62
73
  feature: feature.name,
63
74
  kind: AgentDocGapKinds.customScreenWithoutDescription,
64
75
  message:
65
- "This custom screen has no description, so the AI agent can't tell what it's for — set `description` to explain it.",
76
+ "This custom screen has no description, so the AI agent can't tell what it's for — set `description` to explain it, or `agent: { expose: false }` to hide it from the agent deliberately.",
66
77
  });
67
78
  }
68
79
  return gaps;
@@ -10,7 +10,11 @@ import type {
10
10
  WorkspaceDefinition,
11
11
  WriteHandlerDef,
12
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
- import { hasAccess, resolveAgentExposure } from "@cosmicdrift/kumiko-framework/engine";
13
+ import {
14
+ hasAccess,
15
+ isAgentVisibleScreen,
16
+ resolveAgentExposure,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
14
18
  import { z } from "zod";
15
19
  import type {
16
20
  AgentManifest,
@@ -204,10 +208,15 @@ function buildNavs(
204
208
  workspaces: ReadonlyMap<string, WorkspaceDefinition>,
205
209
  translations: TranslationKeys,
206
210
  roles: readonly string[],
211
+ screens: ReadonlyMap<string, ScreenDefinition>,
207
212
  ): readonly AgentManifestNav[] {
208
213
  const result: AgentManifestNav[] = [];
209
214
  for (const [, nav] of navs) {
210
215
  if (!uiVisible(nav.access, roles)) continue;
216
+ // A nav pointing at an opted-out screen would leak its id and label back
217
+ // into the manifest the screen was just removed from.
218
+ const target = nav.screen !== undefined ? screens.get(nav.screen) : undefined;
219
+ if (target !== undefined && !isAgentVisibleScreen(target)) continue;
211
220
  const visibleWorkspaces = visibleWorkspaceIds(nav.workspaces, workspaces, roles);
212
221
  const hasWorkspaces = nav.workspaces !== undefined && nav.workspaces.length > 0;
213
222
  if (hasWorkspaces && visibleWorkspaces.length === 0) continue;
@@ -239,6 +248,9 @@ function buildScreens(
239
248
  const result: AgentManifestScreen[] = [];
240
249
 
241
250
  for (const [, screen] of screens) {
251
+ // An opted-out screen must not reach the manifest at all — the tool catalog
252
+ // builds `navigate`'s screen-id enum straight from `manifest.screens`.
253
+ if (!isAgentVisibleScreen(screen)) continue;
242
254
  const matchingNavs = allNavs.filter((nav) => nav.screen === screen.id);
243
255
  const accessibleNavs = matchingNavs.filter((nav) => uiVisible(nav.access, roles));
244
256
 
@@ -312,8 +324,9 @@ export function buildAgentManifest(
312
324
  roles,
313
325
  (qn) => registry.getHandlerEntity(qn),
314
326
  );
315
- const navs = buildNavs(navMap, workspaceMap, translations, roles);
316
- const screens = buildScreens(registry.getAllScreens(), navMap, workspaceMap, translations, roles);
327
+ const screenMap = registry.getAllScreens();
328
+ const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
329
+ const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles);
317
330
  const workspaces = buildWorkspaces(workspaceMap, translations, roles);
318
331
 
319
332
  return {
@@ -6,7 +6,7 @@ export const AGENT_TOOLS_FEATURE_NAME = "agent-tools";
6
6
  export function createAgentToolsFeature(): FeatureDefinition {
7
7
  return defineFeature(AGENT_TOOLS_FEATURE_NAME, (r) => {
8
8
  r.describe(
9
- "Builds a tool catalog and an agent manifest from the mounted registry so an LLM agent can call handlers and understand the app's shape. A handler, custom screen, or entity without a `description` stays invisible to the agent by construction (see `resolveAgentExposure`) — this feature surfaces those gaps at boot and via `kumiko agent lint` so an app author notices before an agent silently can't see a feature.",
9
+ "Builds a tool catalog and an agent manifest from the mounted registry so an LLM agent can call handlers and understand the app's shape. A handler or entity without a `description` stays invisible to the agent by construction (see `resolveAgentExposure`), while a screen stays visible unless it opts out with `agent: { expose: false }` (see `isAgentVisibleScreen`) — this feature surfaces the resulting gaps at boot and via `kumiko agent lint` so an app author notices before an agent silently can't see a feature.",
10
10
  );
11
11
  r.uiHints({
12
12
  displayLabel: "AI Agent Tools",