@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "./server/*": "./src/server/*",
31
31
  "./browser": "./src/server/api-client.ts",
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
+ "./browser/app-badges": "./src/browser/app-badges.ts",
33
34
  "./browser/live": "./src/browser/live.ts",
34
35
  "./browser/mermaid": "./src/browser/mermaid.ts",
35
36
  "./browser/reload": "./src/browser/reload.ts",
@@ -52,7 +53,9 @@
52
53
  "./cli/admin": "./src/cli/admin/index.ts",
53
54
  "./shared": "./src/shared/index.ts",
54
55
  "./services": "./src/services/index.ts",
56
+ "./services/image-metadata": "./src/services/image-metadata.ts",
55
57
  "./services/document-extraction": "./src/services/document-extraction.ts",
58
+ "./services/outbox": "./src/services/outbox.ts",
56
59
  "./services/ipa/service-account": null,
57
60
  "./services/*": "./src/services/*",
58
61
  "./ai": "./src/ai/index.ts",
@@ -89,7 +92,7 @@
89
92
  },
90
93
  "scripts": {
91
94
  "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.*'",
92
- "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
95
+ "typecheck": "tsc --checkers 2 -p tsconfig.json --noEmit --pretty false"
93
96
  },
94
97
  "dependencies": {
95
98
  "@firecrawl/anydoc": "0.2.4",
@@ -98,10 +101,10 @@
98
101
  "@simplewebauthn/server": "14.0.3",
99
102
  "@tabler/icons": "3.48.0",
100
103
  "@tailwindcss/typography": "0.5.20",
101
- "@k2b/nessi": "0.15.0",
102
- "@k2b/ssr": "0.14.0",
103
- "@k2b/ui": "0.18.0",
104
- "@k2b/stdlib": "0.27.0",
104
+ "@k2b/nessi": "0.17.0",
105
+ "@k2b/ssr": "0.15.0",
106
+ "@k2b/ui": "0.20.0",
107
+ "@k2b/stdlib": "0.28.0",
105
108
  "@k2b/sync": "7.0.0",
106
109
  "@nats-io/jetstream": "3.4.0",
107
110
  "@nats-io/transport-node": "3.4.0",
@@ -94,6 +94,24 @@ export const buildCliPlugin = async (params: {
94
94
  minify: true,
95
95
  // Playwright references this optional BiDi adapter lazily; Cloud uses CDP.
96
96
  external: ["chromium-bidi/*"],
97
+ plugins: [
98
+ {
99
+ name: "playwright-metadata",
100
+ setup(build) {
101
+ build.onLoad({ filter: /[/\\]playwright-core[/\\]lib[/\\](?:coreBundle|serverRegistry|package)\.js$/ }, async ({ path }) => ({
102
+ // Computed package-relative JSON requires evade Bun's dependency
103
+ // graph and retain the container's absolute path. Static requires
104
+ // carry the version and browser registry in the single-file plugin.
105
+ contents: (await readFile(path, "utf8")).replace(
106
+ /require\(import_path\d*\.default\.join\(packageRoot, "(package|browsers)\.json"\)\)/g,
107
+ (_, file: string) => `require("../${file}.json")`,
108
+ ),
109
+ loader: "js",
110
+ resolveDir: dirname(path),
111
+ }));
112
+ },
113
+ },
114
+ ],
97
115
  });
98
116
  if (!result.success) {
99
117
  throw new Error(`CLI module "${name}" failed to bundle:\n${result.logs.map((log) => String(log)).join("\n")}`);
@@ -86,6 +86,7 @@ import { configurePostgresApplicationName } from "./postgres-application-name";
86
86
  import { getProcessSync, startProcessSync } from "./process-sync";
87
87
  import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
88
88
  import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
89
+ import { canonicalRoute, isOwnNavBadge } from "./registry-validation";
89
90
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
90
91
  import { servePublicAsset } from "./static-assets";
91
92
  import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
@@ -144,6 +145,13 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
144
145
  section: "primary" | "more" | "hidden";
145
146
  requiresAuth?: boolean;
146
147
  requiresRoles?: Role[];
148
+ /**
149
+ * Same-origin path of a `GET` route this app serves below one of its
150
+ * `routes`. After the page loads, the shell reads it with the person's
151
+ * session and shows the returned `AppNavBadge` count on the app's icon in
152
+ * the rail and the app grid. Protect it like any other route.
153
+ */
154
+ badge?: string;
147
155
  };
148
156
  /**
149
157
  * Settings owned by this app, declared as a map of dotted-key → definition.
@@ -332,11 +340,6 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
332
340
  // ── Implementation ──────────────────────────────────────────────────────────
333
341
 
334
342
  const isPwaPath = (route: string): boolean => route === "/pwa" || route.startsWith("/pwa/");
335
- /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
336
- const canonicalRoute = (route: string): string => {
337
- const trimmed = route.trim();
338
- return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
339
- };
340
343
 
341
344
  /**
342
345
  * `/pwa` belongs to the mobile app shell, `/pwa/_auth` to Core, and `/pwa/<id>`
@@ -357,6 +360,14 @@ const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): Ap
357
360
  return { href: `/pwa/${opts.id}`, requiresRoles: opts.pwa.requiresRoles ? [...opts.pwa.requiresRoles] : undefined };
358
361
  };
359
362
 
363
+ /** The badge route is read by the browser through the gateway, so it must be one of this app's own paths. */
364
+ const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): void => {
365
+ const badge = opts.nav?.badge;
366
+ if (badge !== undefined && !isOwnNavBadge(badge, opts.routes)) {
367
+ throw new Error(`App "${opts.id}" declares nav.badge "${badge}"; it must be a same-origin path below one of its routes`);
368
+ }
369
+ };
370
+
360
371
  export const defineApp = <
