@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -3,6 +3,7 @@ import { type RequestOptions as HttpsRequestOptions, request as httpsRequest } f
3
3
  import { z } from "zod";
4
4
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../services/network-security";
5
5
  import { AI_FILES_MAX_FILE_BYTES_DEFAULT, aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
6
+ import { authorizeWebRead } from "./firecrawl-tools";
6
7
  import { defineAiTool } from "./tools";
7
8
 
8
9
  const FETCH_FILE_MAX_REDIRECTS = 5;
@@ -225,9 +226,12 @@ const responseLocation = (headers: IncomingHttpHeaders): string | null => {
225
226
  return typeof value === "string" && value.trim() ? value : null;
226
227
  };
227
228
 
229
+ /** How a download that a website approval let through may proceed: only on that origin, each request shown first. */
230
+ type WebsiteApprovedDownload = { origin: string; onRequest: (url: URL, hop: number) => Promise<void> };
231
+
228
232
  export const downloadPublicFile = async (
229
233
  rawUrl: string,
230
- options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal } = {},
234
+ options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload } = {},
231
235
  ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> => {
232
236
  const resolve = options.resolve ?? resolvePublicNetworkAddresses;
233
237
  const perform =
@@ -238,6 +242,14 @@ export const downloadPublicFile = async (
238
242
  let url = publicHttpsUrl(rawUrl);
239
243
 
240
244
  for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
245
+ if (options.approvedWebsite) {
246
+ // A website approval covers its own origin only; an open redirect must not carry the address to another one.
247
+ if (url.origin !== options.approvedWebsite.origin)
248
+ throw new Error(
249
+ `Redirect to another website — The file moved to ${url.toString()}, which this chat has not allowed. Download that address directly so the person can decide.`,
250
+ );
251
+ await options.approvedWebsite.onRequest(url, redirects);
252
+ }
241
253
  const addresses = await resolveFileAddresses(resolve, url.hostname);
242
254
  const response = await perform(url, addresses, { maxBytes, signal: options.signal });
243
255
  if (response.statusCode >= 300 && response.statusCode < 400) {
@@ -280,11 +292,15 @@ const responseMediaType = (headers: IncomingHttpHeaders, path: string): string =
280
292
 
281
293
  export const runCloudAiFetchFile = async (
282
294
  input: z.infer<typeof CloudAiFetchFileInputSchema>,
283
- context: { conversationId?: string; signal?: AbortSignal },
295
+ context: { conversationId?: string; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload },
284
296
  dependencies: FetchFileDependencies = {},
285
297
  ): Promise<z.infer<typeof CloudAiFetchFileOutputSchema>> => {
286
298
  if (!context.conversationId) throw new Error("The fetch_file tool needs a conversation context.");
287
- const downloaded = await downloadPublicFile(input.url, { ...dependencies, signal: context.signal });
299
+ const downloaded = await downloadPublicFile(input.url, {
300
+ ...dependencies,
301
+ signal: context.signal,
302
+ approvedWebsite: context.approvedWebsite,
303
+ });
288
304
  const path = normalizeAiFilePath(`/imports/${safeFilename(input.filename, downloaded.url)}`);
289
305
  if (!path) throw new Error("Could not derive a valid conversation file path.");
290
306
  const stat = await aiFileStore.createAssistantFile({
@@ -307,7 +323,28 @@ export const createCloudAiFetchFileTool = (dependencies: FetchFileDependencies =
307
323
  timeoutMs: 90_000,
308
324
  promptHint:
309
325
  "fetch an exact public HTTPS source file into the chat when the user provides a file link; inspect it with read_file or view_image and present it when useful.",
310
- }).server((input, context) => runCloudAiFetchFile(input, context, dependencies));
326
+ }).server(async (input, context) => {
327
+ const url = publicHttpsUrl(input.url);
328
+ const allowedForChat = await authorizeWebRead("file", url.toString(), context);
329
+ return runCloudAiFetchFile(
330
+ input,
331
+ {
332
+ ...context,
333
+ ...(allowedForChat
334
+ ? {
335
+ approvedWebsite: {
336
+ origin: url.origin,
337
+ // The first request's receipt is already shown; each redirect on the same website adds one.
338
+ onRequest: async (next: URL, hop: number) => {
339
+ if (hop > 0) await context.reportWebsiteReceipts?.([{ index: hop, method: "GET", url: next.toString() }]);
340
+ },
341
+ },
342
+ }
343
+ : {}),
344
+ },
345
+ dependencies,
346
+ );
347
+ });
311
348
 
312
349
  export type CloudAiFetchFileInput = z.infer<typeof CloudAiFetchFileInputSchema>;
313
350
  export type CloudAiFetchFileOutput = z.infer<typeof CloudAiFetchFileOutputSchema>;
@@ -0,0 +1,2 @@
1
+ export const aiMissingChatFileError = (path: string, localToolsEnabled?: boolean, message = `No such file: ${path}`): Error =>
2
+ new Error(`${message}${localToolsEnabled ? " This is a chat path. Upload local files with local_upload first." : ""}`);
@@ -6,6 +6,7 @@ import {
6
6
  } from "@k2b/cloud/services/document-extraction";
7
7
  import { z } from "zod";
8
8
  import type { RequestActor } from "../server";
9
+ import { aiMissingChatFileError } from "./file-error";
9
10
  import {
10
11
  AI_PROJECT_FILE_MOUNT,
11
12
  AI_SKILL_FILE_MOUNT,
@@ -14,8 +15,9 @@ import {
14
15
  mountAiProjectFilePath,
15
16
  mountAiSkillFilePath,
16
17
  } from "./file-mount";
17
- import { aiFileStore, guessAiMediaType, isAiWorkingFilePath, normalizeAiFilePath } from "./files-store";
18
+ import { aiFileStore, assertAiModelWritablePath, guessAiMediaType, isAiWorkingFilePath, normalizeAiFilePath } from "./files-store";
18
19
  import { defineAiTool } from "./tools";
20
+ import { CLOUD_AI_VIEW_MAX_BYTES, CloudAiViewSummarySchema, cloudAiViewSummary, isAiViewFile, parseCloudAiView } from "./view-block";
19
21
 
20
22
  const FILE_READ_MAX_BYTES = 64 * 1024;
21
23
  const FILE_WRITE_MAX_BYTES = 100 * 1024;
@@ -234,7 +236,7 @@ export const createCloudAiReadFileTool = () => {
234
236
  length: input.length,
235
237
  })
236
238
  : null);
237
- if (!stored) throw new Error(`No such file: ${path}`);
239
+ if (!stored) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
238
240
  const extractAsDocument = shouldExtractDocument(path, stored.mediaType);
239
241
  if (isTextMediaType(stored.mediaType) && !extractAsDocument) {
240
242
  if (input.offset > stored.size) throw new Error(`Offset ${input.offset} is past the end of ${path} (${stored.size} bytes).`);
@@ -271,7 +273,7 @@ export const createCloudAiReadFileTool = () => {
271
273
  ? await aiFileStore.readTurnFile({ turnId: ctx.turnId, path })
272
274
  : await aiFileStore.read({ conversationId: conversationId(ctx.conversationId, "read_file"), path });
273
275
  if (!fullFile) {
274
- throw new Error(snapshot ? `Attached file snapshot is unavailable: ${path}` : `No such file: ${path}`);
276
+ throw snapshot ? new Error(`Attached file snapshot is unavailable: ${path}`) : aiMissingChatFileError(path, ctx.localToolsEnabled);
275
277
  }
276
278
  try {
277
279
  extracted = await extractDocumentMarkdown({ bytes: fullFile.bytes, filename: path, signal: ctx.signal });
@@ -328,6 +330,7 @@ export const createCloudAiWriteFileTool = () =>
328
330
  const id = conversationId(ctx.conversationId, "write_file");
329
331
  const path = toolPath(input.path, "write");
330
332
  assertConversationFilePath(path, "written");
333
+ assertAiModelWritablePath(path);
331
334
  const existing = await aiFileStore.stat({ conversationId: id, path });
332
335
  if (existing?.origin === "user") throw new Error(`Cannot overwrite user-uploaded file ${path}. Choose another path.`);
333
336
  const bytes = new TextEncoder().encode(input.content);
@@ -356,13 +359,29 @@ export const CloudAiPresentInputSchema = z.object({
356
359
  "One sentence in the user's language that says what the file is and what it is for, without repeating its name. Shown next to the file in the chat's results.",
357
360
  ),
358
361
  });
359
- export const CloudAiPresentOutputSchema = z.object({ path: z.string(), size: z.number(), mediaType: z.string() });
362
+ export const CloudAiPresentOutputSchema = z.object({
363
+ path: z.string(),
364
+ size: z.number(),
365
+ mediaType: z.string(),
366
+ view: CloudAiViewSummarySchema.optional(),
367
+ });
368
+
369
+ /** A view file as it is now; invalid user edits leave an ordinary file. */
370
+ const readView = async (conversationId: string, path: string) => {
371
+ const file = await aiFileStore.read({ conversationId, path });
372
+ if (!file || !isAiViewFile(file) || file.bytes.byteLength > CLOUD_AI_VIEW_MAX_BYTES) return undefined;
373
+ try {
374
+ return parseCloudAiView(JSON.parse(new TextDecoder().decode(file.bytes)));
375
+ } catch {
376
+ return undefined;
377
+ }
378
+ };
360
379
 
361
380
  export const createCloudAiPresentTool = () =>
362
381
  defineAiTool({
363
382
  name: "present",
364
383
  description:
365
- "Present a conversation file to the user as an openable and downloadable chat attachment. The returned path is a conversation-local file identifier, not a website URL. Refer to the delivered attachment by title; do not turn its path into a Markdown link.",
384
+ "Present a conversation file to the user as an openable and downloadable chat attachment. The returned path is a conversation-local file identifier, not a website URL. Refer to the delivered attachment by title; do not turn its path into a Markdown link. A view file from cloud.view.* (/views/…view.json) shows inline as its chart, group, table, stats or details view, so present every view the person should see.",
366
385
  inputSchema: CloudAiPresentInputSchema,
367
386
  outputSchema: CloudAiPresentOutputSchema,
368
387
  approval: "never",
@@ -372,8 +391,9 @@ export const createCloudAiPresentTool = () =>
372
391
  const path = toolPath(input.path, "read");
373
392
  assertConversationFilePath(path, "presented");
374
393
  const stat = await aiFileStore.stat({ conversationId: id, path });
375
- if (!stat) throw new Error(`No such file: ${path}`);
376
- return { path: stat.path, size: stat.size, mediaType: stat.mediaType };
394
+ if (!stat) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
395
+ const view = isAiViewFile(stat) && stat.size <= CLOUD_AI_VIEW_MAX_BYTES ? await readView(id, path) : undefined;
396
+ return { path: stat.path, size: stat.size, mediaType: stat.mediaType, ...(view ? { view: cloudAiViewSummary(view) } : {}) };
377
397
  });
378
398
 
379
399
  type MathToken = { kind: "number"; value: number } | { kind: "ident"; name: string } | { kind: "op"; op: string };
@@ -2,6 +2,14 @@ import { type SQL, sql } from "bun";
2
2
  import { stripImageMetadata } from "../services/image-metadata";
3
3
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
4
4
  import { isAiImage } from "./file-media-type";
5
+ import {
6
+ CLOUD_AI_VIEW_MAX_BYTES,
7
+ CLOUD_AI_VIEW_SUFFIX,
8
+ type CloudAiViewSummary,
9
+ checkCloudAiView,
10
+ cloudAiViewSummary,
11
+ isAiViewPath,
12
+ } from "./view-block";
5
13
 
6
14
  export { guessAiMediaType } from "./file-media-type";
7
15
 
@@ -697,6 +705,7 @@ export async function createAiConversationArtifact(input: {
697
705
  const { aiConversations } = await import("./store");
698
706
  const conversation = await aiConversations.getConversation({ conversationId: input.conversationId, ownerUserId: input.ownerUserId });
699
707
  if (!conversation || conversation.archivedAt) throw new Error("Conversation access denied");
708
+ assertAiModelWritablePath(input.path);
700
709
  return aiFileStore.createToolArtifact(input);
701
710
  }
702
711
 
@@ -710,6 +719,57 @@ export async function writeAiConversationFile(input: {
710
719
  expectedVersion: string | null;
711
720
  }): Promise<AiFileStat> {
712
721
  if (normalizeAiFilePath(input.path) !== input.path) throw new Error("Invalid file path");
722
+ assertAiModelWritablePath(input.path);
713
723
  const current = await aiFileStore.stat(input);
714
724
  return aiFileStore.write({ ...input, origin: current?.origin ?? "assistant", allowUserOverwrite: current?.origin === "user" });
715
725
  }
726
+
727
+ export function assertAiModelWritablePath(path: string): void {
728
+ if (isAiViewPath(normalizeAiFilePath(path) ?? path))
729
+ throw new Error("The /views folder holds views that code runs create with cloud.view.*; choose another path.");
730
+ }
731
+ export class AiViewInvalidError extends Error {
732
+ constructor(readonly issues: string[]) {
733
+ super(`View rejected: ${issues.join("; ")}`);
734
+ this.name = "AiViewInvalidError";
735
+ }
736
+ }
737
+
738
+ /** Run-produced views use the same ownership, budget and retry rules as other chat artifacts. */
739
+ export async function createAiConversationView(input: {
740
+ conversationId: string;
741
+ ownerUserId: string;
742
+ turnId: string;
743
+ runId: string;
744
+ index: number;
745
+ view: unknown;
746
+ }): Promise<{ path: string } & CloudAiViewSummary> {
747
+ const { aiConversations } = await import("./store");
748
+ const conversation = await aiConversations.getConversation({ conversationId: input.conversationId, ownerUserId: input.ownerUserId });
749
+ if (!conversation || conversation.archivedAt) throw new Error("Conversation access denied");
750
+ const checked = checkCloudAiView(input.view);
751
+ if ("issues" in checked) throw new AiViewInvalidError(checked.issues);
752
+ const bytes = new TextEncoder().encode(JSON.stringify(checked.view));
753
+ if (bytes.byteLength > CLOUD_AI_VIEW_MAX_BYTES)
754
+ throw new AiViewInvalidError([`view: At most ${CLOUD_AI_VIEW_MAX_BYTES} bytes are allowed.`]);
755
+ const slug =
756
+ checked.view.title
757
+ .normalize("NFKD")
758
+ .replace(/\p{M}/gu, "")
759
+ .toLowerCase()
760
+ .replace(/[^a-z0-9]+/g, "-")
761
+ .replace(/^-+|-+$/g, "")
762
+ .slice(0, 48)
763
+ .replace(/-+$/g, "") || "view";
764
+ const key = `${input.turnId}:${input.runId}:${input.index}`;
765
+ const digest = new Bun.CryptoHasher("sha256").update(key).digest("hex").slice(0, 8);
766
+ const path = `/views/${slug}-${digest}${CLOUD_AI_VIEW_SUFFIX}`;
767
+ await aiFileStore.createToolArtifact({
768
+ conversationId: input.conversationId,
769
+ path,
770
+ bytes,
771
+ mediaType: "application/json",
772
+ producerCallKey: `view:${key}`,
773
+ });
774
+ return { path, ...cloudAiViewSummary(checked.view) };
775
+ }
@@ -1,8 +1,13 @@
1
1
  import { isIP } from "node:net";
2
- import { truncateMiddle } from "@k2b/nessi";
2
+ import { type ToolContext, truncateMiddle } from "@k2b/nessi";
3
3
  import { z } from "zod";
4
+ import type { RequestActor } from "../server";
4
5
  import { coreSettings } from "../services";
6
+ import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
7
+ import { aiConversations } from "./store";
5
8
  import { defineAiTool } from "./tools";
9
+ import type { AiApprovalTarget, AiWebsiteReceipt } from "./types";
10
+ import { hasWebAddressProvenance, recordWebAddresses, webAddressesIn } from "./web-provenance";
6
11
 
7
12
  export const AI_FIRECRAWL_API_KEY_SETTING_KEY = "ai.firecrawl_api_key";
8
13
 
@@ -274,13 +279,70 @@ export const createCloudAiWebSearchTool = (config: FirecrawlToolConfig = {}) =>
274
279
  ...result,
275
280
  snippet: truncateSnippet(result.snippet),
276
281
  })),
277
- }).server(async (input, ctx) => runCloudAiWebSearch(input, { ...config, signal: ctx.signal }));
282
+ }).server(async (input, ctx) => {
283
+ const results = await runCloudAiWebSearch(input, { ...config, signal: ctx.signal });
284
+ if (ctx.conversationId)
285
+ await recordWebAddresses(
286
+ ctx.conversationId,
287
+ results.flatMap((result) => [...webAddressesIn(result.url)]),
288
+ );
289
+ return results;
290
+ });
291
+
292
+ const webReadApprovalMessage = (kind: "page" | "file", url: string, locale: string | undefined) =>
293
+ locale?.startsWith("de")
294
+ ? [
295
+ `${kind === "page" ? "Webseite lesen" : "Datei laden"}: ${url}`,
296
+ "Diese Adresse steht nicht in deiner Nachricht, in einem Suchergebnis oder als Link einer gelesenen Seite.",
297
+ "Die vollständige Adresse geht an die Website.",
298
+ ].join("\n")
299
+ : [
300
+ `${kind === "page" ? "Read web page" : "Download file"}: ${url}`,
301
+ "This address is not in your message, in a search result, or linked from a page read earlier.",
302
+ "The full address goes to the website.",
303
+ ].join("\n");
304
+
305
+ export type WebReadContext = Pick<ToolContext, "requestApproval"> & {
306
+ actor: RequestActor;
307
+ conversationId?: string;
308
+ turnId?: string;
309
+ locale?: string;
310
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
311
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
312
+ };
313
+
314
+ /**
315
+ * Lets a public web read through when the chat supplied its address. A read sends its full address to the website,
316
+ * and an address the chat did not supply may carry private data the model put into it, so any other address asks
317
+ * like an HTTP request from code: with the full URL, and with the website approval of a signed-in chat. Returns
318
+ * whether such an approval let it through without asking; the chat then shows the receipt before the read goes out.
319
+ * Throws when the person declines.
320
+ */
321
+ export const authorizeWebRead = async (kind: "page" | "file", url: string, ctx: WebReadContext): Promise<boolean> => {
322
+ if (ctx.conversationId && (await hasWebAddressProvenance(ctx.conversationId, url))) return false;
323
+ const target = { toolName: AI_WEBSITE_APPROVAL_TOOL, approvalScope: aiWebsiteApprovalScope(new URL(url).origin), always: false };
324
+ const runConfig = ctx.conversationId
325
+ ? await aiConversations.getTurnRunConfig({ conversationId: ctx.conversationId, turnId: ctx.turnId ?? "" }).catch(() => null)
326
+ : null;
327
+ const actorUserId = ctx.actor.kind === "user" ? ctx.actor.user.id : (ctx.actor.delegatedUser?.id ?? "");
328
+ if (
329
+ aiTurnAllowsWebsiteApprovals(runConfig) &&
330
+ (await findRememberedAiToolApproval({ actorUserId }, { ...target, conversationId: ctx.conversationId, chatOnly: true })) !== null
331
+ ) {
332
+ await ctx.reportWebsiteReceipts?.([{ index: 0, method: "GET", url }]);
333
+ return true;
334
+ }
335
+ const message = webReadApprovalMessage(kind, url, ctx.locale);
336
+ const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target) : await ctx.requestApproval(message);
337
+ if (!approved) throw new Error("The person did not allow reading this address. Use an address from their message or a search result.");
338
+ return false;
339
+ };
278
340
 
