@agent-native/toolkit 0.227.0 → 0.228.0-nightly-20261111004142

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 (144) hide show
  1. package/agent-native.eject.json +8 -0
  2. package/dist/app/agent-page/AgentTabFrame.d.ts +2 -1
  3. package/dist/app/agent-page/AgentTabFrame.d.ts.map +1 -1
  4. package/dist/app/agent-page/AgentTabFrame.js +3 -2
  5. package/dist/app/agent-page/AgentTabFrame.js.map +1 -1
  6. package/dist/app/chat/AgentPanel.d.ts +15 -15
  7. package/dist/app/chat/AgentPanel.d.ts.map +1 -1
  8. package/dist/app/chat/AgentPanel.js.map +1 -1
  9. package/dist/app/chat/AgentSidebar.d.ts +7 -1
  10. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  11. package/dist/app/chat/AgentSidebar.js +47 -16
  12. package/dist/app/chat/AgentSidebar.js.map +1 -1
  13. package/dist/app/chat/chat/surface-types.d.ts +26 -26
  14. package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
  15. package/dist/app/extensions/EmbeddedExtension.d.ts.map +1 -1
  16. package/dist/app/extensions/EmbeddedExtension.js +6 -4
  17. package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
  18. package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
  19. package/dist/app/extensions/ExtensionViewer.js +6 -3
  20. package/dist/app/extensions/ExtensionViewer.js.map +1 -1
  21. package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
  22. package/dist/app/extensions/InlineExtensionFrame.js +32 -28
  23. package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
  24. package/dist/app/extensions/use-extension-chat-submission.d.ts +14 -0
  25. package/dist/app/extensions/use-extension-chat-submission.d.ts.map +1 -0
  26. package/dist/app/extensions/use-extension-chat-submission.js +36 -0
  27. package/dist/app/extensions/use-extension-chat-submission.js.map +1 -0
  28. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  29. package/dist/app/i18n/catalogs/ar-SA.js +16 -0
  30. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  31. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  32. package/dist/app/i18n/catalogs/de-DE.js +12 -0
  33. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  34. package/dist/app/i18n/catalogs/en-US.d.ts +12 -0
  35. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/en-US.js +12 -0
  37. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  38. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/es-ES.js +13 -0
  40. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  41. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  42. package/dist/app/i18n/catalogs/fr-FR.js +13 -0
  43. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  44. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  45. package/dist/app/i18n/catalogs/hi-IN.js +12 -0
  46. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  47. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  48. package/dist/app/i18n/catalogs/ja-JP.js +12 -0
  49. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  50. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  51. package/dist/app/i18n/catalogs/ko-KR.js +12 -0
  52. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  53. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  54. package/dist/app/i18n/catalogs/pt-BR.js +13 -0
  55. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  56. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  57. package/dist/app/i18n/catalogs/zh-CN.js +12 -0
  58. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  59. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  60. package/dist/app/i18n/catalogs/zh-TW.js +12 -0
  61. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  62. package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
  63. package/dist/app/org/OrgSwitcher.js +3 -2
  64. package/dist/app/org/OrgSwitcher.js.map +1 -1
  65. package/dist/app/resources/McpAccessSettings.d.ts +9 -2
  66. package/dist/app/resources/McpAccessSettings.d.ts.map +1 -1
  67. package/dist/app/resources/McpAccessSettings.js +264 -86
  68. package/dist/app/resources/McpAccessSettings.js.map +1 -1
  69. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts +6 -0
  70. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts.map +1 -1
  71. package/dist/app/settings/api-keys/ApiKeyDialogs.js +74 -2
  72. package/dist/app/settings/api-keys/ApiKeyDialogs.js.map +1 -1
  73. package/dist/app/settings/api-keys/ApiKeysSettingsPage.d.ts.map +1 -1
  74. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js +23 -9
  75. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js.map +1 -1
  76. package/dist/app/settings/api-keys/api-keys-client.d.ts +10 -1
  77. package/dist/app/settings/api-keys/api-keys-client.d.ts.map +1 -1
  78. package/dist/app/settings/api-keys/api-keys-client.js +19 -1
  79. package/dist/app/settings/api-keys/api-keys-client.js.map +1 -1
  80. package/dist/app/settings/shell/pages/mcp.d.ts +1 -1
  81. package/dist/app/settings/shell/pages/mcp.d.ts.map +1 -1
  82. package/dist/app/settings/shell/pages/mcp.js +26 -20
  83. package/dist/app/settings/shell/pages/mcp.js.map +1 -1
  84. package/dist/app/sharing/ShareButton.d.ts +2 -0
  85. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  86. package/dist/app/sharing/ShareButton.js +4 -3
  87. package/dist/app/sharing/ShareButton.js.map +1 -1
  88. package/dist/composer/TiptapComposer.js +3 -0
  89. package/dist/composer/TiptapComposer.js.map +1 -1
  90. package/dist/sharing/ShareControls.d.ts +2 -0
  91. package/dist/sharing/ShareControls.d.ts.map +1 -1
  92. package/dist/sharing/ShareControls.js +7 -2
  93. package/dist/sharing/ShareControls.js.map +1 -1
  94. package/dist/ui/index.d.ts +1 -0
  95. package/dist/ui/index.d.ts.map +1 -1
  96. package/dist/ui/index.js +1 -0
  97. package/dist/ui/index.js.map +1 -1
  98. package/dist/ui/numbered-steps.d.ts +14 -0
  99. package/dist/ui/numbered-steps.d.ts.map +1 -0
  100. package/dist/ui/numbered-steps.js +19 -0
  101. package/dist/ui/numbered-steps.js.map +1 -0
  102. package/package.json +4 -4
  103. package/src/app/agent-page/AgentTabFrame.tsx +21 -10
  104. package/src/app/chat/AgentPanel.header.spec.ts +4 -1
  105. package/src/app/chat/AgentPanel.tsx +19 -14
  106. package/src/app/chat/AgentSidebar.spec.tsx +167 -0
  107. package/src/app/chat/AgentSidebar.tsx +170 -101
  108. package/src/app/chat/chat/surface-types.ts +35 -31
  109. package/src/app/extensions/EmbeddedExtension.height.spec.tsx +45 -0
  110. package/src/app/extensions/EmbeddedExtension.tsx +6 -3
  111. package/src/app/extensions/ExtensionViewer.spec.tsx +28 -0
  112. package/src/app/extensions/ExtensionViewer.tsx +6 -2
  113. package/src/app/extensions/InlineExtensionFrame.spec.tsx +23 -0
  114. package/src/app/extensions/InlineExtensionFrame.tsx +23 -15
  115. package/src/app/extensions/use-extension-chat-submission.tsx +101 -0
  116. package/src/app/i18n/catalogs/ar-SA.ts +17 -0
  117. package/src/app/i18n/catalogs/de-DE.ts +15 -0
  118. package/src/app/i18n/catalogs/en-US.ts +13 -0
  119. package/src/app/i18n/catalogs/es-ES.ts +16 -0
  120. package/src/app/i18n/catalogs/fr-FR.ts +16 -0
  121. package/src/app/i18n/catalogs/hi-IN.ts +13 -0
  122. package/src/app/i18n/catalogs/ja-JP.ts +15 -0
  123. package/src/app/i18n/catalogs/ko-KR.ts +14 -0
  124. package/src/app/i18n/catalogs/pt-BR.ts +15 -0
  125. package/src/app/i18n/catalogs/zh-CN.ts +13 -0
  126. package/src/app/i18n/catalogs/zh-TW.ts +13 -0
  127. package/src/app/integrations/IntegrationsPanel.spec.tsx +1 -1
  128. package/src/app/org/OrgSwitcher.spec.tsx +43 -0
  129. package/src/app/org/OrgSwitcher.tsx +29 -24
  130. package/src/app/resources/McpAccessSettings.spec.tsx +354 -278
  131. package/src/app/resources/McpAccessSettings.tsx +1012 -323
  132. package/src/app/settings/api-keys/ApiKeyDialogs.tsx +148 -1
  133. package/src/app/settings/api-keys/ApiKeysSettingsPage.spec.tsx +190 -0
  134. package/src/app/settings/api-keys/ApiKeysSettingsPage.tsx +40 -1
  135. package/src/app/settings/api-keys/api-keys-client.spec.ts +49 -0
  136. package/src/app/settings/api-keys/api-keys-client.ts +32 -0
  137. package/src/app/settings/shell/pages/app-group-pages.spec.tsx +31 -21
  138. package/src/app/settings/shell/pages/mcp.tsx +50 -42
  139. package/src/app/sharing/ShareButton.tsx +10 -3
  140. package/src/composer/TiptapComposer.tsx +2 -0
  141. package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +16 -0
  142. package/src/sharing/ShareControls.tsx +44 -24
  143. package/src/ui/index.ts +1 -0
  144. package/src/ui/numbered-steps.tsx +104 -0