361
372
  const S extends AppSettingsMap = {},
362
373
  const N extends NotificationDefinitionMap = {},
@@ -369,6 +380,7 @@ export const defineApp = <
369
380
  const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
370
381
  const cliModules = validateAppCliModules(opts.id, opts.cli);
371
382
  const pwaPart = validateAppPwaPart(opts);
383
+ validateAppNavBadge(opts);
372
384
 
373
385
  // ── 0. Register declared settings into the runtime registry ──────────
374
386
  // SETTINGS_MAP is the single source of truth for validation in store.ts
@@ -387,6 +399,9 @@ export const defineApp = <
387
399
  rootDir,
388
400
  componentRoots: [resolve(env.APP_DIR ?? rootDir, "src"), fileURLToPath(new URL("../", import.meta.url))],
389
401
  basePath: opts.basePath,
402
+ // Every island instance already mounts inside its own error boundary; this
403
+ // only gives the fallback Cloud's look and the reader's language.
404
+ errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
390
405
  template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
391
406
  const themeFixed = theme !== undefined;
392
407
  // App pages paint the canvas before any stylesheet and give it to the
@@ -571,6 +586,7 @@ export const defineApp = <
571
586
  section: meta.nav?.section ?? "hidden",
572
587
  requiresAuth: meta.nav?.requiresAuth,
573
588
  requiresRoles: meta.nav?.requiresRoles,
589
+ badge: meta.nav?.badge,
574
590
  adminHref: meta.adminHref,
575
591
  }
576
592
  : undefined,
@@ -7,6 +7,32 @@ const isString = (value: unknown): value is string => typeof value === "string";
7
7
  const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
8
8
  const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
9
9
 
10
+ /** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
11
+ const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
12
+
13
+ /** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
14
+ export const canonicalRoute = (route: string): string => {
15
+ const trimmed = route.trim();
16
+ return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
17
+ };
18
+
19
+ /**
20
+ * Whether a `nav.badge` path stays below one of the app's own `routes`. It is
21
+ * checked on the path the browser actually requests, after dot segments, and
22
+ * never starts with `//`, which a URL resolver reads as another host.
23
+ */
24
+ export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
25
+ if (!SAME_ORIGIN_PATH.test(badge)) return false;
26
+ const path = new URL(badge, "https://cloud.invalid").pathname;
27
+ return (
28
+ !path.startsWith("//") &&
29
+ routes.some((route) => {
30
+ const prefix = canonicalRoute(route);
31
+ return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
32
+ })
33
+ );
34
+ };
35
+
10
36
  const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
11
37
 
12
38
  export const validateAppRegistryEntry = (value: unknown): string | null => {
@@ -61,6 +87,9 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
61
87
  if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
62
88
  return invalid("nav.requiresRoles", "an array of strings");
63
89
  }
90
+ if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnNavBadge(value.nav.badge, value.routes))) {
91
+ return invalid("nav.badge", "a same-origin path below one of the entry's routes");
92
+ }
64
93
  }
65
94
  if (value.adminNav !== undefined) {
66
95
  if (
@@ -98,7 +127,7 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
98
127
  !link.label.trim() ||
99
128
  (link.description !== undefined && !isString(link.description)) ||
100
129
  !isString(link.href) ||
101
- !/^\/(?![\/\\])[^\\\s]*$/.test(link.href) ||
130
+ !SAME_ORIGIN_PATH.test(link.href) ||
102
131
  (link.icon !== undefined && !isString(link.icon)) ||
103
132
  (link.keywords !== undefined && !isStringArray(link.keywords)),
104
133
  )
@@ -55,6 +55,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
55
55
  // Registry stores roles as serialized strings; the source type is
56
56
  // Role[] and round-trip is value-preserving.
57
57
  requiresRoles: e.nav.requiresRoles as Role[] | undefined,
58
+ badge: e.nav.badge,
58
59
  }
