@k2b/cloud 0.37.0 → 0.38.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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/help-catalog.ts +123 -4
  3. package/src/_internal/help.ts +27 -6
  4. package/src/account/Avatar.tsx +20 -2
  5. package/src/account/ui.ts +1 -1
  6. package/src/ai/browser.ts +1 -0
  7. package/src/ai/chart-block.ts +44 -13
  8. package/src/ai/chat/chart-block.tsx +2 -11
  9. package/src/ai/chat/messages.ts +10 -0
  10. package/src/ai/chat/web-tools.tsx +23 -6
  11. package/src/ai/chat-tasks.ts +65 -1
  12. package/src/ai/default-tool-contracts.ts +30 -13
  13. package/src/ai/fetch-file-tool.ts +37 -3
  14. package/src/ai/migrate.ts +19 -2
  15. package/src/ai/readable-html.ts +1057 -0
  16. package/src/api/help.ts +23 -4
  17. package/src/api/mcp.ts +8 -2
  18. package/src/browser/FileChooser.tsx +11 -3
  19. package/src/browser/choose-files.browser-harness.tsx +4 -1
  20. package/src/browser/file-chooser-messages.ts +4 -0
  21. package/src/browser/file-providers.ts +14 -1
  22. package/src/browser/provider-folder.tsx +58 -5
  23. package/src/browser/resource-picker.tsx +5 -1
  24. package/src/cli/admin/workflows.ts +1 -0
  25. package/src/config/env.ts +14 -0
  26. package/src/contracts/file-provider.ts +48 -1
  27. package/src/contracts/outgoing-mail.ts +8 -1
  28. package/src/server/help.ts +136 -10
  29. package/src/server/services/geo.ts +21 -4
  30. package/src/services/allowed-network-fetch.ts +189 -0
  31. package/src/services/help/index.ts +46 -5
  32. package/src/services/help/store.ts +26 -5
  33. package/src/services/help/types.ts +10 -2
  34. package/src/services/index.ts +1 -0
  35. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  36. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  37. package/src/services/outgoing-mail/test-send.ts +18 -3
  38. package/src/services/outgoing-mail/transport.ts +24 -13
  39. package/src/services/settings/index.ts +48 -26
  40. package/src/shared/help.ts +41 -0
  41. package/src/shared/index.ts +9 -1
  42. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  43. package/src/shared/markdown/formula.ts +8 -8
  44. package/src/shared/markdown/index.ts +72 -11
  45. package/src/ssr/Layout.tsx +20 -2
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  47. package/src/ssr/LayoutHelp.tsx +569 -410
  48. package/src/ssr/ShellRuntime.island.tsx +17 -25
  49. package/src/ssr/help-messages.ts +60 -38
  50. package/src/ssr/help.ts +3 -0
  51. package/src/ssr/layout-help-article.ts +41 -0
  52. package/src/ssr/layout-help-navigation.ts +0 -12
  53. package/src/ssr/layout-help-registry.tsx +4 -11
  54. package/src/ssr/layout-help-search.ts +9 -12
  55. package/src/ssr/layout-help-start.ts +68 -0
  56. package/src/ssr/platform-messages.ts +4 -0
  57. package/src/ssr/shell-data.tsx +7 -0
  58. package/src/ssr/shell-shortcuts.ts +8 -0
  59. package/src/styles/effects.css +688 -185
  60. package/src/styles/resource-search.css +25 -0
  61. package/src/workflows/store/events.ts +24 -0
  62. package/src/workflows/store/index.ts +8 -0
  63. package/src/workflows/store/observability.ts +6 -1
  64. package/src/workflows/store/retention.ts +157 -0
package/src/api/help.ts CHANGED
@@ -1,14 +1,16 @@
1
+ import { keyboardPlatform } from "@k2b/ui";
1
2
  import { Hono, type MiddlewareHandler } from "hono";
3
+ import { helpAgentMarkdown } from "../_internal/help-catalog";
2
4
  import { type AuthContext, auth, getLocale } from "../server";
