@agent-native/core 0.101.2 → 0.101.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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/credentials/index.ts +41 -8
  5. package/corpus/core/src/extensions/fetch-tool.ts +33 -3
  6. package/corpus/core/src/notifications/channels.ts +20 -6
  7. package/corpus/core/src/secrets/crypto.ts +26 -10
  8. package/corpus/core/src/secrets/storage.ts +16 -3
  9. package/corpus/core/src/server/agent-chat-plugin.ts +60 -7
  10. package/corpus/core/src/server/derived-secret.ts +5 -1
  11. package/corpus/templates/analytics/changelog/2026-07-14-long-running-cross-app-analyses-now-continue-reliably-in-the.md +6 -0
  12. package/corpus/templates/analytics/server/plugins/agent-chat.ts +8 -4
  13. package/corpus/templates/clips/app/components/player/share-dialog.tsx +44 -81
  14. package/corpus/templates/clips/app/i18n/ar-SA.ts +0 -8
  15. package/corpus/templates/clips/app/i18n/de-DE.ts +0 -9
  16. package/corpus/templates/clips/app/i18n/en-US.ts +0 -8
  17. package/corpus/templates/clips/app/i18n/es-ES.ts +0 -9
  18. package/corpus/templates/clips/app/i18n/fr-FR.ts +0 -9
  19. package/corpus/templates/clips/app/i18n/hi-IN.ts +0 -8
  20. package/corpus/templates/clips/app/i18n/ja-JP.ts +0 -9
  21. package/corpus/templates/clips/app/i18n/ko-KR.ts +0 -9
  22. package/corpus/templates/clips/app/i18n/pt-BR.ts +0 -9
  23. package/corpus/templates/clips/app/i18n/zh-CN.ts +0 -7
  24. package/corpus/templates/clips/app/i18n/zh-TW.ts +0 -7
  25. package/corpus/templates/clips/changelog/2026-07-14-share-dialogs-show-separate-links-for-people-and-agents.md +6 -0
  26. package/corpus/templates/dispatch/changelog/2026-07-14-dispatch-now-distinguishes-connected-agents-from-locally-mou.md +6 -0
  27. package/dist/credentials/index.d.ts +12 -7
  28. package/dist/credentials/index.d.ts.map +1 -1
  29. package/dist/credentials/index.js +33 -8
  30. package/dist/credentials/index.js.map +1 -1
  31. package/dist/extensions/fetch-tool.d.ts +13 -1
  32. package/dist/extensions/fetch-tool.d.ts.map +1 -1
  33. package/dist/extensions/fetch-tool.js +9 -2
  34. package/dist/extensions/fetch-tool.js.map +1 -1
  35. package/dist/notifications/channels.js +20 -4
  36. package/dist/notifications/channels.js.map +1 -1
  37. package/dist/notifications/routes.d.ts +3 -3
  38. package/dist/observability/routes.d.ts +1 -1
  39. package/dist/resources/handlers.d.ts +1 -1
  40. package/dist/secrets/crypto.d.ts +15 -7
  41. package/dist/secrets/crypto.d.ts.map +1 -1
  42. package/dist/secrets/crypto.js +23 -10
  43. package/dist/secrets/crypto.js.map +1 -1
  44. package/dist/secrets/storage.d.ts.map +1 -1
  45. package/dist/secrets/storage.js +16 -3
  46. package/dist/secrets/storage.js.map +1 -1
  47. package/dist/server/agent-chat-plugin.d.ts +20 -0
  48. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  49. package/dist/server/agent-chat-plugin.js +38 -4
  50. package/dist/server/agent-chat-plugin.js.map +1 -1
  51. package/dist/server/derived-secret.d.ts +1 -1
  52. package/dist/server/derived-secret.d.ts.map +1 -1
  53. package/dist/server/derived-secret.js.map +1 -1
  54. package/package.json +2 -2
  55. package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +0 -68
@@ -7,7 +7,6 @@ import {
7
7
  } from "@agent-native/core/client";