59
60
  : undefined,
60
61
  help: e.help ? { ...e.help } : undefined,
@@ -1,6 +1,6 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
2
  import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { For, Show } from "solid-js";
3
+ import { createMemo, For, Show } from "solid-js";
4
4
  import { createStore } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
@@ -52,6 +52,13 @@ type PermissionEditorProps = {
52
52
  * account), its row can neither be lowered nor removed. */
53
53
  revokeAccess: (accessId: string) => Promise<void>;
54
54
 
55
+ /** The entries that still apply when the resource lets one entry shadow
56
+ * another, such as a `none` that overrides the same principal's `admin`.
57
+ * The editor counts managers only among them, so pass the same function
58
+ * the service applies before `ensureManagerRemains()`. Defaults to every
59
+ * entry. */
60
+ effectiveEntries?: (entries: readonly AccessEntry[]) => readonly AccessEntry[];
61
+
55
62
  /** Allow granting `public` access from this editor. */
56
63
  allowPublic?: boolean;
57
64
 
@@ -218,10 +225,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
218
225
  });
219
226
  const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
220
227
  // The service refuses to remove the last manager; the row says so up front.
221
- const lastManagerId = () => {
222
- const managers = entries.filter(isManagerEntry);
228
+ const lastManagerId = createMemo(() => {
229
+ const managers = (props.effectiveEntries?.(entries) ?? entries).filter(isManagerEntry);
223
230
  return managers.length === 1 ? managers[0]!.id : null;
224
- };
231
+ });
225
232
 