3
5
  import { createHelpReader, type HelpReaderFactory } from "../services/help";
4
- import type { HelpDocumentPayload, HelpSearchPayload } from "../shared/help";
6
+ import { type HelpDocumentPayload, type HelpSearchPayload, parseHelpReference } from "../shared/help";
5
7
  import { renderHelpMarkdown } from "../shared/markdown";
6
8
  import { helpApiMessages } from "./help-messages";
7
9
 
8
10
  export type HelpRouteDependencies = {
9
11
  help?: HelpReaderFactory;
10
12
  authenticate?: MiddlewareHandler<AuthContext>;
11
- renderMarkdown?: (markdown: string, locale: string) => string;
13
+ renderMarkdown?: typeof renderHelpMarkdown;
12
14
  };
13
15
 
14
16
  export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
@@ -34,12 +36,29 @@ export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
34
36
  const { t } = helpApiMessages.resolve([locale]);
35
37
  const document = await reader(locale, c.get("actor")).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
36
38
  if (!document) return c.json({ code: "HELP_NOT_FOUND", message: t.notFound }, 404);
39
+ const links = (reference: { appId?: string; documentId: string }) =>
40
+ document.links[`${reference.appId ?? document.appId}/${reference.documentId}`];
41
+ const resolve = (reference: string) => {
42
+ const ref = parseHelpReference(reference);
43
+ return ref ? links(ref) : undefined;
44
+ };
37
45
  const payload: HelpDocumentPayload = {
38
46
  locale: document.locale,
39
47
  id: document.documentId,
40
48
  title: document.title,
41
- markdown: document.markdown,
42
- html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, document.locale),
49
+ markdown: helpAgentMarkdown(document),
50
+ kind: document.kind,
51
+ needs: document.needs,
52
+ next: document.next ? resolve(document.next) : undefined,
53
+ related: document.related.flatMap((reference) => {
54
+ const target = resolve(reference);
55
+ return target ? [target] : [];
56
+ }),
57
+ html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, {
58
+ locale: document.locale,
59
+ platform: keyboardPlatform(c.req.header("User-Agent") ?? ""),
60
+ links,
61
+ }),
43
62
  };
44
63
  return c.json(payload);
45
64
  });
