@agent-native/core 0.126.0 → 0.127.3

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 (181) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +83 -0
  3. package/corpus/core/docs/content/external-agents-catalog.mdx +14 -11
  4. package/corpus/core/docs/content/mcp-protocol.mdx +9 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/index.ts +2 -0
  7. package/corpus/core/src/agent/production-agent.ts +6 -1
  8. package/corpus/core/src/agent/run-manager.ts +37 -0
  9. package/corpus/core/src/agent/run-store.ts +18 -0
  10. package/corpus/core/src/cli/template-sync.ts +96 -0
  11. package/corpus/core/src/client/MultiTabAssistantChat.tsx +75 -43
  12. package/corpus/core/src/client/agent-chat.ts +19 -1
  13. package/corpus/core/src/client/chat/tool-call-display.tsx +25 -0
  14. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +23 -0
  15. package/corpus/core/src/client/settings/AgentsSection.tsx +565 -71
  16. package/corpus/core/src/client/settings/SecretsSection.tsx +71 -36
  17. package/corpus/core/src/client/use-chat-models.ts +54 -37
  18. package/corpus/core/src/localization/default-messages.ts +11 -0
  19. package/corpus/core/src/mcp/ask-app-task-handle.ts +183 -0
  20. package/corpus/core/src/mcp/builtin-tools.ts +146 -34
  21. package/corpus/core/src/server/agent-capabilities.ts +8 -1
  22. package/corpus/core/src/server/agent-chat-plugin.ts +17 -0
  23. package/corpus/core/src/server/agent-discovery.ts +1 -1
  24. package/corpus/core/src/server/agent-peer-probe.ts +133 -0
  25. package/corpus/core/src/server/builder-design-systems.ts +1 -1
  26. package/corpus/core/src/server/core-routes-plugin.ts +67 -0
  27. package/corpus/core/src/shared/mcp-embed-headers.ts +4 -0
  28. package/corpus/core/src/templates/chat/_gitignore +2 -0
  29. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  30. package/corpus/templates/chat/_gitignore +2 -0
  31. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +23 -0
  32. package/corpus/templates/design/actions/create-file.ts +15 -0
  33. package/corpus/templates/design/actions/generate-design.ts +40 -0
  34. package/corpus/templates/design/actions/present-design-variants.ts +16 -0
  35. package/corpus/templates/design/app/hooks/use-question-flow.ts +21 -1
  36. package/corpus/templates/design/app/pages/design-editor/save-failure.ts +7 -1
  37. package/corpus/templates/design/changelog/2026-07-28-generated-designs-with-malformed-html-are-now-rejected-at-sa.md +6 -0
  38. package/corpus/templates/design/changelog/2026-07-28-the-model-you-pick-when-starting-a-new-design-is-now-used-fo.md +6 -0
  39. package/corpus/templates/design/server/source-workspace.ts +1 -0
  40. package/corpus/templates/design/shared/html-integrity.ts +608 -18
  41. package/corpus/templates/dispatch/changelog/2026-07-28-pending-apps-are-hidden-by-default-and-app-ownership-details.md +6 -0
  42. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -6
  43. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +5 -1
  44. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +5 -0
  45. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +31 -8
  46. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +15 -0
  47. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +78 -8
  48. package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +179 -0
  49. package/corpus/templates/slides/app/components/presentation/present-channel.ts +15 -0
  50. package/corpus/templates/slides/app/i18n/en-US.ts +10 -1
  51. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +67 -0
  52. package/corpus/templates/slides/app/lib/export-pptx-client.ts +16 -3
  53. package/corpus/templates/slides/app/pages/DeckEditor.tsx +11 -0
  54. package/corpus/templates/slides/app/pages/Presentation.tsx +11 -3
  55. package/corpus/templates/slides/changelog/2026-07-28-export-to-google-slides-now-creates-the-deck-directly-in-you.md +6 -0
  56. package/corpus/templates/slides/changelog/2026-07-28-opening-a-presentation-link-directly-no-longer-bounces-back-.md +6 -0
  57. package/corpus/templates/slides/changelog/2026-07-28-presenter-view-open-a-second-window-while-presenting-to-see-.md +6 -0
  58. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +90 -0
  59. package/corpus/toolkit/CHANGELOG.md +6 -0
  60. package/corpus/toolkit/package.json +1 -1
  61. package/corpus/toolkit/src/composer/TiptapComposer.tsx +143 -111
  62. package/corpus/toolkit/src/composer/runtime-adapters.tsx +2 -0
  63. package/dist/a2a/index.d.ts +2 -0
  64. package/dist/a2a/index.d.ts.map +1 -1
  65. package/dist/a2a/index.js +1 -0
  66. package/dist/a2a/index.js.map +1 -1
  67. package/dist/agent/production-agent.d.ts +2 -2
  68. package/dist/agent/production-agent.d.ts.map +1 -1
  69. package/dist/agent/production-agent.js +6 -3
  70. package/dist/agent/production-agent.js.map +1 -1
  71. package/dist/agent/run-manager.d.ts +11 -0
  72. package/dist/agent/run-manager.d.ts.map +1 -1
  73. package/dist/agent/run-manager.js +34 -1
  74. package/dist/agent/run-manager.js.map +1 -1
  75. package/dist/agent/run-store.d.ts +8 -0
  76. package/dist/agent/run-store.d.ts.map +1 -1
  77. package/dist/agent/run-store.js +16 -0
  78. package/dist/agent/run-store.js.map +1 -1
  79. package/dist/cli/template-sync.d.ts.map +1 -1
  80. package/dist/cli/template-sync.js +88 -0
  81. package/dist/cli/template-sync.js.map +1 -1
  82. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  83. package/dist/client/MultiTabAssistantChat.js +60 -31
  84. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  85. package/dist/client/agent-chat.d.ts +6 -0
  86. package/dist/client/agent-chat.d.ts.map +1 -1
  87. package/dist/client/agent-chat.js +13 -1
  88. package/dist/client/agent-chat.js.map +1 -1
  89. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  90. package/dist/client/chat/tool-call-display.js +14 -0
  91. package/dist/client/chat/tool-call-display.js.map +1 -1
  92. package/dist/client/resources/mcp-integration-catalog.d.ts +6 -0
  93. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  94. package/dist/client/resources/mcp-integration-catalog.js +16 -0
  95. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  96. package/dist/client/settings/AgentsSection.d.ts.map +1 -1
  97. package/dist/client/settings/AgentsSection.js +250 -30
  98. package/dist/client/settings/AgentsSection.js.map +1 -1
  99. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  100. package/dist/client/settings/SecretsSection.js +16 -2
  101. package/dist/client/settings/SecretsSection.js.map +1 -1
  102. package/dist/client/use-chat-models.d.ts.map +1 -1
  103. package/dist/client/use-chat-models.js +46 -27
  104. package/dist/client/use-chat-models.js.map +1 -1
  105. package/dist/collab/routes.d.ts +2 -2
  106. package/dist/collab/struct-routes.d.ts +1 -1
  107. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  108. package/dist/localization/default-messages.d.ts +9 -0
  109. package/dist/localization/default-messages.d.ts.map +1 -1
  110. package/dist/localization/default-messages.js +9 -0
  111. package/dist/localization/default-messages.js.map +1 -1
  112. package/dist/mcp/ask-app-task-handle.d.ts +25 -0
  113. package/dist/mcp/ask-app-task-handle.d.ts.map +1 -0
  114. package/dist/mcp/ask-app-task-handle.js +136 -0
  115. package/dist/mcp/ask-app-task-handle.js.map +1 -0
  116. package/dist/mcp/builtin-tools.d.ts +3 -3
  117. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  118. package/dist/mcp/builtin-tools.js +115 -38
  119. package/dist/mcp/builtin-tools.js.map +1 -1
  120. package/dist/observability/routes.d.ts +3 -3
  121. package/dist/progress/routes.d.ts +1 -1
  122. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  123. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/agent-capabilities.d.ts +7 -1
  127. package/dist/server/agent-capabilities.d.ts.map +1 -1
  128. package/dist/server/agent-capabilities.js +1 -0
  129. package/dist/server/agent-capabilities.js.map +1 -1
  130. package/dist/server/agent-chat-plugin.d.ts +7 -0
  131. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  132. package/dist/server/agent-chat-plugin.js +16 -1
  133. package/dist/server/agent-chat-plugin.js.map +1 -1
  134. package/dist/server/agent-discovery.d.ts +1 -0
  135. package/dist/server/agent-discovery.d.ts.map +1 -1
  136. package/dist/server/agent-discovery.js +1 -1
  137. package/dist/server/agent-discovery.js.map +1 -1
  138. package/dist/server/agent-peer-probe.d.ts +37 -0
  139. package/dist/server/agent-peer-probe.d.ts.map +1 -0
  140. package/dist/server/agent-peer-probe.js +90 -0
  141. package/dist/server/agent-peer-probe.js.map +1 -0
  142. package/dist/server/builder-design-systems.js +1 -1
  143. package/dist/server/builder-design-systems.js.map +1 -1
  144. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  145. package/dist/server/core-routes-plugin.js +53 -0
  146. package/dist/server/core-routes-plugin.js.map +1 -1
  147. package/dist/server/realtime-token.d.ts +1 -1
  148. package/dist/server/transcribe-voice.d.ts +1 -1
  149. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  150. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  151. package/dist/shared/mcp-embed-headers.js +4 -0
  152. package/dist/shared/mcp-embed-headers.js.map +1 -1
  153. package/dist/templates/chat/_gitignore +2 -0
  154. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  155. package/docs/content/external-agents-catalog.mdx +14 -11
  156. package/docs/content/mcp-protocol.mdx +9 -4
  157. package/package.json +2 -2
  158. package/src/a2a/index.ts +2 -0
  159. package/src/agent/production-agent.ts +6 -1
  160. package/src/agent/run-manager.ts +37 -0
  161. package/src/agent/run-store.ts +18 -0
  162. package/src/cli/template-sync.ts +96 -0
  163. package/src/client/MultiTabAssistantChat.tsx +75 -43
  164. package/src/client/agent-chat.ts +19 -1
  165. package/src/client/chat/tool-call-display.tsx +25 -0
  166. package/src/client/resources/mcp-integration-catalog.ts +23 -0
  167. package/src/client/settings/AgentsSection.tsx +565 -71
  168. package/src/client/settings/SecretsSection.tsx +71 -36
  169. package/src/client/use-chat-models.ts +54 -37
  170. package/src/localization/default-messages.ts +11 -0
  171. package/src/mcp/ask-app-task-handle.ts +183 -0
  172. package/src/mcp/builtin-tools.ts +146 -34
  173. package/src/server/agent-capabilities.ts +8 -1
  174. package/src/server/agent-chat-plugin.ts +17 -0
  175. package/src/server/agent-discovery.ts +1 -1
  176. package/src/server/agent-peer-probe.ts +133 -0
  177. package/src/server/builder-design-systems.ts +1 -1
  178. package/src/server/core-routes-plugin.ts +67 -0
  179. package/src/shared/mcp-embed-headers.ts +4 -0
  180. package/src/templates/chat/_gitignore +2 -0
  181. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
