@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  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/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. 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.27.0",
3
+ "version": "0.29.0",
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,6 +53,7 @@
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",
56
58
  "./services/ipa/service-account": null,
57
59
  "./services/*": "./src/services/*",
@@ -98,10 +100,10 @@
98
100
  "@simplewebauthn/server": "14.0.3",
99
101
  "@tabler/icons": "3.48.0",
100
102
  "@tailwindcss/typography": "0.5.20",
101
- "@k2b/nessi": "0.15.0",
102
- "@k2b/ssr": "0.14.0",
103
- "@k2b/ui": "0.17.0",
104
- "@k2b/stdlib": "0.27.0",
103
+ "@k2b/nessi": "0.17.0",
104
+ "@k2b/ssr": "0.15.0",
105
+ "@k2b/ui": "0.19.0",
106
+ "@k2b/stdlib": "0.28.0",
105
107
  "@k2b/sync": "7.0.0",
106
108
  "@nats-io/jetstream": "3.4.0",
107
109
  "@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}`;
@@ -10,20 +10,20 @@ export const CODE_RUNTIME_TOOL_NAMES = [
10
10
  "code_run",
11
11
  "code_action",
12
12
  "code_inspect",
13
- "code_interact",
14
13
  "code_stop",
15
14
  "code_open",
16
15
  "code_export",
17
16
  "code_present",
17
+ "code_check",
18
18
  "code_secret",
19
19
  ] as const;
20
20
  export const CodeRunInput = z
21
21
  .object({
22
- id: id.optional(),
22
+ id: id.optional().describe("Saved script resource to run; an app with an index.html interface opens with code_open instead."),
23
23
  resourceId: id
24
24
  .optional()
25
25
  .describe(
26
- "Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source. Test runs use real shared and personal data; writes and capability effects keep normal permissions and approvals.",
26
+ "Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source. Runs use real shared and personal data; writes and capability effects keep normal permissions and approvals.",
27
27
  ),
28
28
  version: z
29
29
  .number()
@@ -36,14 +36,12 @@ export const CodeRunInput = z
36
36
  .min(1)
37
37
  .max(1024 * 1024)
38
38
  .optional()
39
- .describe("One-off JavaScript/TypeScript entry exporting one function. Use code OR a saved resource id; no title or icon needed."),
39
+ .describe("One-off JavaScript/TypeScript script exporting one function. Use code OR a saved resource id; no title or icon needed."),
40
40
  inputPaths: z
41
41
  .array(z.string().min(1).max(500))
42
42
  .max(64)
43
43
  .default([])
44
- .describe(
45
- "Explicit current-chat input files. Scripts can read them; app test runs expose them only through the simulated picker. User apps never receive chat files.",
46
- ),
44
+ .describe("Explicit current-chat input files; the script reads them from its context files. Apps never receive chat files."),
47
45
  })
48
46
  .strict()
49
47
  .refine((input) => Number(input.id !== undefined) + Number(input.code !== undefined) === 1, "Provide exactly one of id or code")
@@ -91,66 +89,38 @@ export const CodeInspectInput = z
91
89
  .max(30000)
92
90
  .default(0)
93
91
  .describe("Wait up to this duration for background work to finish before returning its real state; never restarts work."),
94
- nodeId: z.string().min(1).max(80).optional(),
95
- offset: z.number().int().min(0).default(0),
96
- limit: z.number().int().min(1).max(100).default(20),
97
92
  })
98
93
  .strict();
99
- const CodeInteraction = z
94
+ export const CodeStopInput = z.object({ runId }).strict();
95
+ export const CodeOpenInput = z.object({ id }).strict();
96
+ const AppFilePath = z
97
+ .string()
98
+ .min(1)
99
+ .max(180)
100
+ .regex(/^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/)
101
+ .refine((path) => path.split("/").every((part) => part !== "." && part !== ".."), "Expected a relative path without . or ..");
102
+ export const CodePresentInput = z
100
103
  .object({
101
- id: z.string().min(1).max(80).describe("Control ID or pending modal ID returned by the snapshot."),
102
- event: z
103
- .discriminatedUnion("type", [
104
- z
105
- .object({
106
- type: z.literal("change"),
107
- value: z.union([
108
- z.string(),
109
- z.number().finite(),
110
- z.boolean(),
111
- z.null(),
112
- z.array(z.string()),
113
- z.object({ start: z.iso.date().nullable(), end: z.iso.date().nullable() }).strict(),
114
- ]),
115
- })
116
- .strict(),
117
- z.object({ type: z.literal("select"), key: z.string().nullable() }).strict(),
118
- z.object({ type: z.literal("view"), value: z.enum(["chart", "table"]) }).strict(),
119
- z.object({ type: z.literal("refresh") }).strict(),
120
- z.object({ type: z.literal("request"), request: z.record(z.string(), z.json()) }).strict(),
121
- ])
104
+ id: id
122
105
  .optional()
123
- .describe(
124
- "An event object, never a JSON-encoded string. Omit to activate a button. Example: {type:'change',value:['Sued']} or {type:'view',value:'table'}.",
125
- ),
126
- answer: z.json().optional().describe("Only for the pending modal ID: the modal answer, or null to cancel. Do not use for controls."),
127
- })
128
- .strict();
129
- const validInteraction = (input: z.infer<typeof CodeInteraction>) => input.event === undefined || input.answer === undefined;
130
- export const CodeInteractInput = CodeInteraction.partial({ id: true })
131
- .extend({
132
- runId,
133
- // Three normal 15-second callback budgets share the existing 45-second call budget.
134
- steps: z
135
- .array(CodeInteraction.refine(validInteraction, "Supply event OR answer"))
106
+ .describe("Saved app with an index.html interface; the card runs it live with its data. Omit title to use the app title."),
107
+ files: z
108
+ .array(z.object({ path: AppFilePath, content: z.string().max(1024 * 1024) }).strict())
136
109
  .min(1)
137
- .max(3)
110
+ .max(64)
138
111
  .optional()
139
- .describe(
140
- "Up to three sequential interactions in one call. Use steps OR the top-level id/event/answer. Stops on an error, pending modal or background work; returns completedSteps and nextStep.",
141
- ),
112
+ .describe("One-off app for this chat: index.html plus optional style.css, app.js and other modules. It has no saved data."),
113
+ title: z.string().trim().min(1).max(120).optional().describe("Card title; required with files."),
142
114
  })