279
341
  export const createCloudAiWebExtractTool = (config: FirecrawlToolConfig = {}) =>
280
342
  defineAiTool({
281
343
  name: "web_extract",
282
344
  description:
283
- "Read one web page by URL and return clean Markdown. Inspect the relevant pages needed to support the answer; for research or comparison, prefer primary sources and read more than one useful source when warranted.",
345
+ "Read one web page by URL and return clean Markdown. Inspect the relevant pages needed to support the answer; for research or comparison, prefer primary sources and read more than one useful source when warranted. Use addresses exactly as the user wrote them, as web_search returned them, or as a page you already read links them; any other address asks the user first.",
284
346
  inputSchema: CloudAiWebExtractInputSchema,
285
347
  outputSchema: CloudAiWebExtractOutputSchema,
286
348
  approval: "never",
@@ -297,7 +359,28 @@ export const createCloudAiWebExtractTool = (config: FirecrawlToolConfig = {}) =>
297
359
  truncated: output.truncated || content !== output.content,
298
360
  };
299
361
  },
300
- }).server(async (input, ctx) => runCloudAiWebExtract(input, { ...config, signal: ctx.signal }));
362
+ }).server(async (input, ctx) => {
363
+ const url = assertPublicHttpUrl(input.url);
364
+ const allowedForChat = await authorizeWebRead("page", url, ctx);
365
+ const output = await runCloudAiWebExtract({ url }, { ...config, signal: ctx.signal });
366
+ const final = URL.canParse(output.url) ? new URL(output.url) : null;
367
+ if (allowedForChat && final && final.href !== url) {
368
+ // Firecrawl follows redirects on its side. The chat shows where the read ended, and a website approval covers
369
+ // only content from its own origin.
370
+ await ctx.reportWebsiteReceipts?.([{ index: 1, method: "GET", url: final.href }]);
371
+ if (final.origin !== new URL(url).origin)
372
+ throw new Error(
373
+ `The page redirected to ${final.href}, which this chat has not allowed. Read that address directly so the person can decide.`,
374
+ );
375
+ }
376
+ // The page and every address it links to may be read again without asking.
377
+ if (ctx.conversationId)
378
+ await recordWebAddresses(
379
+ ctx.conversationId,
380
+ new Set([...webAddressesIn(url), ...webAddressesIn(output.url), ...webAddressesIn(output.content)]),
381
+ );
382
+ return output;
383
+ });
301
384
 