@@ -9,6 +9,15 @@ import {
9
9
  Button as ToolkitButton,
10
10
  ButtonBase as ToolkitButtonBase,
11
11
  } from "@agent-native/toolkit/ui/button";
12
+ import {
13
+ Command,
14
+ CommandEmpty,
15
+ CommandGroup,
16
+ CommandInput,
17
+ CommandItem,
18
+ CommandList,
19
+ CommandSeparator,
20
+ } from "@agent-native/toolkit/ui/command";
12
21
  import {
13
22
  IconCheck,
14
23
  IconChevronRight,
@@ -23,13 +32,10 @@ import React, { useEffect, useMemo, useState, useCallback } from "react";
23
32
 
24
33
  import { agentNativePath } from "../api-path.js";
25
34
  import {
26
- DropdownMenu,
27
- DropdownMenuContent,
28
- DropdownMenuItem,
29
- DropdownMenuLabel,
30
- DropdownMenuSeparator,
31
- DropdownMenuTrigger,
32
- } from "../components/ui/dropdown-menu.js";
35
+ Popover,
36
+ PopoverContent,
37
+ PopoverTrigger,
38
+ } from "../components/ui/popover.js";
33
39
  import {
34
40
  Tooltip,
35
41
  TooltipContent,
@@ -207,11 +213,13 @@ function KeysHeader({
207
213
  onSecret?: (key: string) => void;
208
214
  onCustomKey: () => void;
209
215
  }) {
216
+ const [open, setOpen] = useState(false);
217
+
210
218
  return (
211
219
  <div className="flex items-center justify-between gap-3">
212
220
  <p className="text-[11px] font-medium text-foreground">Keys</p>
213
- <DropdownMenu>
214
- <DropdownMenuTrigger asChild>
221
+ <Popover open={open} onOpenChange={setOpen}>
222
+ <PopoverTrigger asChild>
215
223
  <ToolkitButtonBase
216
224
  type="button"
217
225
  variant="outline"
@@ -220,34 +228,61 @@ function KeysHeader({
220
228
  <IconPlus size={11} />
221
229
  New
222
230
  </ToolkitButtonBase>
223
- </DropdownMenuTrigger>
224
- <DropdownMenuContent align="end" className="w-60">
225
- {availableSecrets.length > 0 && (
226
- <>
227
- <DropdownMenuLabel>Choose a key</DropdownMenuLabel>
228
- {availableSecrets.map((secret) => (
229
- <DropdownMenuItem
230
- key={secret.key}
231
- onSelect={() => onSecret?.(secret.key)}
232
- className="flex items-center justify-between gap-3"
231
+ </PopoverTrigger>
232
+ <PopoverContent align="end" className="w-60 p-0">
233
+ <Command
234
+ // cmdk's default scorer matches loose subsequences, so "logo"
235
+ // also surfaces every "G-o-o-g-l-e ... " key.
236
+ filter={(value, search) =>
237
+ value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0
238
+ }
239
+ >
240
+ {availableSecrets.length > 0 && (
241
+ <CommandInput placeholder="Search keys..." />
242
+ )}
243
+ <CommandList>
244
+ <CommandEmpty>No keys found.</CommandEmpty>
245
+ {availableSecrets.length > 0 && (
246
+ <>
247
+ <CommandGroup heading="Choose a key">
248
+ {availableSecrets.map((secret) => (
249
+ <CommandItem
250
+ key={secret.key}
251
+ value={`${secret.label} ${secret.key}`}
252
+ onSelect={() => {
253
+ setOpen(false);
254
+ onSecret?.(secret.key);
255
+ }}
256
+ className="flex items-center justify-between gap-3"
257
+ >
258
+ <span className="truncate">{secret.label}</span>
259
+ {secret.required && (
260
+ <span className="shrink-0 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
261
+ Required
262
+ </span>
263
+ )}
264
+ </CommandItem>
265
+ ))}
266
+ </CommandGroup>
267
+ <CommandSeparator />
268
+ </>
269
+ )}
270
+ <CommandGroup>
271
+ <CommandItem
272
+ value="custom key"
273
+ onSelect={() => {
274
+ setOpen(false);
275
+ onCustomKey();
276
+ }}
233
277
  >
234
- <span className="truncate">{secret.label}</span>
235
- {secret.required && (
236
- <span className="text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
237
- Required
238
- </span>
239
- )}
240
- </DropdownMenuItem>
241
- ))}
242
- <DropdownMenuSeparator />
243
- </>
244
- )}
245
- <DropdownMenuItem onSelect={onCustomKey}>
246
- <IconPlus size={14} />
247
- Custom
248
- </DropdownMenuItem>
249
- </DropdownMenuContent>
250
- </DropdownMenu>
278
+ <IconPlus size={14} />
279
+ Custom
280
+ </CommandItem>
281
+ </CommandGroup>
282
+ </CommandList>
283
+ </Command>
284
+ </PopoverContent>
285
+ </Popover>
251
286
  </div>
252
287
  );
253
288
  }
@@ -200,7 +200,14 @@ export function useChatModels({
200
200
  .catch(() => null),
201
201
  ])
202
202
  .then(([enginesData, envKeys, builderStatus]) => {
203
- if (!enginesData?.engines) return;
203
+ if (!enginesData?.engines) {
204
+ // Without a catalog the picker keeps an unvalidated DEFAULT_MODEL,
205
+ // which is indistinguishable from a real selection unless we say so.
206
+ console.warn(
207
+ "[agent-chat] no engine list; model picker is showing an unvalidated default",
208
+ );
209
+ return;
210
+ }
204
211
  const configuredKeys = new Set(
205
212
  (envKeys as Array<{ key: string; configured: boolean }>)
206
213
  .filter((k) => k.configured)
@@ -223,22 +230,45 @@ export function useChatModels({
223
230
  setDefaultModel(nextDefaultModel);
224
231
 
225
232
  const selection = selectionRef.current;
233
+
234
+ // Default only to a CONFIGURED group, and to nothing when there is
235
+ // none. `DEFAULT_MODEL` is a builder-gateway id that no group carries
236
+ // unless Builder is connected, and unconfigured groups are kept in the
237
+ // list for their connect affordance — so both `?? DEFAULT_MODEL` and
238
+ // `?? groups[0]` yield a selection the app cannot route, which the
239
+ // server silently replaces with its own default. An empty selection
240
+ // hides the picker instead of showing a model that will not be used.
241
+ const configuredGroups = groups.filter((g) => g.configured);
242
+ const resolveRoutableSelection = () => {
243
+ const group =
244
+ configuredGroups.find((g) => g.models.includes(nextDefaultModel)) ??
245
+ configuredGroups[0];
246
+ if (!group) return null;
247
+ const model =
248
+ group.models.find((m) => m === nextDefaultModel) ?? group.models[0];
249
+ if (!model) return null;
250
+ return {
251
+ model,
252
+ engine: group.engine,
253
+ effort: resolveReasoningEffortSelection(
254
+ model,
255
+ selection.selectedEffort,
256
+ ),
257
+ };
258
+ };
259
+
260
+ const applyFallback = (persist: boolean) => {
261
+ const next = resolveRoutableSelection();
262
+ setSelectedModel(next?.model ?? "");
263
+ setSelectedEngine(next?.engine ?? "");
264
+ if (next) {
265
+ setSelectedEffort(next.effort);
266
+ if (persist) writePersisted(storageKey, next);
267
+ }
268
+ };
269
+
226
270
  if (!hasExplicitSelectionRef.current) {
227
- const defaultGroup =
228
- groups.find((group) => group.models.includes(nextDefaultModel)) ??
229
- groups[0];
230
- const nextModel =
231
- defaultGroup?.models.find((model) => model === nextDefaultModel) ??
232
- defaultGroup?.models[0] ??
233
- nextDefaultModel;
234
- const nextEngine = defaultGroup?.engine ?? "";
235
- const nextEffort = resolveReasoningEffortSelection(
236
- nextModel,
237
- selection.selectedEffort,
238
- );
239
- setSelectedModel(nextModel);
240
- setSelectedEngine(nextEngine);
241
- setSelectedEffort(nextEffort);
271
+ applyFallback(false);
242
272
  return;
243
273
  }
244
274
 
@@ -248,28 +278,15 @@ export function useChatModels({
248
278
  (!selection.selectedEngine ||
249
279
  group.engine === selection.selectedEngine),
250
280
  );
251
- if (!selectedGroup) {
252
- const defaultGroup =
253
- groups.find((group) => group.models.includes(nextDefaultModel)) ??
254
- groups[0];
255
- const nextModel =
256
- defaultGroup?.models.find((model) => model === nextDefaultModel) ??
257
- defaultGroup?.models[0] ??
258
- nextDefaultModel;
259
- const nextEngine = defaultGroup?.engine ?? "";
260
- const nextEffort = resolveReasoningEffortSelection(
261
- nextModel,
262
- selection.selectedEffort,
263
- );
264
- setSelectedModel(nextModel);
265
- setSelectedEngine(nextEngine);
266
- setSelectedEffort(nextEffort);
267
- writePersisted(storageKey, {
268
- model: nextModel,
269
- engine: nextEngine,
270
- effort: nextEffort,
271
- });
281
+ if (selectedGroup) {
282
+ // Heal a selection stored without an engine (or with a stale one) so
283
+ // later submits carry the pair the catalog resolved.
284
+ if (selection.selectedEngine !== selectedGroup.engine) {
285
+ setSelectedEngine(selectedGroup.engine);
286
+ }
287
+ return;
272
288
  }
289
+ applyFallback(true);
273
290
  })
274
291
  .catch(() => {})
275
292
  .finally(() => setIsLoading(false));
@@ -206,6 +206,7 @@ const messages = {
206
206
  otherAppsDescription: "Open other apps available to you.",
207
207
  availableCount: "{{count}} available",
208
208
  activeCount: "{{count}} active",
209
+ pendingCount: "{{count}} pending",
209
210
  hiddenCount: "{{count}} hidden",
210
211
  createApp: "Create app",
211
212
  templates: "Templates",
@@ -215,6 +216,16 @@ const messages = {
215
216
  show: "Show",
216
217
  hiddenApps: "Hidden apps",
217
218
  hiddenAppCount: "{{count}} hidden apps",
219
+ pendingApps: "Pending apps",
220
+ pendingAppsDescription:
221
+ "{{count}} pending apps are hidden by default. Show them only when you need to inspect work in progress.",
222
+ showPendingApps: "Show pending apps",
223
+ hidePendingApps: "Hide pending apps",
224
+ noActiveWorkspaceApps: "No active workspace apps yet",
225
+ noActiveWorkspaceAppsDescription:
226
+ "Pending apps are hidden by default. Expand Pending apps below to inspect them.",
227
+ appMetadataOwner: "Owner",
228
+ appMetadataTeams: "Teams",
218
229
  workspaceAppFallback: "Workspace App",
219
230
  workspaceAppDescription:
220
231
  "Open a deployed app or check the status of an app being created.",
@@ -0,0 +1,183 @@
1
+ import { createHmac } from "node:crypto";
2
+
3
+ import { CompactEncrypt, compactDecrypt } from "jose";
4
+
5
+ import { getAuthSecret } from "../server/better-auth-instance.js";
6
+
7
+ const HANDLE_VERSION = 1;
8
+ const HANDLE_TTL_SECONDS = 60 * 60;
9
+ const INVALID_HANDLE_ERROR = "Invalid or expired ask_app task handle.";
10
+
11
+ export interface AskAppTaskHandleRoute {
12
+ app: string;
13
+ origin: string;
14
+ routedVia: "local" | "a2a";
15
+ requestOrigin?: string;
16
+ }
17
+
18
+ interface AskAppTaskHandleClaims {
19
+ version: number;
20
+ issuerApp: string;
21
+ issuerAudience: string;
22
+ organization: string;
23
+ subject: string;
24
+ route: AskAppTaskHandleRoute;
25
+ taskId: string;
26
+ expiresAt: number;
27
+ }
28
+
29
+ function normalizedIdentity(value: string): string {
30
+ return value.trim().toLowerCase();
31
+ }
32
+
33
+ function normalizedAudience(value: string): string | null {
34
+ const url = validHttpUrl(value);
35
+ if (!url || url.search || url.hash) return null;
36
+ const pathname = url.pathname.replace(/\/+$/, "");
37
+ return `${url.origin}${pathname}`;
38
+ }
39
+
40
+ function rootSecrets(): string[] {
41
+ const a2a = process.env.A2A_SECRET?.trim();
42
+ const auth = getAuthSecret();
43
+ return [...new Set([a2a, auth].filter((value): value is string => !!value))];
44
+ }
45
+
46
+ function handleKey(rootSecret: string): Uint8Array {
47
+ return createHmac("sha256", rootSecret)
48
+ .update("agent-native:ask-app-task-handle:v1")
49
+ .digest();
50
+ }
51
+
52
+ function validHttpUrl(value: string): URL | null {
53
+ try {
54
+ const url = new URL(value);
55
+ if (!["http:", "https:"].includes(url.protocol)) return null;
56
+ if (url.username || url.password) return null;
57
+ return url;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function validRoute(value: unknown): value is AskAppTaskHandleRoute {
64
+ if (!value || typeof value !== "object") return false;
65
+ const route = value as Partial<AskAppTaskHandleRoute>;
66
+ if (typeof route.app !== "string" || !route.app.trim()) return false;
67
+ if (route.routedVia !== "local" && route.routedVia !== "a2a") return false;
68
+ if (typeof route.origin !== "string") return false;
69
+ const endpoint = validHttpUrl(route.origin);
70
+ if (!endpoint) return false;
71
+ if (route.requestOrigin == null) return true;
72
+ if (typeof route.requestOrigin !== "string") return false;
73
+ const requestOrigin = validHttpUrl(route.requestOrigin);
74
+ return requestOrigin?.origin === endpoint.origin;
75
+ }
76
+
77
+ function invalidHandle(): Error {
78
+ return new Error(INVALID_HANDLE_ERROR);
79
+ }
80
+
81
+ export async function signAskAppTaskHandle(params: {
82
+ issuerApp: string;
83
+ issuerAudience: string;
84
+ organization: string;
85
+ subject: string;
86
+ route: AskAppTaskHandleRoute;
87
+ taskId: string;
88
+ expiresInSeconds?: number;
89
+ }): Promise<string> {
90
+ const issuerApp = normalizedIdentity(params.issuerApp);
91
+ const issuerAudience = normalizedAudience(params.issuerAudience);
92
+ const organization = normalizedIdentity(params.organization);
93
+ const subject = normalizedIdentity(params.subject);
94
+ const taskId = params.taskId.trim();
95
+ if (
96
+ !issuerApp ||
97
+ !issuerAudience ||
98
+ !subject ||
99
+ !taskId ||
100
+ !validRoute(params.route)
101
+ ) {
102
+ throw invalidHandle();
103
+ }
104
+
105
+ const claims: AskAppTaskHandleClaims = {
106
+ version: HANDLE_VERSION,
107
+ issuerApp,
108
+ issuerAudience,
109
+ organization,
110
+ subject,
111
+ route: {
112
+ ...params.route,
113
+ app: normalizedIdentity(params.route.app),
114
+ },
115
+ taskId,
116
+ expiresAt:
117
+ Math.floor(Date.now() / 1000) +
118
+ (params.expiresInSeconds ?? HANDLE_TTL_SECONDS),
119
+ };
120
+ const payload = new TextEncoder().encode(JSON.stringify(claims));
121
+ return new CompactEncrypt(payload)
122
+ .setProtectedHeader({
123
+ alg: "dir",
124
+ enc: "A256GCM",
125
+ typ: "agent-native-ask-app-task-handle",
126
+ })
127
+ .encrypt(handleKey(rootSecrets()[0]));
128
+ }
129
+
130
+ export async function verifyAskAppTaskHandle(
131
+ handle: string,
132
+ expected: {
133
+ issuerApp: string;
134
+ issuerAudience: string;
135
+ organization: string;
136
+ subject: string;
137
+ },
138
+ ): Promise<{ route: AskAppTaskHandleRoute; taskId: string }> {
139
+ const issuerApp = normalizedIdentity(expected.issuerApp);
140
+ const issuerAudience = normalizedAudience(expected.issuerAudience);
141
+ const organization = normalizedIdentity(expected.organization);
142
+ const subject = normalizedIdentity(expected.subject);
143
+ if (!handle.trim() || !issuerApp || !issuerAudience || !subject) {
144
+ throw invalidHandle();
145
+ }
146
+
147
+ for (const rootSecret of rootSecrets()) {
148
+ try {
149
+ const { plaintext, protectedHeader } = await compactDecrypt(
150
+ handle,
151
+ handleKey(rootSecret),
152
+ );
153
+ if (
154
+ protectedHeader.alg !== "dir" ||
155
+ protectedHeader.enc !== "A256GCM" ||
156
+ protectedHeader.typ !== "agent-native-ask-app-task-handle"
157
+ ) {
158
+ continue;
159
+ }
160
+ const claims = JSON.parse(
161
+ new TextDecoder().decode(plaintext),
162
+ ) as Partial<AskAppTaskHandleClaims>;
163
+ if (
164
+ claims.version !== HANDLE_VERSION ||
165
+ claims.issuerApp !== issuerApp ||
166
+ claims.issuerAudience !== issuerAudience ||
167
+ claims.organization !== organization ||
168
+ claims.subject !== subject ||
169
+ typeof claims.taskId !== "string" ||
170
+ !claims.taskId ||
171
+ typeof claims.expiresAt !== "number" ||
172
+ claims.expiresAt <= Math.floor(Date.now() / 1000) ||
173
+ !validRoute(claims.route)
174
+ ) {
175
+ continue;
176
+ }
177
+ return { route: claims.route, taskId: claims.taskId };
178
+ } catch {
179
+ // All handle failures are deliberately indistinguishable to callers.
180
+ }
181
+ }
182
+ throw invalidHandle();
183
+ }