@@ -37,6 +37,7 @@ import {
37
37
  } from "@agent-native/toolkit/ui/select";
38
38
  import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
39
39
  import { Spinner } from "@agent-native/toolkit/ui/spinner";
40
+ import { Textarea } from "@agent-native/toolkit/ui/textarea";
40
41
  import {
41
42
  IconAlertCircle,
42
43
  IconExternalLink,
@@ -53,7 +54,11 @@ import {
53
54
  } from "../../integrations/index.js";
54
55
  import { effectText } from "../model/RemoveProviderDialog.js";
55
56
  import { normalizeKeyName } from "../NewKeyMenu.js";
56
- import { saveApiKeyValue, notifyKeysChanged } from "./api-keys-client.js";
57
+ import {
58
+ saveApiKeyValue,
59
+ updateApiKeyOrigins,
60
+ notifyKeysChanged,
61
+ } from "./api-keys-client.js";
57
62
  import {
58
63
  addableSuggestions,
59
64
  addKeyTarget,
@@ -64,6 +69,26 @@ import {
64
69
  const K = "agentChat.settingsApiKeys.";
65
70
  const M = "agentChat.settingsModel.";
66
71
 
72
+ function parseExactOrigins(raw: string): string[] | null {
73
+ const origins = raw
74
+ .split(/\r?\n/)
75
+ .map((line) => line.trim())
76
+ .filter(Boolean);
77
+ for (const origin of origins) {
78
+ if (!URL.canParse(origin)) return null;
79
+ const url = new URL(origin);
80
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
81
+ if (
82
+ url.origin !== origin ||
83
+ url.username ||
84
+ url.password ||
85
+ (url.protocol !== "https:" && !(url.protocol === "http:" && local))
86
+ )
87
+ return null;
88
+ }
89
+ return [...new Set(origins)];
90
+ }
91
+
67
92
  function refreshKeys(queryClient: ReturnType<typeof useQueryClient>) {
68
93
  return queryClient.invalidateQueries({ queryKey: ["action"] });
69
94
  }
@@ -119,6 +144,98 @@ export function KeyValueDialog(props: KeyValueDialogProps) {
119
144
  );
120
145
  }
121
146
 
147
+ /** A saved custom key's policy can be approved without exposing its value. */
148
+ export function KeyOriginsDialog({
149
+ entry,
150
+ open,
151
+ onOpenChange,
152
+ }: {
153
+ entry: ApiKeyEntry;
154
+ open: boolean;
155
+ onOpenChange: (open: boolean) => void;
156
+ }) {
157
+ const t = useT();
158
+ const queryClient = useQueryClient();
159
+ const id = useId();
160
+ const [origins, setOrigins] = useState(entry.urlAllowlist?.join("\n") ?? "");
161
+ const [saving, setSaving] = useState(false);
162
+ const [error, setError] = useState<string | null>(null);
163
+
164
+ const save = async () => {
165
+ const parsed = parseExactOrigins(origins);
166
+ if (parsed === null) {
167
+ setError(t(`${K}invalidOrigins`));
168
+ return;
169
+ }
170
+ setSaving(true);
171
+ setError(null);
172
+ try {
173
+ await updateApiKeyOrigins({
174
+ name: entry.name,
175
+ scope: entry.storedScope,
176
+ owner: entry.scope,
177
+ urlAllowlist: parsed,
178
+ });
179
+ await refreshKeys(queryClient);
180
+ toast.success(t(`${K}originsSaved`));
181
+ onOpenChange(false);
182
+ } catch (cause) {
183
+ setError(cause instanceof Error ? cause.message : String(cause));
184
+ } finally {
185
+ setSaving(false);
186
+ }
187
+ };
188
+
189
+ return (
190
+ <Dialog open={open} onOpenChange={onOpenChange}>
191
+ <DialogContent className="max-w-lg" aria-describedby={undefined}>
192
+ <DialogHeader>
193
+ <DialogTitle>{t(`${K}editOrigins`)}</DialogTitle>
194
+ </DialogHeader>
195
+ <form
196
+ className="grid gap-4"
197
+ onSubmit={(event) => {
198
+ event.preventDefault();
199
+ void save();
200
+ }}
201
+ >
202
+ <div className="grid gap-2">
203
+ <Label htmlFor={id}>{t(`${K}approvedOrigins`)}</Label>
204
+ <Textarea
205
+ id={id}
206
+ value={origins}
207
+ rows={3}
208
+ spellCheck={false}
209
+ placeholder="https://api.example.com"
210
+ onChange={(event) => {
211
+ setOrigins(event.target.value);
212
+ setError(null);
213
+ }}
214
+ />
215
+ <p className="text-xs text-muted-foreground">
216
+ {t(`${K}originsHint`)}
217
+ </p>
218
+ </div>
219
+ <FormError message={error} />
220
+ <DialogFooter>
221
+ <Button
222
+ type="button"
223
+ variant="secondary"
224
+ onClick={() => onOpenChange(false)}
225
+ >
226
+ {t(`${M}cancel`)}
227
+ </Button>
228
+ <Button type="submit" disabled={saving}>
229
+ {saving ? <Spinner aria-hidden /> : null}
230
+ {t(`${M}save`)}
231
+ </Button>
232
+ </DialogFooter>
233
+ </form>
234
+ </DialogContent>
235
+ </Dialog>
236
+ );
237
+ }
238
+
122
239
  /** False once the dialog content unmounts, so a late save can't close a newer dialog. */
123
240
  function useMounted() {
124
241
  const mounted = useRef(true);
@@ -161,6 +278,7 @@ function KeyValueDialogContent({
161
278
  nameHint: useId(),
162
279
  value: useId(),
163
280
  who: useId(),
281
+ origins: useId(),
164
282
  };
165
283
  const replacing = dialog.mode === "replace" ? dialog.entry : null;
166
284
  const forService = dialog.mode === "add" && dialog.forService === true;
@@ -169,6 +287,9 @@ function KeyValueDialogContent({
169
287
  (dialog.mode === "add" ? (dialog.initialName ?? "") : ""),
170
288
  );
171
289
  const [value, setValue] = useState("");
290
+ const [origins, setOrigins] = useState(
291
+ replacing?.urlAllowlist?.join("\n") ?? "",
292
+ );
172
293
  const [shared, setShared] = useState(true);
173
294
  const [saving, setSaving] = useState(false);
174
295
  const [error, setError] = useState<string | null>(null);
@@ -205,9 +326,15 @@ function KeyValueDialogContent({
205
326
  lockedShared === null && listing.hasOrganization && listing.canManageOrg;
206
327
  const isShared = lockedShared ?? (canChoose && shared);
207
328
  const valid = !blocked && target.kind !== "empty" && !!value.trim();
329
+ const customOrigins =
330
+ target.kind === "custom" ? parseExactOrigins(origins) : [];
208
331
 
209
332
  const save = async () => {
210
333
  if (saving || !valid) return;
334
+ if (customOrigins === null) {
335
+ setError(t(`${K}invalidOrigins`));
336
+ return;
337
+ }
211
338
  setSaving(true);
212
339
  onSavingChange?.(true);
213
340
  setError(null);
@@ -218,6 +345,7 @@ function KeyValueDialogContent({
218
345
  value: value.trim(),
219
346
  registered: target.kind === "registered",
220
347
  shared: isShared,
348
+ ...(target.kind === "custom" ? { urlAllowlist: customOrigins } : {}),
221
349
  }),
222
350
  );
223
351
  void refreshKeys(queryClient);
@@ -328,6 +456,25 @@ function KeyValueDialogContent({
328
456
  </a>
329
457
  ) : null}
330
458
  </div>
459
+ {target.kind === "custom" ? (
460
+ <div className="grid gap-2">
461
+ <Label htmlFor={ids.origins}>{t(`${K}approvedOrigins`)}</Label>
462
+ <Textarea
463
+ id={ids.origins}
464
+ value={origins}
465
+ rows={2}
466
+ spellCheck={false}
467
+ placeholder="https://api.example.com"
468
+ onChange={(event) => {
469
+ setOrigins(event.target.value);
470
+ setError(null);
471
+ }}
472
+ />
473
+ <p className="text-xs text-muted-foreground">
474
+ {t(`${K}originsHint`)}
475
+ </p>
476
+ </div>
477
+ ) : null}
331
478
  {listing.hasOrganization && !blocked ? (
332
479
  <div className="grid gap-2">
333
480
  <span
@@ -18,6 +18,7 @@ const callActionMock = vi.hoisted(() => vi.fn());
18
18
  const navigateMock = vi.hoisted(() => vi.fn());
19
19
  const clientMock = vi.hoisted(() => ({
20
20
  save: vi.fn(),
21
+ updateOrigins: vi.fn(),
21
22
  test: vi.fn(),
22
23
  notify: vi.fn(),
23
24
  }));
@@ -73,6 +74,7 @@ vi.mock("../model/ProviderDialog.js", () => ({
73
74
 
74
75
  vi.mock("./api-keys-client.js", () => ({
75
76
  saveApiKeyValue: clientMock.save,
77
+ updateApiKeyOrigins: clientMock.updateOrigins,
76
78
  testSavedApiKey: clientMock.test,
77
79
  notifyKeysChanged: clientMock.notify,
78
80
  }));
@@ -121,6 +123,7 @@ function entry(overrides: Partial<ApiKeyEntry>): ApiKeyEntry {
121
123
  registered: false,
122
124
  usedFor: [],
123
125
  canReplace: true,
126
+ canEditOrigins: true,
124
127
  canDelete: true,
125
128
  canTest: false,
126
129
  ...overrides,
@@ -216,6 +219,15 @@ function typeInto(input: HTMLInputElement, value: string) {
216
219
  input.dispatchEvent(new Event("input", { bubbles: true }));
217
220
  }
218
221
 
222
+ function typeTextarea(input: HTMLTextAreaElement, value: string) {
223
+ const setter = Object.getOwnPropertyDescriptor(
224
+ HTMLTextAreaElement.prototype,
225
+ "value",
226
+ )!.set!;
227
+ setter.call(input, value);
228
+ input.dispatchEvent(new Event("input", { bubbles: true }));
229
+ }
230
+
219
231
  describe("ApiKeysSettingsPage", () => {
220
232
  let container: HTMLDivElement;
221
233
  let root: Root;
@@ -229,6 +241,7 @@ describe("ApiKeysSettingsPage", () => {
229
241
  callActionMock.mockReset();
230
242
  navigateMock.mockReset();
231
243
  clientMock.save.mockReset();
244
+ clientMock.updateOrigins.mockReset();
232
245
  clientMock.test.mockReset();
233
246
  onboardingOutcomeMock.mockReset();
234
247
  onboardingAbandonmentRequestMock.mockReset();
@@ -412,6 +425,7 @@ describe("ApiKeysSettingsPage", () => {
412
425
  value: "fake-linear-value",
413
426
  registered: false,
414
427
  shared: false,
428
+ urlAllowlist: [],
415
429
  });
416
430
  });
417
431
 
@@ -443,6 +457,180 @@ describe("ApiKeysSettingsPage", () => {
443
457
  value: "fake-linear-value",
444
458
  registered: false,
445
459
  shared: true,
460
+ urlAllowlist: [],
461
+ });
462
+ });
463
+
464
+ it("sends approved exact origins with a new custom key", async () => {
465
+ clientMock.save.mockResolvedValue(undefined);
466
+ await render();
467
+ const header = await renderHeader();
468
+ await act(async () => buttonByText("Add key", header).click());
469
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
470
+ const [name, value] = [
471
+ ...dialog.querySelectorAll("input"),
472
+ ] as HTMLInputElement[];
473
+ await act(async () => typeInto(name!, "WEBHOOK_TOKEN"));
474
+ await act(async () => typeInto(value!, "fake-value"));
475
+ await act(async () =>
476
+ typeTextarea(
477
+ dialog.querySelector("textarea")!,
478
+ "https://api.example.test",
479
+ ),
480
+ );
481
+ await act(async () =>
482
+ dialog
483
+ .querySelector("form")!
484
+ .dispatchEvent(
485
+ new Event("submit", { bubbles: true, cancelable: true }),
486
+ ),
487
+ );
488
+ expect(clientMock.save).toHaveBeenCalledWith({
489
+ name: "WEBHOOK_TOKEN",
490
+ value: "fake-value",
491
+ registered: false,
492
+ shared: false,
493
+ urlAllowlist: ["https://api.example.test"],
494
+ });
495
+ });
496
+
497
+ it("edits an existing custom key's origins without asking for its value", async () => {
498
+ state.listing = listing({ keys: [entry({ urlAllowlist: null })] });
499
+ clientMock.updateOrigins.mockResolvedValue(undefined);
500
+ await render();
501
+ expect(row("secrets:STRIPE_SECRET_KEY").textContent).toContain(
502
+ "No approved origins",
503
+ );
504
+ await act(async () =>
505
+ pointerDown(
506
+ row("secrets:STRIPE_SECRET_KEY").querySelector(
507
+ '[aria-label="Manage STRIPE_SECRET_KEY"]',
508
+ ),
509
+ ),
510
+ );
511
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(
512
+ (node) => node.textContent?.trim() === "Edit origins",
513
+ );
514
+ if (!item) throw new Error("No origin policy menu item");
515
+ await act(async () =>
516
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
517
+ );
518
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
519
+ expect(dialog.querySelector('input[type="password"]')).toBeNull();
520
+ await act(async () =>
521
+ typeTextarea(
522
+ dialog.querySelector("textarea")!,
523
+ "https://api.example.test",
524
+ ),
525
+ );
526
+ await act(async () =>
527
+ dialog
528
+ .querySelector("form")!
529
+ .dispatchEvent(
530
+ new Event("submit", { bubbles: true, cancelable: true }),
531
+ ),
532
+ );
533
+ expect(clientMock.updateOrigins).toHaveBeenCalledWith({
534
+ name: "STRIPE_SECRET_KEY",
535
+ scope: "user",
536
+ owner: "user",
537
+ urlAllowlist: ["https://api.example.test"],
538
+ });
539
+ expect(clientMock.save).not.toHaveBeenCalled();
540
+ });
541
+
542
+ it("offers policy editing for a legacy solo row without replacing its value", async () => {
543
+ state.listing = listing({
544
+ keys: [
545
+ entry({
546
+ name: "OLD_TOKEN",
547
+ storedScope: "workspace",
548
+ canReplace: false,
549
+ canEditOrigins: true,
550
+ urlAllowlist: null,
551
+ }),
552
+ ],
553
+ });
554
+ clientMock.updateOrigins.mockResolvedValue(undefined);
555
+ await render();
556
+ await act(async () =>
557
+ pointerDown(
558
+ row("secrets:user-workspace:OLD_TOKEN").querySelector(
559
+ '[aria-label="Manage OLD_TOKEN"]',
560
+ ),
561
+ ),
562
+ );
563
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(
564
+ (node) => node.textContent?.trim() === "Edit origins",
565
+ );
566
+ if (!item) throw new Error("No origin policy menu item");
567
+ await act(async () =>
568
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
569
+ );
570
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
571
+ await act(async () =>
572
+ typeTextarea(
573
+ dialog.querySelector("textarea")!,
574
+ "https://api.example.test",
575
+ ),
576
+ );
577
+ await act(async () =>
578
+ dialog
579
+ .querySelector("form")!
580
+ .dispatchEvent(
581
+ new Event("submit", { bubbles: true, cancelable: true }),
582
+ ),
583
+ );
584
+ expect(clientMock.updateOrigins).toHaveBeenCalledWith({
585
+ name: "OLD_TOKEN",
586
+ scope: "workspace",
587
+ owner: "user",
588
+ urlAllowlist: ["https://api.example.test"],
589
+ });
590
+ expect(clientMock.save).not.toHaveBeenCalled();
591
+ });
592
+
593
+ it("keeps approved origins when rotating a custom key", async () => {
594
+ state.listing = listing({
595
+ keys: [entry({ urlAllowlist: ["https://api.example.test"] })],
596
+ });
597
+ clientMock.save.mockResolvedValue(undefined);
598
+ await render();
599
+ await act(async () =>
600
+ pointerDown(
601
+ row("secrets:STRIPE_SECRET_KEY").querySelector(
602
+ '[aria-label="Manage STRIPE_SECRET_KEY"]',
603
+ ),
604
+ ),
605
+ );
606
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(
607
+ (node) => node.textContent?.trim() === "Replace value",
608
+ );
609
+ if (!item) throw new Error("No replacement menu item");
610
+ await act(async () =>
611
+ item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
612
+ );
613
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
614
+ expect(dialog.querySelector("textarea")?.value).toBe(
615
+ "https://api.example.test",
616
+ );
617
+ const value = dialog.querySelector(
618
+ 'input[type="password"]',
619
+ ) as HTMLInputElement;
620
+ await act(async () => typeInto(value, "new-fake-value"));
621
+ await act(async () =>
622
+ dialog
623
+ .querySelector("form")!
624
+ .dispatchEvent(
625
+ new Event("submit", { bubbles: true, cancelable: true }),
626
+ ),
627
+ );
628
+ expect(clientMock.save).toHaveBeenCalledWith({
629
+ name: "STRIPE_SECRET_KEY",
630
+ value: "new-fake-value",
631
+ registered: false,
632
+ shared: false,
633
+ urlAllowlist: ["https://api.example.test"],
446
634
  });
447
635
  });
448
636
 
@@ -532,6 +720,7 @@ describe("ApiKeysSettingsPage", () => {
532
720
  value: "fake-custom-value",
533
721
  registered: false,
534
722
  shared: false,
723
+ urlAllowlist: [],
535
724
  });
536
725
 
537
726
  await act(async () => buttonByText("Close", dialog).click());
@@ -769,6 +958,7 @@ describe("DeleteKeyDialog", () => {
769
958
  value: "fake-voyage-value",
770
959
  registered: false,
771
960
  shared: true,
961
+ urlAllowlist: [],
772
962
  });
773
963
  expect(onSaved).toHaveBeenCalled();
774
964
  expect(onOpenChange).toHaveBeenCalledWith(false);
@@ -23,6 +23,7 @@ import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
23
23
  import { TooltipProvider } from "@agent-native/toolkit/ui/tooltip";
24
24
  import {
25
25
  IconDots,
26
+ IconLock,
26
27
  IconKey,
27
28
  IconPlugConnected,
28
29
  IconPlus,
@@ -56,6 +57,7 @@ import {
56
57
  } from "./api-keys-state.js";
57
58
  import {
58
59
  DeleteKeyDialog,
60
+ KeyOriginsDialog,
59
61
  KeyValueDialog,
60
62
  type KeyValueDialogMode,
61
63
  } from "./ApiKeyDialogs.js";
@@ -98,6 +100,9 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
98
100
  null,
99
101
  );
100
102
  const [deleting, setDeleting] = useState<ApiKeyEntry | null>(null);
103
+ const [editingOrigins, setEditingOrigins] = useState<ApiKeyEntry | null>(
104
+ null,
105
+ );
101
106
  const [provider, setProvider] = useState<ProviderDialogState | null>(null);
102
107
  const hashKey = useSecretKeyHash();
103
108
  const handledHashKey = useRef<string | null>(null);
@@ -178,6 +183,7 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
178
183
  onReplace: (entry: ApiKeyEntry) =>
179
184
  setValueDialog({ mode: "replace", entry }),
180
185
  onDelete: setDeleting,
186
+ onEditOrigins: setEditingOrigins,
181
187
  };
182
188
 
183
189
  return (
@@ -260,6 +266,15 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
260
266
  orgName={orgName}
261
267
  />
262
268
  ) : null}
269
+ {editingOrigins ? (
270
+ <KeyOriginsDialog
271
+ entry={editingOrigins}
272
+ open
273
+ onOpenChange={(open) => {
274
+ if (!open) setEditingOrigins(null);
275
+ }}
276
+ />
277
+ ) : null}
263
278
  <ProviderDialog
264
279
  open={provider !== null}
265
280
  onOpenChange={(open) => {
@@ -311,20 +326,31 @@ function KeyRow({
311
326
  onManageProvider,
312
327
  onReplace,
313
328
  onDelete,
329
+ onEditOrigins,
314
330
  }: {
315
331
  entry: ApiKeyEntry;
316
332
  onManageProvider: (entry: ApiKeyEntry) => void;
317
333
  onReplace: (entry: ApiKeyEntry) => void;
318
334
  onDelete: (entry: ApiKeyEntry) => void;
335
+ onEditOrigins: (entry: ApiKeyEntry) => void;
319
336
  }) {
320
337
  const t = useT();
321
338
  const usedBy = <UsedBy entry={entry} />;
322
339
  const hasUse = !!entry.provider || usedByFeatures(entry).length > 0;
340
+ const custom = !entry.registered && !entry.provider;
323
341
  const description: ReactNode = (
324
342
  <>
325
343
  {entry.masked ? <span className="font-mono">{entry.masked}</span> : null}
326
344
  {entry.masked && hasUse ? " · " : null}
327
345
  {hasUse ? usedBy : null}
346
+ {custom && entry.canEditOrigins && (entry.masked || hasUse)
347
+ ? " · "
348
+ : null}
349
+ {custom && entry.canEditOrigins
350
+ ? entry.urlAllowlist?.length
351
+ ? t(`${K}originsCount`, { count: entry.urlAllowlist.length })
352
+ : t(`${K}noApprovedOrigins`)
353
+ : null}
328
354
  </>
329
355
  );
330
356
 
@@ -353,6 +379,7 @@ function KeyRow({
353
379
  entry={entry}
354
380
  onReplace={() => onReplace(entry)}
355
381
  onDelete={() => onDelete(entry)}
382
+ onEditOrigins={() => onEditOrigins(entry)}
356
383
  />
357
384
  );
358
385
  }
@@ -365,7 +392,11 @@ function KeyRow({
365
392
  <span className="font-mono text-[13px]">{entry.name}</span>
366
393
  )
367
394
  }
368
- description={entry.masked || hasUse ? description : undefined}
395
+ description={
396
+ entry.masked || hasUse || (custom && entry.canEditOrigins)
397
+ ? description
398
+ : undefined
399
+ }
369
400
  control={control}
370
401
  />
371
402
  );
@@ -375,10 +406,12 @@ function KeyMenu({
375
406
  entry,
376
407
  onReplace,
377
408
  onDelete,
409
+ onEditOrigins,
378
410
  }: {
379
411
  entry: ApiKeyEntry;
380
412
  onReplace: () => void;
381
413
  onDelete: () => void;
414
+ onEditOrigins: () => void;
382
415
  }) {
383
416
  const t = useT();
384
417
  const test = async () => {
@@ -400,6 +433,12 @@ function KeyMenu({
400
433
  </Button>
401
434
  </DropdownMenuTrigger>
402
435
  <DropdownMenuContent align="end" className="w-44">
436
+ {entry.canEditOrigins ? (
437
+ <DropdownMenuItem onSelect={onEditOrigins}>
438
+ <IconLock className="size-4" aria-hidden />
439
+ {t(`${K}editOrigins`)}
440
+ </DropdownMenuItem>
441
+ ) : null}
403
442
  {entry.canTest ? (
404
443
  <DropdownMenuItem onSelect={() => void test()}>
405
444
  <IconPlugConnected className="size-4" aria-hidden />
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { saveApiKeyValue, updateApiKeyOrigins } from "./api-keys-client.js";
4
+
5
+ afterEach(() => vi.unstubAllGlobals());
6
+
7
+ describe("custom key requests", () => {
8
+ it("sends an approved origin with a new key, then edits only policy metadata", async () => {
9
+ const requests: Array<{ url: string; init: RequestInit }> = [];
10
+ vi.stubGlobal(
11
+ "fetch",
12
+ vi.fn(async (url: string, init: RequestInit) => {
13
+ requests.push({ url, init });
14
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
15
+ }),
16
+ );
17
+
18
+ await saveApiKeyValue({
19
+ name: "WEBHOOK_TOKEN",
20
+ value: "fake-value",
21
+ registered: false,
22
+ shared: true,
23
+ urlAllowlist: ["https://api.example.test"],
24
+ });
25
+ await updateApiKeyOrigins({
26
+ name: "WEBHOOK_TOKEN",
27
+ scope: "workspace",
28
+ owner: "org",
29
+ urlAllowlist: ["https://new.example.test"],
30
+ });
31
+
32
+ expect(requests[0]?.url).toContain("/_agent-native/secrets/adhoc");
33
+ expect(JSON.parse(String(requests[0]?.init.body))).toEqual({
34
+ name: "WEBHOOK_TOKEN",
35
+ value: "fake-value",
36
+ scope: "workspace",
37
+ urlAllowlist: ["https://api.example.test"],
38
+ });
39
+ expect(requests[1]?.init.method).toBe("PATCH");
40
+ expect(requests[1]?.url).toContain(
41
+ "/_agent-native/secrets/adhoc/WEBHOOK_TOKEN",
42
+ );
43
+ expect(JSON.parse(String(requests[1]?.init.body))).toEqual({
44
+ scope: "workspace",
45
+ owner: "org",
46
+ urlAllowlist: ["https://new.example.test"],
47
+ });
48
+ });
49
+ });
@@ -11,6 +11,8 @@ export interface SaveApiKeyValueInput {
11
11
  registered: boolean;
12
12
  /** Ad-hoc keys only: save for everyone in the organization. */
13
13
  shared?: boolean;
14
+ /** Exact outbound origins for a custom key. Omit when rotating to retain the policy. */
15
+ urlAllowlist?: string[];
14
16
  }
15
17
 
16
18
  async function readError(res: Response): Promise<string | null> {
@@ -41,6 +43,7 @@ export async function saveApiKeyValue({
41
43
  value,
42
44
  registered,
43
45
  shared,
46
+ urlAllowlist,
44
47
  }: SaveApiKeyValueInput): Promise<void> {
45
48
  const res = registered
46
49
  ? await fetch(`${secretsEndpoint()}/${encodeURIComponent(name)}`, {
@@ -55,6 +58,7 @@ export async function saveApiKeyValue({
55
58
  name,
56
59
  value,
57
60
  scope: shared ? "workspace" : "user",
61
+ ...(urlAllowlist === undefined ? {} : { urlAllowlist }),
58
62
  }),
59
63
  });
60
64
  if (!res.ok) {
@@ -65,6 +69,34 @@ export async function saveApiKeyValue({
65
69
  notifyKeysChanged();
66
70
  }
67
71
 
72
+ /** Edit a saved custom key's outbound policy without sending its value. */
73
+ export async function updateApiKeyOrigins(input: {
74
+ name: string;
75
+ scope: "user" | "org" | "workspace";
76
+ owner: "user" | "org";
77
+ urlAllowlist: string[];
78
+ }): Promise<void> {
79
+ const res = await fetch(
80
+ `${secretsEndpoint()}/adhoc/${encodeURIComponent(input.name)}`,
81
+ {
82
+ method: "PATCH",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({
85
+ scope: input.scope,
86
+ owner: input.owner,
87
+ urlAllowlist: input.urlAllowlist,
88
+ }),
89
+ },
90
+ );
91
+ if (!res.ok) {
92
+ throw new Error(
93
+ (await readError(res)) ??
94
+ `Could not update ${input.name} (${res.status})`,
95
+ );
96
+ }
97
+ notifyKeysChanged();
98
+ }
99
+
68
100
  /** Remove a registered key's saved value. Throws the server's message. */
69
101
  export async function removeApiKeyValue(name: string): Promise<void> {
70
102
  const res = await fetch(`${secretsEndpoint()}/${encodeURIComponent(name)}`, {