302
385
  export type CloudAiWebSearchInput = z.infer<typeof CloudAiWebSearchInputSchema>;
303
386
  export type CloudAiWebSearchOutput = z.infer<typeof CloudAiWebSearchOutputSchema>;
@@ -8,6 +8,7 @@ import {
8
8
  type RenderHtmlToPdfResult,
9
9
  renderHtmlToPdfWithConfig,
10
10
  } from "../services/pdf";
11
+ import { aiMissingChatFileError } from "./file-error";
11
12
  import { aiProjectFilePathFromMount, aiSkillFilePathFromMount } from "./file-mount";
12
13
  import { type AiFileContent, type AiFileStat, aiFileStore, normalizeAiFilePath } from "./files-store";
13
14
  import { withAiPdfConversionSlot } from "./pdf-conversions";
@@ -122,7 +123,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
122
123
  for (const path of [sourcePath, cssPath, headerPath, footerPath, ...assetPaths]) {
123
124
  if (path === undefined) continue;
124
125
  const file = await stat({ conversationId, path });
125
- if (!file) throw new Error(`No such file: ${path}`);
126
+ if (!file) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
126
127
  if (path === sourcePath && file.origin !== "assistant") {
127
128
  throw new Error(`Create an assistant-owned HTML file with write_file before converting ${sourcePath}.`);
128
129
  }
@@ -134,7 +135,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
134
135
 
135
136
  const load = async (path: string): Promise<AiFileContent> => {
136
137
  const file = await read({ conversationId, path });
137
- if (!file) throw new Error(`No such file: ${path}`);
138
+ if (!file) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
138
139
  return file;
139
140
  };
140
141
  const text = async (path: string | undefined): Promise<string | undefined> => (path === undefined ? undefined : utf8(await load(path)));
package/src/ai/http.ts CHANGED
@@ -6,6 +6,7 @@ import { type AuthContext, err, fail, respond } from "../server";
6
6
  import { aiAttachmentMarker } from "./attachments";
7
7
  import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
8
8
  import { AI_TURN_ATTACHMENT_MAX_ITEMS } from "./limits";
9
+ import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema, AiLocalContextSchema } from "./local-tool-contracts";
9
10
  import { AiQuotaError } from "./quotas";