package/src/api/mcp.ts CHANGED
@@ -15,7 +15,13 @@ import {
15
15
  import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
17
  import { readBoundedJson } from "../_internal/bounded-json";
18
- import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
18
+ import {
19
+ HELP_SEARCH_MAX_LIMIT,
20
+ helpAgentMarkdown,
21
+ helpResourceUri,
22
+ parseHelpResourceUri,
23
+ readHelpArticle,
24
+ } from "../_internal/help-catalog";
19
25
  import { getCapability, listApps } from "../_internal/registry";
20
26
  import { addCloudResourceHrefs } from "../ai/resource-refs";
21
27
  import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
@@ -540,7 +546,7 @@ const createMcpServer = (
540
546
  {
541
547
  uri: message.params.uri,
542
548
  mimeType: "text/markdown",
543
- text: document.markdown,
549
+ text: helpAgentMarkdown(document),
544
550
  _meta: { "cloud/manifestHash": document.manifestHash, "cloud/locale": document.locale },
545
551
  },
546
552
  ],
@@ -14,7 +14,7 @@ import {
14
14
  } from "@k2b/ui";
15
15
  import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
16
16
  import { createStore } from "solid-js/store";
17
- import type { FileProviderEntry, FileProviderTag } from "../contracts/file-provider";
17
+ import type { FileProviderContext, FileProviderEntry, FileProviderTag } from "../contracts/file-provider";
18
18
  import { fileChooserMessages } from "./file-chooser-messages";
19
19
  import {
20
20
  type EntryProblem,
@@ -39,6 +39,14 @@ export type ChooseFilesOptions = {
39
39
  * above it, so it also bounds what the browser holds. The provider's own read limit applies to each file as well.
40
40
  */
41
41
  maxBytes?: number;
42
+ /**
43
+ * What the person is working on, as resource refs, for providers that understand it. Only providers whose `list`
44
+ * accepts `context` receive it; others list as before. A function runs only when the person opens such a provider,
45
+ * at most once per chooser, and again after it failed. Refs are hints, never access.
46
+ */
47
+ context?: FileProviderContext | ((signal: AbortSignal) => Promise<FileProviderContext>);
48
+ /** The empty root title for a provider that received context. Defaults to the chooser's "No matching files". */
49
+ noMatchingFiles?: string;
42
50
  /** Closes the chooser, or stops waiting for the device's dialog, and resolves `[]` when aborted. */
43
51
  signal?: AbortSignal;
44
52
  };
@@ -95,7 +103,7 @@ export function FileChooser(props: {
95
103
  if (first) (location() ? selection : sourceSelection).focus(first);
96
104
  });
97
105
  };
98
- const folder = createProviderFolder({ caller: props.caller, t, settled: refocus });
106
+ const folder = createProviderFolder({ caller: props.caller, t, settled: refocus, context: props.options.context });
99
107
  const { location, items, openFolder } = folder;
100
108
  const [downloads, setDownloads] = createStore<Download[]>([]);
101
109
  const [downloading, setDownloading] = createSignal(false);
@@ -297,7 +305,7 @@ export function FileChooser(props: {
297
305
  </Switch>
298
306
  </Match>
299
307
  <Match when={true}>
300
- <FolderContents folder={folder} t={t}>
308
+ <FolderContents folder={folder} t={t} noMatchingFiles={props.options.noMatchingFiles}>
301
309
  <FileGrid
302
310
  rows={items()}
303
311
  getRowId={(entry) => entry.id}
@@ -3,7 +3,8 @@ import { chooseFiles } from "./files";
3
3
 
4
4
  /**
5
5
  * One "Attach" button that calls the real `chooseFiles` for a browser test, which answers the catalog, the
6
- * provider's Queries, and its read streams. `?single` chooses one file, `?accept=` and `?max=` pass through.
6
+ * provider's Queries, and its read streams. `?single` chooses one file, `?accept=` and `?max=` pass through,
7
+ * `?customer=` passes one `customers.customer` ref as context and `?noMatching=` its empty title.
7
8
  * Each call gets a fresh signal that `window.abortChoosing()` aborts. Each result lands in `window.chosen` as plain
8
9
  * facts. `?dropzone` opens, like Notebooks, a dialog whose `FileDropzone` chooses with
9
10
  * `chooseFiles`; what it hands over lands in `window.chosen` the same way.
@@ -43,6 +44,8 @@ button.addEventListener("click", () => {
43
44
  multiple: !params.has("single"),
44
45
  accept: params.get("accept") ?? undefined,
45
46
  maxBytes: params.has("max") ? Number(params.get("max")) : undefined,
47
+ context: params.has("customer") ? { refs: [{ type: "customers.customer", id: params.get("customer")! }] } : undefined,
48
+ noMatchingFiles: params.get("noMatching") ?? undefined,
46
49
  signal: choosing.signal,
47
50
  }).then(async (files) => void results.push(await facts(files)));
48
51
  });
@@ -19,6 +19,8 @@ export const fileChooserMessages = i18n.define({
19
19
  loading: "Loading…",
20
20
  loadMore: "Load more",
21
21
  emptyFolder: "This folder is empty",
22
+ noMatchingFiles: "No matching files",
23
+ otherSource: "Choose another source",
22
24
  noMatches: ({ query }: { query: string }) => `Nothing matches “${query}”`,
23
25
  clearFilter: "Clear filter",
24
26
  offline: "You are offline",
@@ -92,6 +94,8 @@ export const fileChooserMessages = i18n.define({
92
94
  loading: "Wird geladen…",
93
95
  loadMore: "Mehr laden",
94
96
  emptyFolder: "Dieser Ordner ist leer",
97
+ noMatchingFiles: "Keine passenden Dateien",
98
+ otherSource: "Andere Quelle wählen",
95
99
  noMatches: ({ query }) => `Nichts passt zu „${query}“`,
96
100
  clearFilter: "Filter löschen",
97
101
  offline: "Du bist offline",
@@ -6,12 +6,14 @@ import type { CapabilityCatalogApp, CapabilityClientError, CapabilityHttpOptions
6
6
  import { CapabilityErrorSchema, type CapabilityStream, capabilityResultSchema } from "../contracts/capabilities";
7
7
  import { CapabilityStreamStatusSchema } from "../contracts/capability-streams";
8
8
  import {
9
+ type FileProviderContext,
9
10
  type FileProviderEntry,
10
11
  type FileProviderIssue,
11
12
  FileProviderListDataSchema,
12
13
  FileProviderReadDataSchema,
13
14
  FileProviderSaveDataSchema,
14
15
  FileProviderSaveInputSchema,
16
+ fileProviderAcceptsContext,
15
17
  fileProviderIssues,
16
18
  } from "../contracts/file-provider";
17
19
  import { LOCALE_HEADER } from "../shared/locale";
@@ -25,6 +27,7 @@ export type FileProviderSource = {
25
27
  name: string;
26
28
  icon: string;
27
29
  list: string;
30
+ context: boolean;
28
31
  read: string;
29
32
  /** The provider's read limit; a consumer combines it with its own. */
30
33
  maxBytes: number;
@@ -61,6 +64,14 @@ const issuesOf = (app: CapabilityCatalogApp): FileProviderIssue[] | null => {
61
64
  }
62
65
  };
63
66
 
67
+ const acceptsContext = (app: CapabilityCatalogApp): boolean => {
68
+ try {
69
+ return fileProviderAcceptsContext(app.manifest);
70
+ } catch {
71
+ return false;
72
+ }
73
+ };
74
+
64
75
  /**
65
76
  * Keeps the catalog apps that offer files with a usable `list` and `read`. Core already drops invalid declarations;
66
77
  * the check repeats here because a manifest from another Cloud release may name an operation this reader left out.
@@ -81,6 +92,7 @@ export const fileProviderSources = (apps: readonly CapabilityCatalogApp[], local
81
92
  name: app.appName,
82
93
  icon: providerIcon(app.appIcon, "ti ti-folders"),
83
94
  list: declaration.list,
95
+ context: acceptsContext(app),
84
96
  read: declaration.read,
85
97
  maxBytes: read.stream.maxBytes,
86
98
  ...(save?.stream ? { save: { id: save.localId, maxBytes: save.stream.maxBytes } } : {}),
@@ -112,7 +124,7 @@ export type FileProviderPage = { items: FileProviderEntry[]; next: string | null
112
124
  /** One folder page. Without `parent` it is the provider's root. */
113
125
  export const listProviderFolder = async (
114
126
  provider: FileProviderSource,
115
- input: { parent?: string; query?: string; cursor?: string },
127
+ input: { parent?: string; query?: string; cursor?: string; context?: FileProviderContext },
116
128
  caller: FileProviderCaller,
117
129
  signal?: AbortSignal,
118
130
  ): Promise<FileProviderPage> => {
@@ -125,6 +137,7 @@ export const listProviderFolder = async (
125
137
  ...(input.parent === undefined ? {} : { parent: input.parent }),
126
138
  ...(input.query ? { query: input.query } : {}),
127
139
  ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
140
+ ...(provider.context === true && input.context !== undefined ? { context: input.context } : {}),
128
141
  limit: FILE_PROVIDER_PAGE_SIZE,
129
142
  },
130
143
  signal,
@@ -1,7 +1,8 @@
1
1
  import { timed } from "@k2b/stdlib/solid";
2
2
  import { announce, Button, IconButton, Placeholder, TextInput } from "@k2b/ui";
3
3
  import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
4
- import type { FileProviderEntry } from "../contracts/file-provider";
4
+ import { CloudResourceRefSchema } from "../contracts/capabilities";
5
+ import { FILE_PROVIDER_CONTEXT_MAX_REFS, type FileProviderContext, type FileProviderEntry } from "../contracts/file-provider";
5
6
  import type { fileChooserMessages } from "./file-chooser-messages";
6
7
  import { type FileProviderCaller, FileProviderError, type FileProviderSource, listProviderFolder } from "./file-providers";
7
8
 
@@ -18,7 +19,12 @@ const EMPTY_PAGES_FOLLOWED = 5;
18
19
  * and its load state. `settled` runs after a folder has loaded or the source list is back, so the dialog can move
19
20
  * focus to its first row.
20
21
  */
21
- export const createProviderFolder = (props: { caller: () => FileProviderCaller; t: () => Messages; settled: () => void }) => {
22
+ export const createProviderFolder = (props: {
23
+ caller: () => FileProviderCaller;
24
+ t: () => Messages;
25
+ settled: () => void;
26
+ context?: FileProviderContext | ((signal: AbortSignal) => Promise<FileProviderContext>);
27
+ }) => {
22
28
  const [location, setLocation] = createSignal<FolderLocation>(null);
23
29
  const [filter, setFilter] = createSignal("");
24
30
  const [query, setQuery] = createSignal("");
@@ -27,6 +33,26 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
27
33
  const [writable, setWritable] = createSignal(false);
28
34
  const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
29
35
  const [error, setError] = createSignal<FileProviderError | null>(null);
36
+ const [contextual, setContextual] = createSignal(false);
37
+ let resolvedContext: FileProviderContext | undefined;
38
+ const contextFor = async (signal: AbortSignal): Promise<FileProviderContext> => {
39
+ if (resolvedContext) return resolvedContext;
40
+ const raw = typeof props.context === "function" ? await props.context(signal) : props.context;
41
+ signal.throwIfAborted();
42
+ const refs: FileProviderContext["refs"] = [];
43
+ const seen = new Set<string>();
44
+ for (const value of raw?.refs ?? []) {
45
+ const parsed = CloudResourceRefSchema.safeParse(value);
46
+ if (!parsed.success) continue;
47
+ const key = JSON.stringify([parsed.data.type, parsed.data.id]);
48
+ if (seen.has(key)) continue;
49
+ seen.add(key);
50
+ refs.push(parsed.data);
51
+ if (refs.length === FILE_PROVIDER_CONTEXT_MAX_REFS) break;
52
+ }
53
+ resolvedContext = { refs };
54
+ return resolvedContext;
55
+ };
30
56
  let request: AbortController | undefined;
31
57
  onCleanup(() => request?.abort());
32
58
 
@@ -48,12 +74,15 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
48
74
  setStatus(more ? "more" : "loading");
49
75
  setError(null);
50
76
  try {
77
+ const context = current.provider.context === true && props.context !== undefined ? await contextFor(pending.signal) : undefined;
78
+ if (pending.signal.aborted) return;
79
+ setContextual(context !== undefined);
51
80
  let cursor = more ? (next() ?? undefined) : undefined;
52
81
  let followed = 0;
53
82
  for (;;) {
54
83
  const page = await listProviderFolder(
55
84
  current.provider,
56
- { parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
85
+ { parent: current.trail.at(-1)?.id, query: query() || undefined, cursor, context },
57
86
  props.caller(),
58
87
  pending.signal,
59
88
  );
@@ -103,6 +132,7 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
103
132
  setFilter("");
104
133
  setQuery("");
105
134
  setLocation(target);
135
+ setContextual(false);
106
136
  setItems([]);
107
137
  setNext(null);
108
138
  setWritable(false);
@@ -138,6 +168,7 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
138
168
 
139
169
  return {
140
170
  location,
171
+ contextual,
141
172
  filter,
142
173
  query,
143
174
  items,
@@ -236,7 +267,12 @@ export function FolderToolbar(props: { folder: ProviderFolder; t: () => Messages
236
267
  * One folder in the dialog body: loading, the error with Try again or Up, the empty folder, no matches, or `children`
237
268
  * with Load more below while the folder continues.
238
269
  */
239
- export function FolderContents(props: { folder: ProviderFolder; t: () => Messages; children: JSX.Element }): JSX.Element {
270
+ export function FolderContents(props: {
271
+ folder: ProviderFolder;
272
+ t: () => Messages;
273
+ noMatchingFiles?: string;
274
+ children: JSX.Element;
275
+ }): JSX.Element {
240
276
  const folder = props.folder;
241
277
  return (
242
278
  <Switch>
@@ -268,7 +304,24 @@ export function FolderContents(props: { folder: ProviderFolder; t: () => Message
268
304
  <Match when={folder.items().length === 0 && !folder.next()}>
269
305
  <Show
270
306
  when={folder.query()}
271
- fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={props.t().emptyFolder} />}
307
+ fallback={
308
+ <Show
309
+ when={folder.contextual() && folder.location()?.trail.length === 1}
310
+ fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={props.t().emptyFolder} />}
311
+ >
312
+ <Placeholder
313
+ variant="panel"
314
+ class="cloud-file-chooser__state"
315
+ icon="ti ti-file-search"
316
+ title={props.noMatchingFiles ?? props.t().noMatchingFiles}
317
+ action={
318
+ <Button size="sm" variant="secondary" onClick={() => folder.navigate(null)}>
319
+ {props.t().otherSource}
320
+ </Button>
321
+ }
322
+ />
323
+ </Show>
324
+ }
272
325
  >
273
326
  <Placeholder
274
327
  variant="panel"
@@ -22,6 +22,10 @@ export const openCloudResourcePicker = (options: CloudResourcePickerOptions = {}
22
22
  (close) => (
23
23
  <CloudResourceSearch {...options} title={options.title ?? t.chooseResource} selectionMode onSelect={close} onClose={() => close()} />
24
24
  ),
25
- resourceSearchDialogOptions(options.title ?? t.chooseResource),
25
+ // A phone shows the picker as a full-screen sheet; global search keeps its floating card.
26
+ {
27
+ ...resourceSearchDialogOptions(options.title ?? t.chooseResource),
28
+ panelClassName: "cloud-search-dialog cloud-search-dialog--picker",
29
+ },
26
30
  );
27
31
  };
@@ -55,6 +55,7 @@ export type WorkflowRunDetailRow = WorkflowRunRow & {
55
55
  effectsUsed: Record<string, number>;
56
56
  effectBudget: Record<string, number>;
57
57
  eventData: Record<string, unknown> | null;
58
+ eventPayloadExpired: boolean;
58
59
  steps: WorkflowStepRow[];
59
60
  children: Record<RunState, number>;
60
61
  };
package/src/config/env.ts CHANGED
@@ -91,6 +91,20 @@ const registry = defineEnv({
91
91
  doc: "Core only: temporary first-administrator or recovery login token; remove it after normal administrator sign-in works.",
92
92
  },
93
93
 
94
+ // Outbound network
95
+ OUTGOING_MAIL_ALLOWED_TARGETS: {
96
+ schema: envList,
97
+ default: [],
98
+ scope: "runtime",
99
+ doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
100
+ },
101
+ GEO_ALLOWED_TARGETS: {
102
+ schema: envList,
103
+ default: ["geo"],
104
+ scope: "runtime",
105
+ doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for Geo requests; unset allows public targets and `geo`, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
106
+ },
107
+
94
108
  // Identity and OAuth
95
109
  CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
96
110
  schema: z.string(),
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { CapabilityManifest } from "./capabilities";
2
+ import { type CapabilityManifest, CloudResourceRefSchema } from "./capabilities";
3
3
  import { type CapabilityContract, type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
4
 
5
5
  /**
@@ -34,6 +34,26 @@ export const FileProviderListInputSchema = z
34
34
  })
35
35
  .strict();
36
36
 
37
+ /** Caller hints are bounded independently of provider pagination. */
38
+ export const FILE_PROVIDER_CONTEXT_MAX_REFS = 20;
39
+ export const FileProviderContextSchema = z
40
+ .object({
41
+ refs: z
42
+ .array(CloudResourceRefSchema)
43
+ .max(FILE_PROVIDER_CONTEXT_MAX_REFS)
44
+ .describe(
45
+ "Hints about what the caller works on, for example the contacts a mail goes to; never a permission. Empty means nothing resolved.",
46
+ ),
47
+ })
48
+ .strict()
49
+ .describe("Caller resource hints, never a permission or proof of access.");
50
+ export const FileProviderContextListInputSchema = FileProviderListInputSchema.extend({
51
+ context: FileProviderContextSchema.optional().describe(
52
+ "Optional caller context; keep it unchanged with cursor like parent, query and limit.",
53
+ ),
54
+ }).strict();
55
+ export type FileProviderContext = z.output<typeof FileProviderContextSchema>;
56
+
37
57
  const entryFacts = {
38
58
  id: ProviderIdSchema,
39
59
  name: z.string().min(1).max(255),
@@ -123,6 +143,33 @@ export const fileProvider = {
123
143
  },
124
144
  } as const satisfies Record<string, CapabilityContract>;
125
145
 
146
+ /** Optional context-aware list contract; the base provider contract stays unchanged. */
147
+ export const fileProviderContextList = { ...fileProvider.list, input: FileProviderContextListInputSchema } satisfies CapabilityContract;
148
+
149
+ /** Only explicitly named, compatible context inputs opt in; open input objects do not. */
150
+ export const fileProviderAcceptsContext = (manifest: CapabilityManifest): boolean => {
151
+ if (!manifest.fileProvider) return false;
152
+ const operation = manifest.queries.find((query) => query.localId === manifest.fileProvider?.list);
153
+ if (!operation) return false;
154
+ const root = operation.inputSchema;
155
+ let schema: unknown = root;
156
+ const visited = new Set<unknown>();
157
+ const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
158
+ while (object(schema) && typeof schema.$ref === "string") {
159
+ if (visited.has(schema)) return false;
160
+ visited.add(schema);
161
+ if (!schema.$ref.startsWith("#/$defs/")) return false;
162
+ const defs = root.$defs;
163
+ schema = object(defs) ? defs[schema.$ref.slice(8)] : undefined;
164
+ }
165
+ return (
166
+ object(schema) &&
167
+ object(schema.properties) &&
168
+ Object.hasOwn(schema.properties, "context") &&
169
+ capabilityContractIssues(fileProviderContextList, { kind: "query", operation }).length === 0
170
+ );
171
+ };
172
+
126
173
  export type FileProviderFunction = keyof typeof fileProvider;
127
174
  export const FILE_PROVIDER_FUNCTIONS = ["list", "read", "save"] as const satisfies readonly FileProviderFunction[];
128
175
 
@@ -96,7 +96,14 @@ export const MailErrorCodeSchema = z.enum([
96
96
  "smtp_failed",
97
97
  ]);
98
98
  export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
99
- export const MailBounceErrorCodeSchema = z.enum(["open_failed", "search_failed", "apply_failed", "save_failed", "interrupted"]);
99
+ export const MailBounceErrorCodeSchema = z.enum([
100
+ "open_failed",
101
+ "target_blocked",
102
+ "search_failed",
103
+ "apply_failed",
104
+ "save_failed",
105
+ "interrupted",
106
+ ]);
100
107
  export type MailBounceErrorCode = z.infer<typeof MailBounceErrorCodeSchema>;
101
108
  export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true, imap: true }).extend({
102
109
  key: MailProfileKeySchema,