226
233
  return (
227
234
  <div class="flex flex-col gap-3">
@@ -2,8 +2,9 @@ import type { AccessSubject, RequestActor } from "../server";
2
2
  import { aiModelAccess } from "./model-access";
3
3
  import { personalAiModelPolicy } from "./personal-agent";
4
4
  import { listAiModels, toPublicAiSettingsState } from "./settings";
5
- import { resolveAiAudioModel } from "./transcription";
6
- import type { AiChatTurnRunConfig, AiModelPolicy, AiSettingsError } from "./types";
5
+ import { type AiResolvedAudioModel, AiTranscriptionError, resolveAiAudioModel } from "./transcription";
6
+ import type { AiChatTurnRunConfig, AiDataBoundary, AiModelPolicy, AiSettingsError } from "./types";
7
+ import { isAiSettingsError } from "./validate";
7
8
 
8
9
  export const aiChatAccessSubject = (actor: RequestActor | undefined): AccessSubject | null => {
9
10
  if (!actor) return null;
@@ -20,14 +21,34 @@ export const isAssistantChatTurn = (config: AiChatTurnRunConfig): boolean => con
20
21
  export const listAssistantAiModels = async (subject: AccessSubject | null, policy: AiModelPolicy = personalAiModelPolicy) =>
21
22
  aiModelAccess.filterModels(await listAiModels(policy), subject);
22
23
 
24
+ /** The one audio check behind the composer microphone, dictation, and offering and running transcribe_audio. */
25
+ export const resolveAssistantAudioModel = async (
26
+ subject: AccessSubject | null,
27
+ allowedDataBoundaries?: AiDataBoundary[],
28
+ ): Promise<AiResolvedAudioModel> => {
29
+ const model = await resolveAiAudioModel({ allowedDataBoundaries });
30
+ try {
31
+ await aiModelAccess.assertAllowed(model.profile.id, subject);
32
+ } catch (error) {
33
+ if (!isAiSettingsError(error) || error.aiError.code !== "model_access_denied") throw error;
34
+ throw Object.assign(
35
+ new AiTranscriptionError(
36
+ "transcription_access_denied",
37
+ "Audio transcription is not available to you: you do not have access to the audio model. An administrator can grant access in the AI settings.",
38
+ ),
39
+ { aiError: error.aiError },
40
+ );
41
+ }
42
+ return model;
43
+ };
44
+
23
45
  export const assistantAiSettingsState = async (subject: AccessSubject | null) => {
24
46
  const [status, models, audioModelConfigured] = await Promise.all([
25
47
  toPublicAiSettingsState(personalAiModelPolicy.allowedDataBoundaries),
26
48
  listAssistantAiModels(subject),
27
49
  (async () => {
28
50
  try {
29
- const model = await resolveAiAudioModel({ allowedDataBoundaries: personalAiModelPolicy.allowedDataBoundaries });
30
- await aiModelAccess.assertAllowed(model.profile.id, subject);
51
+ await resolveAssistantAudioModel(subject, personalAiModelPolicy.allowedDataBoundaries);
31
52
  return true;
32
53
  } catch {
33
54
  return false;
@@ -1,10 +1,9 @@
1
1
  import { z } from "zod";
2
- import { aiChatAccessSubject } from "./assistant-models";
2
+ import { aiChatAccessSubject, resolveAssistantAudioModel } from "./assistant-models";
3
3
  import { aiProjectFilePathFromMount } from "./file-mount";
4
4
  import { aiFileStore, normalizeAiFilePath } from "./files-store";
5
- import { aiModelAccess } from "./model-access";
6
5
  import { defineAiTool } from "./tools";
7
- import { type AiResolvedAudioModel, resolveAiAudioModel, runAiTranscription } from "./transcription";
6
+ import { type AiResolvedAudioModel, runAiTranscription } from "./transcription";
8
7
 
9
8
  export const CloudAiTranscribeAudioInputSchema = z.object({
10
9
  path: z.string().trim().min(1).describe("Absolute path of the stored audio file. Project files are below /project."),
@@ -53,13 +52,7 @@ export const createCloudAiTranscribeAudioTool = (options: { resolveModel?: () =>
53
52
  signal: ctx.signal,
54
53
  allowedDataBoundaries: ctx.allowedDataBoundaries,
55
54
  attribution: { conversationId: ctx.conversationId, turnId: ctx.turnId },
56
- resolveModel:
57
- options.resolveModel ??
58
- (async () => {
59
- const resolved = await resolveAiAudioModel({ allowedDataBoundaries: ctx.allowedDataBoundaries });
60
- await aiModelAccess.assertAllowed(resolved.profile.id, aiChatAccessSubject(ctx.actor));
61
- return resolved;
62
- }),
55
+ resolveModel: options.resolveModel ?? (() => resolveAssistantAudioModel(aiChatAccessSubject(ctx.actor), ctx.allowedDataBoundaries)),
63
56
  });
64
57
  ctx.signal.throwIfAborted();
65
58
  const producerCallKey = `${ctx.turnId}:${ctx.callId}`;
@@ -14,6 +14,7 @@ import {
14
14
  import type { CapabilityRegistryEntry } from "../contracts/registry";
15
15
  import type { RequestActor } from "../server";
16
16
  import { createHelpReader, type HelpReaderFactory } from "../services/help";
17
+ import { type MandatePolicyV1, mandatePolicyCanPermitCapability } from "../services/mandates/policy";
17
18
  import { resolveAppIdentityPresentation } from "../shared/app-presentation";
18
19
  import { recordRejectedAiCapability } from "./capability-execution";
19
20
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
@@ -76,7 +77,7 @@ export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
76
77
  * Why `load_tools` could not make a requested name callable:
77
78
  * - `unknown`: no tool has this exact name;
78
79
  * - `not_offered_in_turn`: the tool exists, but this turn's client or task does not provide it;
79
- * - `not_allowed`: the conversation's fixed tool scope excludes it;
80
+ * - `not_allowed`: the conversation's fixed tool scope or task grants exclude it;
80
81
  * - `app_offline`: the app operation is not in the live registry now.
81
82
  */
82
83
  export const AI_TOOL_UNAVAILABLE_REASONS = ["unknown", "not_offered_in_turn", "not_allowed", "app_offline"] as const;
@@ -475,8 +476,8 @@ export const createAiToolMetaTools = (input: {
475
476
  store: ToolStateStore;
476
477
  maxLoadedTools?: number;
477
478
  unavailableLoadedNames?: readonly string[];
478
- /** False when this turn offers no app operations at all. */
479
- appOperationsOffered?: boolean;
479
+ /** Whether this turn offers app operations, offers none, or its fixed scope or task grants exclude all of them. */
480
+ appOperations?: "not_offered" | "not_allowed" | "offered";
480
481
  /** Why a requested name that is not in `catalog` cannot be loaded; defaults to `unknown`. */
481
482
  unavailableReason?: (name: string) => AiToolUnavailableReason;
482
483
  }): AiRuntimeTool[] => {
@@ -494,9 +495,11 @@ export const createAiToolMetaTools = (input: {
494
495
  const liveAppDirectory =
495
496
  directoryEntries.length > 0
496
497
  ? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
497
- : input.appOperationsOffered === false
498
+ : input.appOperations === "not_offered"
498
499
  ? " App operations are not offered in this turn."
499
- : " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
500
+ : input.appOperations === "not_allowed"
501
+ ? " This chat's tool scope or task grants allow no app operations. Do not search for app operations in this turn; continue with the available tools or tell the user which grant is missing."
502
+ : " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
500
503
  const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
501
504
  const unavailableLoadedNotice =
502
505
  unavailableLoadedNames.length > 0
@@ -542,7 +545,7 @@ export const createAiToolMetaTools = (input: {
542
545
  const load = defineAiTool({
543
546
  name: "load_tools",
544
547
  description:
545
- "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope excludes it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
548
+ "Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope or task grants exclude it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
546
549
  inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
547
550
  outputSchema: z
548
551
  .object({
@@ -689,6 +692,8 @@ export const createAiToolResolver =
689
692
  actor: RequestActor;
690
693
  staticTools: AiRuntimeTool[];
691
694
  allowedTools?: readonly string[] | null;
695
+ /** Undefined for interactive runs; null fails closed when a background mandate cannot be loaded. */
696
+ mandatePolicy?: MandatePolicyV1 | null;
692
697
  /** Built-in tools that exist but that this turn does not offer, such as client tools its client did not declare or tools outside `allowedTools`. */
693
698
  unofferedTools?: readonly string[];
694
699
  runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
@@ -725,7 +730,19 @@ export const createAiToolResolver =
725
730
  }
726
731
  const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
727
732
  const fullCapabilityCatalog = buildAiCapabilityCatalog(registry, input.locale);
728
- const capabilityCatalog = fullCapabilityCatalog.filter((entry) => !allowed || allowed.has(entry.name));
733
+ const mandateAllows = (entry: AiCapabilityCatalogEntry) =>
734
+ input.mandatePolicy === undefined ||
735
+ (input.mandatePolicy !== null &&
736
+ mandatePolicyCanPermitCapability(input.mandatePolicy, {
737
+ appId: entry.appId,
738
+ capabilityId: entry.operation.localId,
739
+ kind: entry.kind,
740
+ approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
741
+ }));
742
+ const mandateExcluded = new Set(
743
+ fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
744
+ );
745
+ const capabilityCatalog = fullCapabilityCatalog.filter((entry) => (!allowed || allowed.has(entry.name)) && mandateAllows(entry));
729
746
  const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
730
747
  const resourceTool =
731
748
  input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
@@ -753,24 +770,33 @@ export const createAiToolResolver =
753
770
  // App operation IDs always contain a dot; built-in names never do.
754
771
  const unavailableReason = (requested: string): AiToolUnavailableReason => {
755
772
  const name = loadedByProviderName.get(requested) ?? requested;
773
+ if (mandateExcluded.has(name)) return "not_allowed";
756
774
  if (allowed && outOfScope.has(name) && !allowed.has(name)) return "not_allowed";
757
775
  if (unoffered.has(name)) return "not_offered_in_turn";
758
776
  if (!name.includes(".")) return "unknown";
759
777
  if (!input.listRegistry) return "not_offered_in_turn";
760
778
  return liveRegistry === null || persistedLoadedNames.includes(name) ? "app_offline" : "unknown";
761
779
  };
780
+ // Independent of which apps are live: the fixed scope or task grants name no app operation at all.
781
+ const scopeExcludesAppOperations =
782
+ input.mandatePolicy === null ||
783
+ (input.mandatePolicy?.grants !== undefined && !input.mandatePolicy.grants.some((grant) => "appId" in grant)) ||
784
+ (allowed !== null && ![...allowed].some((name) => name.includes(".")));
762
785
  const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
763
786
  const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
764
787
  const runtimeTools = [
765
788
  ...createAiToolMetaTools({
766
- apps: allowed ? registry.filter((app) => capabilityCatalog.some((entry) => entry.appId === app.appId)) : registry,
789
+ apps:
790
+ allowed || input.mandatePolicy !== undefined
791
+ ? registry.filter((app) => capabilityCatalog.some((entry) => entry.appId === app.appId))
792
+ : registry,
767
793
  catalog,
768
794
  eagerNames,
769
795
  conversationId: input.conversationId,
770
796
  store: input.store,
771
797
  maxLoadedTools: input.maxLoadedTools,
772
798
  unavailableLoadedNames,
773
- appOperationsOffered: Boolean(input.listRegistry),
799
+ appOperations: !input.listRegistry ? "not_offered" : scopeExcludesAppOperations ? "not_allowed" : "offered",
774
800
  unavailableReason,
775
801
  }),
776
802
  ...activeBuiltIns,