10
11
  import { AiResourceMarkerSchema } from "./resource-markers";
11
12
  import { AI_SHORT_ID_PATTERN } from "./short-id";
@@ -120,7 +121,10 @@ export const AiUserContentPartSchema = z.union([
120
121
 
121
122
  export type AiTurnContentPart = z.infer<typeof AiUserContentPartSchema>;
122
123
 
123
- export const AiClientToolIdSchema = z.enum(["local_bash", ...CODE_RUNTIME_TOOL_NAMES] satisfies [AiClientToolId, ...AiClientToolId[]]);
124
+ export const AiClientToolIdSchema = z.enum(["local_bash", ...AI_LOCAL_TOOL_NAMES, ...CODE_RUNTIME_TOOL_NAMES] satisfies [
125
+ AiClientToolId,
126
+ ...AiClientToolId[],
127
+ ]);
124
128
 
125
129
  // Names use aiCapabilityToolName's provider grammar and 64-character limit. The 64-item cap matches allowedTools, with headroom
126
130
  // for retired and not-yet-known names; 64 names × 64 ASCII characters bounds advertised name text to 4 KiB.
@@ -157,12 +161,21 @@ export const AiTurnInputSchema = z
157
161
 
158
162
  export type AiTurnInput = z.infer<typeof AiTurnInputSchema>;
159
163
 
160
- export const AiSubmitConversationDraftInputSchema = z.object({
161
- queueId: z.uuid().optional(),
162
- draftRevision: z.number().int().min(1),
163
- modelProfileId: z.string().trim().min(1).optional(),
164
- clientToolIds: AiClientToolIdsSchema.optional(),
165
- });
164
+ export const AiSubmitConversationDraftInputSchema = z
165
+ .object({
166
+ queueId: z.uuid().optional(),
167
+ draftRevision: z.number().int().min(1),
168
+ modelProfileId: z.string().trim().min(1).optional(),
169
+ clientToolIds: AiClientToolIdsSchema.optional(),
170
+ /** Set by a local client such as cld; the web never sends it. */
171
+ client: AiLocalClientSchema.optional(),
172
+ /** AGENTS.md files and local Skills a local client found; only valid together with client. */
173
+ localContext: AiLocalContextSchema.optional(),
174
+ })
175
+ .refine((input) => !input.localContext || Boolean(input.client), {
176
+ message: "Local context requires a local client.",
177
+ path: ["localContext"],
178
+ });
166
179
 
167
180
  export const AiSteerInputSchema = z.object({
168
181
  message: z.string().trim().min(1).max(20000),
@@ -187,6 +200,13 @@ export const AiMessageRetryInputSchema = z
187
200
  .max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
188
201
  .optional(),
189
202
  modelProfileId: z.string().trim().min(1).optional(),
203
+ clientToolIds: AiClientToolIdsSchema.optional(),
204
+ client: AiLocalClientSchema.optional(),
205
+ localContext: AiLocalContextSchema.omit({ selectedSkills: true }).optional(),
206
+ })
207
+ .refine((input) => !input.localContext || Boolean(input.client), {
208
+ message: "Local context requires a local client.",
209
+ path: ["localContext"],
190
210
  })
191
211
  .refine(
192
212
  (input) =>
@@ -272,13 +292,18 @@ export const toAiErrorResponse = (c: Context<AuthContext>, error: unknown) => {
272
292
  return respond(c, fail(err.internal(message)));
273
293
  };
274
294
 
275
- export const toAiActionFailureResponse = (c: Context<AuthContext>, result: { ok: false; status: 400 | 404 | 409; message: string }) => {
295
+ export const toAiActionFailureResponse = (
296
+ c: Context<AuthContext>,
297
+ result: { ok: false; status: 400 | 403 | 404 | 409; message: string },
298
+ ) => {
276
299
  const failure =
277
300
  result.status === 400
278
301
  ? err.badInput(result.message)
279
- : result.status === 404
280
- ? // err.notFound appends " not found" to its subject — the runtime message is already complete.
281
- { code: "NOT_FOUND" as const, message: result.message, status: 404 as const }
282
- : conflict(result.message);
302
+ : result.status === 403
303
+ ? err.forbidden(result.message)
304
+ : result.status === 404
305
+ ? // err.notFound appends " not found" to its subject — the runtime message is already complete.
306
+ { code: "NOT_FOUND" as const, message: result.message, status: 404 as const }
307
+ : conflict(result.message);
283
308
  return respond(c, fail(failure));
284
309
  };
package/src/ai/index.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileAppSkills } from "./app-skill-store";
2
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
3
  export {
4
+ AI_WEBSITE_APPROVAL_TOOL,
4
5
  type AiToolApprovalContext,
5
6
  type AiToolApprovalPreference,
7
+ type AiToolApprovalRemember,
6
8
  aiToolAllowsAlways,
7
9
  aiToolApprovalScope,
8
10
  aiToolNeedsApproval,
11
+ aiTurnAllowsWebsiteApprovals,
12
+ aiWebsiteApprovalScope,
13
+ findRememberedAiToolApproval,
9
14
  forgetAiToolApproval,
10
15
  hasRememberedAiToolApproval,
11
16
  listAiToolApprovalPreferences,
17
+ parseAiWebsiteApprovalScope,
12
18
  rememberAiToolApproval,
13
19
  revokeAiToolApprovalPreference,
14
20
  } from "./approvals";
@@ -50,7 +56,9 @@ export { listAiCredentialProfileIds } from "./credentials";
50
56
  export {
51
57
  CLOUD_AI_CHART_MAX_SERIES,
52
58
  CLOUD_AI_CHART_MAX_VALUES,
59
+ CLOUD_AI_GROUP_MAX_CHARTS,
53
60
  CLOUD_AI_TEXT_EDITOR_MAX_CHARS,
61
+ CLOUD_AI_VIEW_MAX_MARKS,
54
62
  type CloudAiCardInput,
55
63
  CloudAiCardInputSchema,
56
64
  type CloudAiCardOutput,
@@ -59,10 +67,13 @@ export {
59
67
  CloudAiChartInputSchema,
60
68
  type CloudAiChartOutput,
61
69
  CloudAiChartOutputSchema,
70
+ type CloudAiDateFormat,
71
+ type CloudAiFormat,
62
72
  type CloudAiLocalBashInput,
63
73
  CloudAiLocalBashInputSchema,
64
74
  type CloudAiLocalBashOutput,
65
75
  CloudAiLocalBashOutputSchema,
76
+ type CloudAiNumberFormat,
66
77
  type CloudAiSurveyInput,
67
78
  CloudAiSurveyInputSchema,
68
79
  type CloudAiSurveyOutput,
@@ -71,6 +82,10 @@ export {
71
82
  CloudAiTextEditorInputSchema,
72
83
  type CloudAiTextEditorOutput,
73
84
  CloudAiTextEditorOutputSchema,
85
+ cloudAiAppChartSchema,
86
+ cloudAiChartSchema,
87
+ cloudAiFormatSchema,
88
+ cloudAiNumberFormatSchema,
74
89
  parseCloudAiChartInput,
75
90
  } from "./default-tool-contracts";
76
91
  export {
@@ -93,7 +108,9 @@ export {
93
108
  type AiConversationFileOverview,
94
109
  type AiConversationWorkingGroup,
95
110
  type AiFileStat,
111
+ AiViewInvalidError,
96
112
  createAiConversationArtifact,
113
+ createAiConversationView,
97
114
  guessAiMediaType,
98
115
  isAiWorkingFilePath,
99
116
  listAiConversationFiles,
@@ -148,6 +165,50 @@ export {
148
165
  AiInvalidationDomainSchema,
149
166
  AiInvalidationSchema,
150
167
  } from "./live-events";
168
+ export {
169
+ AI_LOCAL_BATCH_MAX_ITEMS,
170
+ AI_LOCAL_INSTRUCTIONS_MAX_BYTES,
171
+ AI_LOCAL_LIST_MAX_LIMIT,
172
+ AI_LOCAL_READ_MAX_BYTES,
173
+ AI_LOCAL_SELECTED_SKILLS_MAX_ITEMS,
174
+ AI_LOCAL_SKILL_FILES_MAX_ITEMS,
175
+ AI_LOCAL_SKILLS_MAX_ITEMS,
176
+ AI_LOCAL_TOOL_NAMES,
177
+ AI_LOCAL_WRITE_MAX_BYTES,
178
+ type AiLocalClient,
179
+ AiLocalClientSchema,
180
+ type AiLocalContext,
181
+ AiLocalContextSchema,
182
+ type AiLocalToolName,
183
+ type CloudAiLoadLocalSkillInput,
184
+ CloudAiLoadLocalSkillInputSchema,
185
+ type CloudAiLoadLocalSkillOutput,
186
+ CloudAiLoadLocalSkillOutputSchema,
187
+ type CloudAiLocalDownloadInput,
188
+ CloudAiLocalDownloadInputSchema,
189
+ type CloudAiLocalDownloadOutput,
190
+ CloudAiLocalDownloadOutputSchema,
191
+ type CloudAiLocalListInput,
192
+ CloudAiLocalListInputSchema,
193
+ type CloudAiLocalListOutput,
194
+ CloudAiLocalListOutputSchema,
195
+ type CloudAiLocalMoveInput,
196
+ CloudAiLocalMoveInputSchema,
197
+ type CloudAiLocalMoveOutput,
198
+ CloudAiLocalMoveOutputSchema,
199
+ type CloudAiLocalReadInput,
200
+ CloudAiLocalReadInputSchema,
201
+ type CloudAiLocalReadOutput,
202
+ CloudAiLocalReadOutputSchema,
203
+ type CloudAiLocalUploadInput,
204
+ CloudAiLocalUploadInputSchema,
205
+ type CloudAiLocalUploadOutput,
206
+ CloudAiLocalUploadOutputSchema,
207
+ type CloudAiLocalWriteInput,
208
+ CloudAiLocalWriteInputSchema,
209
+ type CloudAiLocalWriteOutput,
210
+ CloudAiLocalWriteOutputSchema,
211
+ } from "./local-tool-contracts";
151
212
  export { AI_ENRICH_CRON_SETTING_KEY, AI_MEMORY_LEARNING_CRON_SETTING_KEY, aiMaintenanceJobs } from "./maintenance";
152
213
  export {
153
214
  AI_MEMORY_CONTENT_MAX_CHARS,
@@ -251,6 +312,8 @@ export {
251
312
  type AiSkillDocument,
252
313
  type AiSkillExtraFrontmatter,
253
314
  type AiSkillReferenceInput,
315
+ isAiSkillManualOnly,
316
+ mountAiSkillInstructionLinks,
254
317
  parseAiSkillMarkdown,
255
318
  serializeAiSkillMarkdown,
256
319
  validateAiSkillDescription,
@@ -309,6 +372,7 @@ export {
309
372
  } from "./transcription";
310
373
  export type {
311
374
  AiAccessResult,
375
+ AiApprovalTarget,
312
376
  AiCapabilityToolPresentation,
313
377
  AiClientToolId,
314
378
  AiConversation,
@@ -360,9 +424,43 @@ export type {
360
424
  AiTurnStatus,
361
425
  AiTurnToolSource,
362
426
  AiUserContentPart,
427
+ AiWebsiteReceipt,
363
428
  } from "./types";
364
429
  export { AI_MESSAGE_FEEDBACK_REASONS, isAiImageMediaType } from "./types";
365
430
  export { isAiSettingsError } from "./validate";
431
+ export {
432
+ CLOUD_AI_STATS_MAX_ITEMS,
433
+ CLOUD_AI_TABLE_MAX_COLUMNS,
434
+ CLOUD_AI_TABLE_MAX_ROWS,
435
+ CLOUD_AI_VIEW_DIRECTORY,
436
+ CLOUD_AI_VIEW_MAX_BYTES,
437
+ CLOUD_AI_VIEW_SUFFIX,
438
+ type CloudAiChartView,
439
+ type CloudAiDetailsView,
440
+ type CloudAiExplorerRequest,
441
+ type CloudAiExplorerView,
442
+ type CloudAiGroupView,
443
+ type CloudAiStatsView,
444
+ type CloudAiTableView,
445
+ type CloudAiView,
446
+ type CloudAiViewReference,
447
+ type CloudAiViewSummary,
448
+ CloudAiViewSummarySchema,
449
+ type CloudAiViewTable,
450
+ checkCloudAiView,
451
+ cloudAiDetailsRows,
452
+ cloudAiExplorerControls,
453
+ cloudAiExplorerState,
454
+ cloudAiStatsTiles,
455
+ cloudAiTableTsv,
456
+ cloudAiViewSummary,
457
+ cloudAiViewTable,
458
+ isAiViewFile,
459
+ isAiViewPath,
460
+ parseCloudAiChartToolView,
461
+ parseCloudAiView,
462
+ sortCloudAiViewRows,
463
+ } from "./view-block";
366
464
  export {
367
465
  type CloudAiViewImageInput,
368
466
  CloudAiViewImageInputSchema,