8
8
  import {
9
9
  IconCode,
10
- IconChevronDown,
11
10
  IconExternalLink,
12
11
  IconLink,
13
12
  IconMail,
@@ -33,13 +32,7 @@ import {
33
32
  type SharesResponse,
34
33
  type Visibility,
35
34
  } from "@/components/sharing/share-ui";
36
- import { SlackShareHint } from "@/components/sharing/slack-share-hint";
37
35
  import { Button } from "@/components/ui/button";
38
- import {
39
- Collapsible,
40
- CollapsibleContent,
41
- CollapsibleTrigger,
42
- } from "@/components/ui/collapsible";
43
36
  import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
44
37
  import { Input } from "@/components/ui/input";
45
38
  import { Label } from "@/components/ui/label";
@@ -317,7 +310,6 @@ function LinkTab({
317
310
  const agentLinkRequestIdRef = useRef(0);
318
311
  const [agentContextUrl, setAgentContextUrl] = useState("");
319
312
  const [agentLinkError, setAgentLinkError] = useState(false);
320
- const [agentShareOpen, setAgentShareOpen] = useState(false);
321
313
 
322
314
  useEffect(() => {
323
315
  createAgentLinkAsyncRef.current = createAgentLink.mutateAsync;
@@ -352,26 +344,22 @@ function LinkTab({
352
344
  setAgentLinkError(false);
353
345
  if (!sharesLoaded) return;
354
346
 
355
- if (!isPublic && agentShareOpen) {
347
+ if (!isPublic) {
356
348
  void loadAgentContextUrl();
357
349
  }
358
350
 
359
351
  return () => {
360
352
  agentLinkRequestIdRef.current += 1;
361
353
  };
362
- }, [
363
- agentShareOpen,
364
- isPublic,
365
- loadAgentContextUrl,
366
- recordingId,
367
- sharesLoaded,
368
- visibility,
369
- ]);
354
+ }, [isPublic, loadAgentContextUrl, recordingId, sharesLoaded, visibility]);
370
355
 
356
+ const agentLink = isPublic ? shareUrl : agentContextUrl;
371
357
  const agentShareDisabled =
372
- isPending || createAgentLink.isPending || !agentContextUrl;
373
- const agentPrompt = agentContextUrl
374
- ? t("shareDialog.agentPrompt", { agentContextUrl })
358
+ visibilityPending ||
359
+ !sharesLoaded ||
360
+ (!isPublic && (isPending || createAgentLink.isPending || !agentContextUrl));
361
+ const agentPrompt = agentLink
362
+ ? t("shareDialog.agentPrompt", { agentContextUrl: agentLink })
375
363
  : "";
376
364
 
377
365
  return (
@@ -399,67 +387,42 @@ function LinkTab({
399
387
  }
400
388
  />
401
389
 
402
- {/* Public links unfurl into a playable video in Slack; surface that here
403
- (and a connect link) instead of leaving it buried in Settings. */}
404
- {isPublic ? <SlackShareHint canManage={canManage} /> : null}
405
-
406
- {sharesLoaded && !isPublic ? (
407
- <Collapsible open={agentShareOpen} onOpenChange={setAgentShareOpen}>
408
- <CollapsibleTrigger asChild>
409
- <Button
410
- type="button"
411
- variant="ghost"
412
- className="h-auto w-full justify-between px-0 py-1 text-left hover:bg-transparent"
413
- >
414
- <span className="min-w-0">
415
- <span className="block text-xs font-medium text-foreground">
416
- {t("shareDialog.shareWithAgents")}
417
- </span>
418
- <span className="mt-0.5 block text-xs font-normal text-muted-foreground">
419
- {t("shareDialog.agentTokenDescription")}
420
- </span>
421
- </span>
422
- <IconChevronDown
423
- className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
424
- agentShareOpen ? "rotate-180" : ""
425
- }`}
426
- aria-hidden
427
- />
428
- </Button>
429
- </CollapsibleTrigger>
430
- <CollapsibleContent className="pt-2">
431
- <div className="space-y-3 rounded-md border border-border bg-muted/30 p-3">
432
- <CopyField
433
- label={t("shareDialog.shareWithAgents")}
434
- value={agentContextUrl}
435
- disabled={agentShareDisabled}
436
- />
437
- {agentLinkError ? (
438
- <div className="flex items-center justify-between gap-2 rounded-md border border-border bg-background px-3 py-2">
439
- <p className="text-xs text-muted-foreground">
440
- {t("shareDialog.agentLinkUnavailable")}
441
- </p>
442
- <Button
443
- type="button"
444
- variant="ghost"
445
- size="sm"
446
- className="h-7"
447
- onClick={() => void loadAgentContextUrl()}
448
- disabled={createAgentLink.isPending}
449
- >
450
- {t("shareDialog.retryAgentLink")}
451
- </Button>
452
- </div>
453
- ) : null}
454
- <CopyField
455
- label={t("shareDialog.copyAgentPrompt")}
456
- value={agentPrompt}
457
- disabled={agentShareDisabled}
458
- />
459
- </div>
460
- </CollapsibleContent>
461
- </Collapsible>
462
- ) : null}
390
+ <div className="space-y-2">
391
+ <CopyField
392
+ label={t("shareDialog.shareWithAgents")}
393
+ value={agentLink}
394
+ disabled={agentShareDisabled}
395
+ />
396
+ {sharesLoaded && !isPublic ? (
397
+ <>
398
+ <p className="text-xs text-muted-foreground">
399
+ {t("shareDialog.agentTokenDescription")}
400
+ </p>
401
+ {agentLinkError ? (
402
+ <div className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
403
+ <p className="text-xs text-muted-foreground">
404
+ {t("shareDialog.agentLinkUnavailable")}
405
+ </p>
406
+ <Button
407
+ type="button"
408
+ variant="ghost"
409
+ size="sm"
410
+ className="h-7"
411
+ onClick={() => void loadAgentContextUrl()}
412
+ disabled={createAgentLink.isPending}
413
+ >
414
+ {t("shareDialog.retryAgentLink")}
415
+ </Button>
416
+ </div>
417
+ ) : null}
418
+ <CopyField
419
+ label={t("shareDialog.copyAgentPrompt")}
420
+ value={agentPrompt}
421
+ disabled={agentShareDisabled}
422
+ />
423
+ </>
424
+ ) : null}
425
+ </div>
463
426
 
464
427
  {sharesLoaded && !isPublic && canManage ? (
465
428
  <MakePublicCard
@@ -887,14 +887,6 @@ const messages = {
887
887
  "سيفقد {{email}} الوصول إلى هذه المؤسسة. يمكنك دائمًا دعوته مرة أخرى.",
888
888
  remove: "إزالة",
889
889
  },
890
- slackShareHint: {
891
- playsInline: "يعمل داخل Slack",
892
- connectedDescription:
893
- "الصق هذا الرابط في أي مساحة عمل متصلة لتشغيله داخلها.",
894
- makeInline: "اجعله يعمل داخل Slack",
895
- connectDescription: "قم بتوصيل مساحة عمل حتى يظهر هذا الرابط كفيديو.",
896
- connect: "اتصال",
897
- },
898
890
  commentsPanel: {
899
891
  disabled: "تم تعطيل التعليقات لهذا التسجيل.",
900
892
  beFirst: "كن أول من يعلق",
@@ -911,15 +911,6 @@ Alle sichtbaren Änderungen für Clips-Nutzer werden hier dokumentiert. Du kanns
911
911
  "{{email}} verliert den Zugriff auf diese Organisation. Du kannst die Person jederzeit erneut einladen.",
912
912
  remove: "Entfernen",
913
913
  },
914
- slackShareHint: {
915
- playsInline: "Wird inline in Slack abgespielt",
916
- connectedDescription:
917
- "Füge diesen Link in einem verbundenen Workspace ein, um ihn inline abzuspielen.",
918
- makeInline: "Inline-Wiedergabe in Slack aktivieren",
919
- connectDescription:
920
- "Verbinde einen Workspace, damit dieser Link als Video angezeigt wird.",
921
- connect: "Verbinden",
922
- },
923
914
  commentsPanel: {
924
915
  disabled: "Kommentare sind für diese Aufzeichnung deaktiviert.",
925
916
  beFirst: "Schreibe den ersten Kommentar",
@@ -882,14 +882,6 @@ All notable user-facing changes to Clips are documented here. Open it any time f
882
882
  "{{email}} will lose access to this organization. You can always invite them back.",
883
883
  remove: "Remove",
884
884
  },
885
- slackShareHint: {
886
- playsInline: "Plays inline in Slack",
887
- connectedDescription:
888
- "Paste this link in any connected workspace to play it inline.",
889
- makeInline: "Make it play inline in Slack",
890
- connectDescription: "Connect a workspace so this link unfurls as a video.",
891
- connect: "Connect",
892
- },
893
885
  commentsPanel: {
894
886
  disabled: "Comments are disabled for this recording.",
895
887
  beFirst: "Be the first to comment",
@@ -904,15 +904,6 @@ Todos los cambios visibles para los usuarios de Clips se documentan aquí. Puede
904
904
  "{{email}} perderá acceso a esta organización. Siempre puedes volver a invitarlo.",
905
905
  remove: "Eliminar",
906
906
  },
907
- slackShareHint: {
908
- playsInline: "Se reproduce inline en Slack",
909
- connectedDescription:
910
- "Pega este enlace en cualquier espacio conectado para reproducirlo inline.",
911
- makeInline: "Haz que se reproduzca inline en Slack",
912
- connectDescription:
913
- "Conecta un espacio para que este enlace se despliegue como video.",
914
- connect: "Conectar",
915
- },
916
907
  commentsPanel: {
917
908
  disabled: "Los comentarios están desactivados para esta grabación.",
918
909
  beFirst: "Sé la primera persona en comentar",
@@ -906,15 +906,6 @@ Tous les changements visibles par les utilisateurs de Clips sont documentés ici
906
906
  "{{email}} perdra l’accès à cette organisation. Vous pourrez toujours l’inviter à nouveau.",
907
907
  remove: "Retirer",
908
908
  },
909
- slackShareHint: {
910
- playsInline: "Lecture inline dans Slack",
911
- connectedDescription:
912
- "Collez ce lien dans n’importe quel espace connecté pour le lire inline.",
913
- makeInline: "Le lire inline dans Slack",
914
- connectDescription:
915
- "Connectez un espace pour que ce lien se déplie en vidéo.",
916
- connect: "Connecter",
917
- },
918
909
  commentsPanel: {
919
910
  disabled: "Les commentaires sont désactivés pour cet enregistrement.",
920
911
  beFirst: "Soyez le premier à commenter",
@@ -876,14 +876,6 @@ Clips में उपयोगकर्ताओं को दिखने व
876
876
  "{{email}} इस संगठन तक पहुंच खो देगा। आप उन्हें हमेशा वापस आमंत्रित कर सकते हैं।",
877
877
  remove: "हटाएं",
878
878
  },
879
- slackShareHint: {
880
- playsInline: "Slack में इनलाइन चलता है",
881
- connectedDescription:
882
- "इसे इनलाइन चलाने के लिए यह लिंक किसी भी जुड़े हुए वर्कस्पेस में पेस्ट करें।",
883
- makeInline: "इसे Slack में इनलाइन चलाएं",
884
- connectDescription: "एक वर्कस्पेस कनेक्ट करें ताकि यह लिंक वीडियो के रूप में खुले।",
885
- connect: "कनेक्ट करें",
886
- },
887
879
  commentsPanel: {
888
880
  disabled: "इस रिकॉर्डिंग के लिए टिप्पणियां बंद हैं।",
889
881
  beFirst: "पहली टिप्पणी करें",
@@ -893,15 +893,6 @@ Clips のユーザー向けの主な変更はここに記録されます。コ
893
893
  "{{email}} はこの組織へのアクセスを失います。いつでも再招待できます。",
894
894
  remove: "削除",
895
895
  },
896
- slackShareHint: {
897
- playsInline: "Slack 内でインライン再生",
898
- connectedDescription:
899
- "このリンクを接続済みワークスペースに貼り付けるとインライン再生されます。",
900
- makeInline: "Slack でインライン再生する",
901
- connectDescription:
902
- "ワークスペースを接続すると、このリンクが動画として展開されます。",
903
- connect: "接続",
904
- },
905
896
  commentsPanel: {
906
897
  disabled: "この録画ではコメントが無効です。",
907
898
  beFirst: "最初にコメントする",
@@ -883,15 +883,6 @@ Clips의 모든 사용자 대상 변경 사항은 여기에 기록됩니다. 명
883
883
  "{{email}}은 이 조직에 대한 액세스 권한을 잃습니다. 언제든 다시 초대할 수 있습니다.",
884
884
  remove: "제거",
885
885
  },
886
- slackShareHint: {
887
- playsInline: "Slack에서 인라인 재생",
888
- connectedDescription:
889
- "이 링크를 연결된 워크스페이스에 붙여넣으면 인라인으로 재생됩니다.",
890
- makeInline: "Slack에서 인라인 재생하기",
891
- connectDescription:
892
- "워크스페이스를 연결하면 이 링크가 동영상으로 펼쳐집니다.",
893
- connect: "연결",
894
- },
895
886
  commentsPanel: {
896
887
  disabled: "이 녹화에서는 댓글이 비활성화되어 있습니다.",
897
888
  beFirst: "첫 댓글을 남겨보세요",
@@ -900,15 +900,6 @@ Todas as mudanças visíveis para usuários do Clips são documentadas aqui. Voc
900
900
  "{{email}} perderá acesso a esta organização. Você sempre pode convidá-lo novamente.",
901
901
  remove: "Remover",
902
902
  },
903
- slackShareHint: {
904
- playsInline: "Reproduz inline no Slack",
905
- connectedDescription:
906
- "Cole este link em qualquer workspace conectado para reproduzi-lo inline.",
907
- makeInline: "Faça reproduzir inline no Slack",
908
- connectDescription:
909
- "Conecte um workspace para que este link abra como vídeo.",
910
- connect: "Conectar",
911
- },
912
903
  commentsPanel: {
913
904
  disabled: "Os comentários estão desativados para esta gravação.",
914
905
  beFirst: "Seja a primeira pessoa a comentar",
@@ -849,13 +849,6 @@ Clips 中所有面向用户的重要更改都会记录在这里。你可以随
849
849
  "{{email}} 将失去对此组织的访问权限。你随时可以重新邀请他们。",
850
850
  remove: "移除",
851
851
  },
852
- slackShareHint: {
853
- playsInline: "可在 Slack 中内联播放",
854
- connectedDescription: "将此链接粘贴到任何已连接工作区即可内联播放。",
855
- makeInline: "让它在 Slack 中内联播放",
856
- connectDescription: "连接一个工作区,让此链接展开为视频。",
857
- connect: "连接",
858
- },
859
852
  commentsPanel: {
860
853
  disabled: "此录制已禁用评论。",
861
854
  beFirst: "成为第一个评论的人",
@@ -842,13 +842,6 @@ const messages = {
842
842
  "{{email}} 將失去對此組織的存取權限。你隨時可以重新邀請他們。",
843
843
  remove: "移除",
844
844
  },
845
- slackShareHint: {
846
- playsInline: "可在 Slack 中行內播放",
847
- connectedDescription: "將此連結貼上到任何已連線工作區即可行內播放。",
848
- makeInline: "讓它在 Slack 中行內播放",
849
- connectDescription: "連線一個工作區,讓此連結展開為影片。",
850
- connect: "連線",
851
- },
852
845
  commentsPanel: {
853
846
  disabled: "此錄製已停用評論。",
854
847
  beFirst: "成為第一個評論的人",
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ The Share dialog once again makes separate links for people and agents easy to find.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ Dispatch now distinguishes connected agents from locally mounted apps when routing cross-app work.
@@ -14,16 +14,21 @@ export declare function resolveCredentialForScope(key: string, ctx: CredentialCo
14
14
  scope: CredentialStorageScope;
15
15
  }): Promise<string | undefined>;
16
16
  /**
17
- * Resolve a credential, scoped to the caller's user (and falling back to
18
- * the active org's shared credential, if any).
17
+ * Resolve a credential across the encrypted app_secrets store and the legacy
18
+ * settings-backed credential store. User overrides win, followed by the
19
+ * active org/workspace shared value.
19
20
  *
20
21
  * SECURITY: NEVER reads from process.env. Env vars are global to the
21
- * deployment and would leak across users in a multi-tenant app. The only
22
- * sources are per-user / per-org rows in the SQL `settings` table.
22
+ * deployment and would leak across users in a multi-tenant app.
23
23
  *
24
- * Storage keys (priority order):
25
- * 1. u:<email>:credential:<KEY> — per-user override
26
- * 2. o:<orgId>:credential:<KEY> — per-org shared credential (if orgId given)
24
+ * Read order:
25
+ * 1. user-scoped app_secrets
26
+ * 2. user-scoped legacy settings credential
27
+ * 3. org-scoped app_secrets
28
+ * 4. legacy workspace-scoped app_secrets for the org
29
+ * 5. org-scoped legacy settings credential
30
+ *
31
+ * Without an active org, step 3 is replaced by the solo workspace scope.
27
32
  */
28
33
  export declare function resolveCredential(key: string, ctx: CredentialContext): Promise<string | undefined>;
29
34
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credentials/index.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,KAAK,CAAC;AA6BpD;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,EAAE,sBAAsB,CAAA;CAAE,GACzD,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAc7B;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,OAAO,CAAC,CAElB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAcf"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credentials/index.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,KAAK,CAAC;AA2CpD;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,EAAE,sBAAsB,CAAA;CAAE,GACzD,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA2B7B;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAAC,OAAO,CAAC,CAElB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,iBAAiB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CAAE,GAClD,OAAO,CAAC,IAAI,CAAC,CAcf"}
@@ -1,4 +1,5 @@
1
1
  import { encryptSecretValue, decryptSecretValue, isEncryptedSecretValue, } from "../secrets/crypto.js";
2
+ import { readAppSecret } from "../secrets/storage.js";
2
3
  import { getSetting, putSetting, deleteSetting } from "../settings/store.js";
3
4
  const SETTING_PREFIX = "credential:";
4
5
  function userCredentialSettingKey(email, key) {
@@ -26,6 +27,16 @@ async function readCredentialSetting(settingKey) {
26
27
  return undefined;
27
28
  }
28
29
  }
30
+ async function readScopedAppSecret(key, scope, scopeId) {
31
+ try {
32
+ return (await readAppSecret({ key, scope, scopeId }))?.value;
33
+ }
34
+ catch {
35
+ // Older databases may not have app_secrets yet. Keep the legacy
36
+ // credential store available while the table bootstraps.
37
+ return undefined;
38
+ }
39
+ }
29
40
  /**
30
41
  * Resolve a credential from one explicit legacy SQL credential scope.
31
42
  *
@@ -44,20 +55,28 @@ export async function resolveCredentialForScope(key, ctx) {
44
55
  return readCredentialSetting(userCredentialSettingKey(ctx.userEmail, key));
45
56
  }
46
57
  /**
47
- * Resolve a credential, scoped to the caller's user (and falling back to
48
- * the active org's shared credential, if any).
58
+ * Resolve a credential across the encrypted app_secrets store and the legacy
59
+ * settings-backed credential store. User overrides win, followed by the
60
+ * active org/workspace shared value.
49
61
  *
50
62
  * SECURITY: NEVER reads from process.env. Env vars are global to the
51
- * deployment and would leak across users in a multi-tenant app. The only
52
- * sources are per-user / per-org rows in the SQL `settings` table.
63
+ * deployment and would leak across users in a multi-tenant app.
64
+ *
65
+ * Read order:
66
+ * 1. user-scoped app_secrets
67
+ * 2. user-scoped legacy settings credential
68
+ * 3. org-scoped app_secrets
69
+ * 4. legacy workspace-scoped app_secrets for the org
70
+ * 5. org-scoped legacy settings credential
53
71
  *
54
- * Storage keys (priority order):
55
- * 1. u:<email>:credential:<KEY> — per-user override
56
- * 2. o:<orgId>:credential:<KEY> — per-org shared credential (if orgId given)
72
+ * Without an active org, step 3 is replaced by the solo workspace scope.
57
73
  */
58
74
  export async function resolveCredential(key, ctx) {
59
75
  if (!ctx?.userEmail)
60
76
  return undefined;
77
+ const userSecret = await readScopedAppSecret(key, "user", ctx.userEmail);
78
+ if (userSecret)
79
+ return userSecret;
61
80
  const userSetting = await resolveCredentialForScope(key, {
62
81
  ...ctx,
63
82
  scope: "user",
@@ -65,9 +84,15 @@ export async function resolveCredential(key, ctx) {
65
84
  if (userSetting)
66
85
  return userSetting;
67
86
  if (ctx.orgId) {
87
+ const orgSecret = await readScopedAppSecret(key, "org", ctx.orgId);
88
+ if (orgSecret)
89
+ return orgSecret;
90
+ const workspaceSecret = await readScopedAppSecret(key, "workspace", ctx.orgId);
91
+ if (workspaceSecret)
92
+ return workspaceSecret;
68
93
  return resolveCredentialForScope(key, { ...ctx, scope: "org" });
69
94
  }
70
- return undefined;
95
+ return readScopedAppSecret(key, "workspace", `solo:${ctx.userEmail}`);
71
96
  }
72
97
  /**
73
98
  * Check if a credential is available for the given context.
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/credentials/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE7E,MAAM,cAAc,GAAG,aAAa,CAAC;AASrC,SAAS,wBAAwB,CAAC,KAAa,EAAE,GAAW;IAC1D,OAAO,KAAK,KAAK,CAAC,WAAW,EAAE,IAAI,cAAc,GAAG,GAAG,EAAE,CAAC;AAC5D,CAAC;AAED,SAAS,uBAAuB,CAAC,KAAa,EAAE,GAAW;IACzD,OAAO,KAAK,KAAK,IAAI,cAAc,GAAG,GAAG,EAAE,CAAC;AAC9C,CAAC;AAED,KAAK,UAAU,qBAAqB,CAClC,UAAkB;IAElB,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IACpE,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAC7B,sEAAsE;IACtE,uEAAuE;IACvE,sEAAsE;IACtE,IAAI,CAAC,sBAAsB,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IACnD,IAAI,CAAC;QACH,OAAO,kBAAkB,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,uEAAuE;QACvE,iEAAiE;QACjE,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,GAAW,EACX,GAA0D;IAE1D,IAAI,CAAC,GAAG,EAAE,SAAS;QAAE,OAAO,SAAS,CAAC;IACtC,IAAI,GAAG,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,GAAG,CAAC,KAAK;YAAE,OAAO,SAAS,CAAC;QACjC,OAAO,qBAAqB,CAAC,uBAAuB,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,qBAAqB,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,GAAW,EACX,GAAsB;IAEtB,IAAI,CAAC,GAAG,EAAE,SAAS;QAAE,OAAO,SAAS,CAAC;IAEtC,MAAM,WAAW,GAAG,MAAM,yBAAyB,CAAC,GAAG,EAAE;QACvD,GAAG,GAAG;QACN,KAAK,EAAE,MAAM;KACd,CAAC,CAAC;IACH,IAAI,WAAW;QAAE,OAAO,WAAW,CAAC;IAEpC,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;QACd,OAAO,yBAAyB,CAAC,GAAG,EAAE,EAAE,GAAG,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAClE,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,GAAW,EACX,GAAsB;IAEtB,OAAO,CAAC,MAAM,iBAAiB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,KAAK,SAAS,CAAC;AAC3D,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAW,EACX,KAAa,EACb,GAAmD;IAEnD,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,0DAA0D,CAAC,CAAC;IAC9E,CAAC;IACD,uEAAuE;IACvE,oEAAoE;IACpE,yBAAyB;IACzB,MAAM,SAAS,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAC5C,IAAI,GAAG,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;QAC/D,CAAC;QACD,MAAM,UAAU,CAAC,uBAAuB,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE;YACxD,KAAK,EAAE,SAAS;SACjB,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IACD,MAAM,UAAU,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,EAAE;QAC7D,KAAK,EAAE,SAAS;KACjB,CAAC,CAAC;AACL,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAW,EACX,GAAmD;IAEnD,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CACb,4DAA4D,CAC7D,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QACxB,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;QACjE,CAAC;QACD,MAAM,aAAa,CAAC,uBAAuB,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;QAC7D,OAAO;IACT,CAAC;IACD,MAAM,aAAa,CAAC,wBAAwB,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC;AACpE,CAAC","sourcesContent":["import {\n encryptSecretValue,\n decryptSecretValue,\n isEncryptedSecretValue,\n} from \"../secrets/crypto.js\";\nimport { getSetting, putSetting, deleteSetting } from \"../settings/store.js\";\n\nconst SETTING_PREFIX = \"credential:\";\n\nexport interface CredentialContext {\n userEmail: string;\n orgId?: string | null;\n}\n\nexport type CredentialStorageScope = \"user\" | \"org\";\n\nfunction userCredentialSettingKey(email: string, key: string): string {\n return `u:${email.toLowerCase()}:${SETTING_PREFIX}${key}`;\n}\n\nfunction orgCredentialSettingKey(orgId: string, key: string): string {\n return `o:${orgId}:${SETTING_PREFIX}${key}`;\n}\n\nasync function readCredentialSetting(\n settingKey: string,\n): Promise<string | undefined> {\n const setting = await getSetting(settingKey);\n if (!setting || typeof setting.value !== \"string\") return undefined;\n const stored = setting.value;\n // Values written by saveCredential are AES-256-GCM encrypted at rest.\n // Rows that predate encryption are plaintext — read them transparently\n // (the migrate-encrypt-credentials script re-encrypts them in place).\n if (!isEncryptedSecretValue(stored)) return stored;\n try {\n return decryptSecretValue(stored);\n } catch {\n // Key rotated, corrupt, or tampered row — treat as not set rather than\n // surfacing ciphertext or throwing into every credential lookup.\n return undefined;\n }\n}\n\n/**\n * Resolve a credential from one explicit legacy SQL credential scope.\n *\n * Prefer `resolveCredential()` for normal app-local credential lookup. This\n * helper exists for workspace connection refs, where a ref can explicitly say\n * \"use the org-scoped key\" and must not accidentally read a user override.\n */\nexport async function resolveCredentialForScope(\n key: string,\n ctx: CredentialContext & { scope: CredentialStorageScope },\n): Promise<string | undefined> {\n if (!ctx?.userEmail) return undefined;\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) return undefined;\n return readCredentialSetting(orgCredentialSettingKey(ctx.orgId, key));\n }\n return readCredentialSetting(userCredentialSettingKey(ctx.userEmail, key));\n}\n\n/**\n * Resolve a credential, scoped to the caller's user (and falling back to\n * the active org's shared credential, if any).\n *\n * SECURITY: NEVER reads from process.env. Env vars are global to the\n * deployment and would leak across users in a multi-tenant app. The only\n * sources are per-user / per-org rows in the SQL `settings` table.\n *\n * Storage keys (priority order):\n * 1. u:<email>:credential:<KEY> — per-user override\n * 2. o:<orgId>:credential:<KEY> — per-org shared credential (if orgId given)\n */\nexport async function resolveCredential(\n key: string,\n ctx: CredentialContext,\n): Promise<string | undefined> {\n if (!ctx?.userEmail) return undefined;\n\n const userSetting = await resolveCredentialForScope(key, {\n ...ctx,\n scope: \"user\",\n });\n if (userSetting) return userSetting;\n\n if (ctx.orgId) {\n return resolveCredentialForScope(key, { ...ctx, scope: \"org\" });\n }\n\n return undefined;\n}\n\n/**\n * Check if a credential is available for the given context.\n */\nexport async function hasCredential(\n key: string,\n ctx: CredentialContext,\n): Promise<boolean> {\n return (await resolveCredential(key, ctx)) !== undefined;\n}\n\n/**\n * Save a credential. By default writes to the per-user store; pass\n * `scope: \"org\"` to write to the active org's shared credentials.\n */\nexport async function saveCredential(\n key: string,\n value: string,\n ctx: CredentialContext & { scope?: \"user\" | \"org\" },\n): Promise<void> {\n if (!ctx?.userEmail) {\n throw new Error(\"saveCredential requires CredentialContext with userEmail\");\n }\n // Encrypt at rest (AES-256-GCM) so a leaked DB backup / pg_dump / read\n // replica doesn't expose plaintext keys. resolveCredential decrypts\n // transparently on read.\n const encrypted = encryptSecretValue(value);\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) {\n throw new Error(\"saveCredential scope='org' requires orgId\");\n }\n await putSetting(orgCredentialSettingKey(ctx.orgId, key), {\n value: encrypted,\n });\n return;\n }\n await putSetting(userCredentialSettingKey(ctx.userEmail, key), {\n value: encrypted,\n });\n}\n\n/**\n * Delete a credential from the per-user (default) or per-org store.\n */\nexport async function deleteCredential(\n key: string,\n ctx: CredentialContext & { scope?: \"user\" | \"org\" },\n): Promise<void> {\n if (!ctx?.userEmail) {\n throw new Error(\n \"deleteCredential requires CredentialContext with userEmail\",\n );\n }\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) {\n throw new Error(\"deleteCredential scope='org' requires orgId\");\n }\n await deleteSetting(orgCredentialSettingKey(ctx.orgId, key));\n return;\n }\n await deleteSetting(userCredentialSettingKey(ctx.userEmail, key));\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/credentials/index.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n encryptSecretValue,\n decryptSecretValue,\n isEncryptedSecretValue,\n} from \"../secrets/crypto.js\";\nimport { readAppSecret, type SecretRef } from \"../secrets/storage.js\";\nimport { getSetting, putSetting, deleteSetting } from \"../settings/store.js\";\n\nconst SETTING_PREFIX = \"credential:\";\n\nexport interface CredentialContext {\n userEmail: string;\n orgId?: string | null;\n}\n\nexport type CredentialStorageScope = \"user\" | \"org\";\n\nfunction userCredentialSettingKey(email: string, key: string): string {\n return `u:${email.toLowerCase()}:${SETTING_PREFIX}${key}`;\n}\n\nfunction orgCredentialSettingKey(orgId: string, key: string): string {\n return `o:${orgId}:${SETTING_PREFIX}${key}`;\n}\n\nasync function readCredentialSetting(\n settingKey: string,\n): Promise<string | undefined> {\n const setting = await getSetting(settingKey);\n if (!setting || typeof setting.value !== \"string\") return undefined;\n const stored = setting.value;\n // Values written by saveCredential are AES-256-GCM encrypted at rest.\n // Rows that predate encryption are plaintext — read them transparently\n // (the migrate-encrypt-credentials script re-encrypts them in place).\n if (!isEncryptedSecretValue(stored)) return stored;\n try {\n return decryptSecretValue(stored);\n } catch {\n // Key rotated, corrupt, or tampered row — treat as not set rather than\n // surfacing ciphertext or throwing into every credential lookup.\n return undefined;\n }\n}\n\nasync function readScopedAppSecret(\n key: string,\n scope: SecretRef[\"scope\"],\n scopeId: string,\n): Promise<string | undefined> {\n try {\n return (await readAppSecret({ key, scope, scopeId }))?.value;\n } catch {\n // Older databases may not have app_secrets yet. Keep the legacy\n // credential store available while the table bootstraps.\n return undefined;\n }\n}\n\n/**\n * Resolve a credential from one explicit legacy SQL credential scope.\n *\n * Prefer `resolveCredential()` for normal app-local credential lookup. This\n * helper exists for workspace connection refs, where a ref can explicitly say\n * \"use the org-scoped key\" and must not accidentally read a user override.\n */\nexport async function resolveCredentialForScope(\n key: string,\n ctx: CredentialContext & { scope: CredentialStorageScope },\n): Promise<string | undefined> {\n if (!ctx?.userEmail) return undefined;\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) return undefined;\n return readCredentialSetting(orgCredentialSettingKey(ctx.orgId, key));\n }\n return readCredentialSetting(userCredentialSettingKey(ctx.userEmail, key));\n}\n\n/**\n * Resolve a credential across the encrypted app_secrets store and the legacy\n * settings-backed credential store. User overrides win, followed by the\n * active org/workspace shared value.\n *\n * SECURITY: NEVER reads from process.env. Env vars are global to the\n * deployment and would leak across users in a multi-tenant app.\n *\n * Read order:\n * 1. user-scoped app_secrets\n * 2. user-scoped legacy settings credential\n * 3. org-scoped app_secrets\n * 4. legacy workspace-scoped app_secrets for the org\n * 5. org-scoped legacy settings credential\n *\n * Without an active org, step 3 is replaced by the solo workspace scope.\n */\nexport async function resolveCredential(\n key: string,\n ctx: CredentialContext,\n): Promise<string | undefined> {\n if (!ctx?.userEmail) return undefined;\n\n const userSecret = await readScopedAppSecret(key, \"user\", ctx.userEmail);\n if (userSecret) return userSecret;\n\n const userSetting = await resolveCredentialForScope(key, {\n ...ctx,\n scope: \"user\",\n });\n if (userSetting) return userSetting;\n\n if (ctx.orgId) {\n const orgSecret = await readScopedAppSecret(key, \"org\", ctx.orgId);\n if (orgSecret) return orgSecret;\n\n const workspaceSecret = await readScopedAppSecret(\n key,\n \"workspace\",\n ctx.orgId,\n );\n if (workspaceSecret) return workspaceSecret;\n\n return resolveCredentialForScope(key, { ...ctx, scope: \"org\" });\n }\n\n return readScopedAppSecret(key, \"workspace\", `solo:${ctx.userEmail}`);\n}\n\n/**\n * Check if a credential is available for the given context.\n */\nexport async function hasCredential(\n key: string,\n ctx: CredentialContext,\n): Promise<boolean> {\n return (await resolveCredential(key, ctx)) !== undefined;\n}\n\n/**\n * Save a credential. By default writes to the per-user store; pass\n * `scope: \"org\"` to write to the active org's shared credentials.\n */\nexport async function saveCredential(\n key: string,\n value: string,\n ctx: CredentialContext & { scope?: \"user\" | \"org\" },\n): Promise<void> {\n if (!ctx?.userEmail) {\n throw new Error(\"saveCredential requires CredentialContext with userEmail\");\n }\n // Encrypt at rest (AES-256-GCM) so a leaked DB backup / pg_dump / read\n // replica doesn't expose plaintext keys. resolveCredential decrypts\n // transparently on read.\n const encrypted = encryptSecretValue(value);\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) {\n throw new Error(\"saveCredential scope='org' requires orgId\");\n }\n await putSetting(orgCredentialSettingKey(ctx.orgId, key), {\n value: encrypted,\n });\n return;\n }\n await putSetting(userCredentialSettingKey(ctx.userEmail, key), {\n value: encrypted,\n });\n}\n\n/**\n * Delete a credential from the per-user (default) or per-org store.\n */\nexport async function deleteCredential(\n key: string,\n ctx: CredentialContext & { scope?: \"user\" | \"org\" },\n): Promise<void> {\n if (!ctx?.userEmail) {\n throw new Error(\n \"deleteCredential requires CredentialContext with userEmail\",\n );\n }\n if (ctx.scope === \"org\") {\n if (!ctx.orgId) {\n throw new Error(\"deleteCredential scope='org' requires orgId\");\n }\n await deleteSetting(orgCredentialSettingKey(ctx.orgId, key));\n return;\n }\n await deleteSetting(userCredentialSettingKey(ctx.userEmail, key));\n}\n"]}
@@ -10,15 +10,27 @@
10
10
  * the raw secret never enters the model's context.
11
11
  */
12
12
  import type { ActionEntry } from "../agent/production-agent.js";
13
+ import type { ResolvedKeyReference } from "../secrets/substitution.js";
13
14
  export interface FetchToolOptions {
14
15
  /** Resolve ${keys.NAME} references. Injected by the plugin at setup time. */
15
16
  resolveKeys?: (text: string) => Promise<{
16
17
  resolved: string;
17
18
  usedKeys: string[];
18
19
  secretValues?: string[];
20
+ /**
21
+ * Optional: which scope (user/org/workspace) each used key actually
22
+ * resolved at. Populated by resolvers like
23
+ * `resolveKeyReferencesWithRequestScopes` so `validateUrl` can look up
24
+ * the allowlist at the scope the value came from rather than assuming
25
+ * user scope. Backwards compatible — callers that omit this (or a
26
+ * resolver that doesn't report it) are unaffected.
27
+ */
28
+ resolvedKeys?: ResolvedKeyReference[];
19
29
  }>;
20
30
  /** Validate URL against per-key allowlists. */
21
- validateUrl?: (url: string, usedKeys: string[]) => Promise<boolean>;
31
+ validateUrl?: (url: string, usedKeys: string[],
32
+ /** Accumulated `resolvedKeys` from every `resolveKeys` call this request made, when the resolver reported them. */
33
+ resolvedKeys?: ResolvedKeyReference[]) => Promise<boolean>;
22
34
  }
23
35
  /**
24
36
  * Create the fetch tool entry for the agent tool registry.
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-tool.d.ts","sourceRoot":"","sources":["../../src/extensions/fetch-tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AA6DhE,MAAM,WAAW,gBAAgB;IAC/B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QACtC,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;KACzB,CAAC,CAAC;IACH,+CAA+C;IAC/C,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACrE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,GAAE,gBAAqB,GAC1B,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CA+U7B"}
1
+ {"version":3,"file":"fetch-tool.d.ts","sourceRoot":"","sources":["../../src/extensions/fetch-tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AA6DvE,MAAM,WAAW,gBAAgB;IAC/B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QACtC,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;QACxB;;;;;;;WAOG;QACH,YAAY,CAAC,EAAE,oBAAoB,EAAE,CAAC;KACvC,CAAC,CAAC;IACH,+CAA+C;IAC/C,WAAW,CAAC,EAAE,CACZ,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAAE;IAClB,mHAAmH;IACnH,YAAY,CAAC,EAAE,oBAAoB,EAAE,KAClC,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,GAAE,gBAAqB,GAC1B,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CA8V7B"}
@@ -153,21 +153,25 @@ export function createFetchToolEntry(opts = {}) {
153
153
  let resolvedBody = rawBody;
154
154
  const allUsedKeys = [];
155
155
  const allSecretValues = [];
156
+ const allResolvedKeys = [];
156
157
  if (opts.resolveKeys) {
157
158
  try {
158
159
  const urlResult = await opts.resolveKeys(rawUrl);
159
160
  resolvedUrl = urlResult.resolved;
160
161
  allUsedKeys.push(...urlResult.usedKeys);
161
162
  allSecretValues.push(...(urlResult.secretValues ?? []));
163
+ allResolvedKeys.push(...(urlResult.resolvedKeys ?? []));
162
164
  const headerResult = await opts.resolveKeys(rawHeaders);
163
165
  resolvedHeaders = headerResult.resolved;
164
166
  allUsedKeys.push(...headerResult.usedKeys);
165
167
  allSecretValues.push(...(headerResult.secretValues ?? []));
168
+ allResolvedKeys.push(...(headerResult.resolvedKeys ?? []));
166
169
  if (rawBody) {
167
170
  const bodyResult = await opts.resolveKeys(rawBody);
168
171
  resolvedBody = bodyResult.resolved;
169
172
  allUsedKeys.push(...bodyResult.usedKeys);
170
173
  allSecretValues.push(...(bodyResult.secretValues ?? []));
174
+ allResolvedKeys.push(...(bodyResult.resolvedKeys ?? []));
171
175
  }
172
176
  }
173
177
  catch (err) {
@@ -175,6 +179,9 @@ export function createFetchToolEntry(opts = {}) {
175
179
  }
176
180
  }
177
181
  const secretValues = collectSecretValues(allSecretValues);
182
+ const resolvedKeys = allResolvedKeys.length
183
+ ? allResolvedKeys
184
+ : undefined;
178
185
  // Block SSRF targets regardless of key usage
179
186
  if (await isBlockedExtensionUrlWithDns(resolvedUrl)) {
180
187
  return `Requests to private/internal addresses are not allowed: "${rawUrl}".`;
@@ -182,7 +189,7 @@ export function createFetchToolEntry(opts = {}) {
182
189
  // Validate URL against per-key allowlists
183
190
  if (opts.validateUrl && allUsedKeys.length > 0) {
184
191
  try {
185
- const allowed = await opts.validateUrl(resolvedUrl, allUsedKeys);
192
+ const allowed = await opts.validateUrl(resolvedUrl, allUsedKeys, resolvedKeys);
186
193
  if (!allowed) {
187
194
  return `URL "${rawUrl}" is not in the allowlist for the referenced keys. Check your key settings.`;
188
195
  }
@@ -236,7 +243,7 @@ export function createFetchToolEntry(opts = {}) {
236
243
  return "Redirect to private/internal address blocked.";
237
244
  }
238
245
  if (redirectUrl && opts.validateUrl && allUsedKeys.length > 0) {
239
- const allowed = await opts.validateUrl(redirectUrl, allUsedKeys);
246
+ const allowed = await opts.validateUrl(redirectUrl, allUsedKeys, resolvedKeys);
240
247
  if (!allowed) {
241
248
  return "Redirect URL is not in the allowlist for the referenced keys.";
242
249
  }