143
115
  .strict()
144
- .refine(
145
- (input) =>
146
- input.steps
147
- ? input.id === undefined && input.event === undefined && input.answer === undefined
148
- : input.id !== undefined && (input.event === undefined || input.answer === undefined),
149
- "Supply steps OR one id with event/answer.",
150
- );
151
- export const CodeStopInput = z.object({ runId }).strict();
152
- export const CodeOpenInput = z.object({ id }).strict();
153
- export const CodePresentInput = z.object({ runId, title: z.string().trim().min(1).max(120) }).strict();
116
+ .refine((input) => Number(input.id !== undefined) + Number(input.files !== undefined) === 1, "Provide exactly one of id or files")
117
+ .refine((input) => input.id !== undefined || input.title !== undefined, "A one-off app needs a title")
118
+ .refine((input) => !input.files || input.files.some((file) => file.path === "index.html"), "files must contain index.html");
119
+ export const CodeCheckInput = z
120
+ .object({ id: id.optional(), files: CodePresentInput.shape.files })
121
+ .strict()
122
+ .refine((input) => Number(input.id !== undefined) + Number(input.files !== undefined) === 1, "Provide exactly one of id or files")
123
+ .refine((input) => !input.files || input.files.some((file) => file.path === "index.html"), "files must contain index.html");
154
124
  export const CodeExportInput = z
155
125
  .object({ runId, name: z.string().min(1).max(180).describe("Captured output filename returned by the snapshot.") })
156
126
  .strict();
@@ -179,11 +149,11 @@ export const CodeRuntimeInput = z.discriminatedUnion("operation", [
179
149
  CodeRunInput.safeExtend({ operation: z.literal("run") }),
180
150
  CodeActionInput.safeExtend({ operation: z.literal("action") }),
181
151
  CodeInspectInput.extend({ operation: z.literal("inspect") }),
182
- CodeInteractInput.safeExtend({ operation: z.literal("interact") }),
183
152
  CodeStopInput.extend({ operation: z.literal("stop") }),
184
153
  CodeOpenInput.extend({ operation: z.literal("open") }),
185
154
  CodeSecretInput.extend({ operation: z.literal("secret") }),
186
- CodePresentInput.extend({ operation: z.literal("present") }),
155
+ CodePresentInput.safeExtend({ operation: z.literal("present") }),
156
+ CodeCheckInput.safeExtend({ operation: z.literal("check") }),
187
157
  CodeExportInput.extend({ operation: z.literal("export") }),
188
158
  ]);
189
159
  export type CodeRuntimeInput = z.infer<typeof CodeRuntimeInput>;
@@ -197,12 +167,12 @@ export function parseCodeToolInput(name: string, args: unknown): CodeRuntimeInpu
197
167
  return { operation: "run", ...CodeRunInput.parse(args) };
198
168
  case "code_inspect":
199
169
  return { operation: "inspect", ...CodeInspectInput.parse(args) };
200
- case "code_interact":
201
- return { operation: "interact", ...CodeInteractInput.parse(args) };
202
170
  case "code_stop":
203
171
  return { operation: "stop", ...CodeStopInput.parse(args) };
204
172
  case "code_open":
205
173
  return { operation: "open", ...CodeOpenInput.parse(args) };
174
+ case "code_check":
175
+ return { operation: "check", ...CodeCheckInput.parse(args) };
206
176
  case "code_present":
207
177
  return { operation: "present", ...CodePresentInput.parse(args) };
208
178
  case "code_export":
package/src/ai/browser.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  CODE_RUNTIME_TOOL_NAMES,
3
3
  CodeActionInput,
4
+ CodeCheckInput,
4
5
  CodeResourceId,
5
6
  CodeRuntimeInput,
6
7
  CodeToolFailure,
@@ -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,