@agent-native/dispatch 0.40.30-nightly-20261021093329 → 0.40.30

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 (117) hide show
  1. package/CHANGELOG.md +6 -47
  2. package/dist/actions/connect-external-agent.d.ts.map +1 -1
  3. package/dist/actions/connect-external-agent.js +12 -13
  4. package/dist/actions/connect-external-agent.js.map +1 -1
  5. package/dist/actions/create-workspace-resource.d.ts +13 -13
  6. package/dist/actions/delete-destination.d.ts +12 -12
  7. package/dist/actions/delete-workspace-resource.d.ts +13 -13
  8. package/dist/actions/import-agent.d.ts +13 -13
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +8 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/inspect-connect-agent-card.d.ts +5 -0
  13. package/dist/actions/inspect-connect-agent-card.d.ts.map +1 -0
  14. package/dist/actions/inspect-connect-agent-card.js +15 -0
  15. package/dist/actions/inspect-connect-agent-card.js.map +1 -0
  16. package/dist/actions/install-curated-community-app.d.ts +36 -0
  17. package/dist/actions/install-curated-community-app.d.ts.map +1 -0
  18. package/dist/actions/install-curated-community-app.js +26 -0
  19. package/dist/actions/install-curated-community-app.js.map +1 -0
  20. package/dist/actions/list-connect-marketplace-apps.d.ts +3 -0
  21. package/dist/actions/list-connect-marketplace-apps.d.ts.map +1 -0
  22. package/dist/actions/list-connect-marketplace-apps.js +15 -0
  23. package/dist/actions/list-connect-marketplace-apps.js.map +1 -0
  24. package/dist/actions/list-connected-agents.d.ts +2 -0
  25. package/dist/actions/list-curated-community-apps.d.ts +3 -0
  26. package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
  27. package/dist/actions/list-curated-community-apps.js +10 -0
  28. package/dist/actions/list-curated-community-apps.js.map +1 -0
  29. package/dist/actions/provider-api-register.d.ts +14 -14
  30. package/dist/actions/remix-workspace-template.d.ts +6 -0
  31. package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
  32. package/dist/actions/update-workspace-app-metadata.js +26 -5
  33. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  34. package/dist/components/community-apps-section.d.ts +2 -0
  35. package/dist/components/community-apps-section.d.ts.map +1 -0
  36. package/dist/components/community-apps-section.js +90 -0
  37. package/dist/components/community-apps-section.js.map +1 -0
  38. package/dist/components/workspace-app-card.d.ts.map +1 -1
  39. package/dist/components/workspace-app-card.js +63 -9
  40. package/dist/components/workspace-app-card.js.map +1 -1
  41. package/dist/lib/connect-apps.d.ts +1 -2
  42. package/dist/lib/connect-apps.d.ts.map +1 -1
  43. package/dist/lib/connect-apps.js +41 -65
  44. package/dist/lib/connect-apps.js.map +1 -1
  45. package/dist/lib/workspace-apps.d.ts +1 -0
  46. package/dist/lib/workspace-apps.d.ts.map +1 -1
  47. package/dist/lib/workspace-apps.js.map +1 -1
  48. package/dist/routes/pages/apps.d.ts.map +1 -1
  49. package/dist/routes/pages/apps.js +2 -1
  50. package/dist/routes/pages/apps.js.map +1 -1
  51. package/dist/routes/pages/connect.d.ts.map +1 -1
  52. package/dist/routes/pages/connect.js +5 -7
  53. package/dist/routes/pages/connect.js.map +1 -1
  54. package/dist/server/index.d.ts +2 -0
  55. package/dist/server/index.d.ts.map +1 -1
  56. package/dist/server/index.js +2 -0
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/server/lib/app-creation-store.d.ts +11 -1
  59. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  60. package/dist/server/lib/app-creation-store.js +258 -39
  61. package/dist/server/lib/app-creation-store.js.map +1 -1
  62. package/dist/server/lib/community-apps.d.ts +45 -0
  63. package/dist/server/lib/community-apps.d.ts.map +1 -0
  64. package/dist/server/lib/community-apps.js +267 -0
  65. package/dist/server/lib/community-apps.js.map +1 -0
  66. package/dist/server/lib/community-source.d.ts +19 -0
  67. package/dist/server/lib/community-source.d.ts.map +1 -0
  68. package/dist/server/lib/community-source.js +118 -0
  69. package/dist/server/lib/community-source.js.map +1 -0
  70. package/dist/server/lib/connect-apps.d.ts +4 -0
  71. package/dist/server/lib/connect-apps.d.ts.map +1 -0
  72. package/dist/server/lib/connect-apps.js +99 -0
  73. package/dist/server/lib/connect-apps.js.map +1 -0
  74. package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
  75. package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
  76. package/dist/server/lib/org-apps-directory-cache.js +44 -0
  77. package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
  78. package/dist/server/plugins/integrations.js +3 -3
  79. package/dist/server/plugins/integrations.js.map +1 -1
  80. package/dist/shared/labs.d.ts +2 -0
  81. package/dist/shared/labs.d.ts.map +1 -0
  82. package/dist/shared/labs.js +8 -0
  83. package/dist/shared/labs.js.map +1 -0
  84. package/package.json +5 -4
  85. package/src/actions/connect-external-agent.spec.ts +101 -3
  86. package/src/actions/connect-external-agent.ts +12 -11
  87. package/src/actions/index.spec.ts +13 -0
  88. package/src/actions/index.ts +8 -0
  89. package/src/actions/inspect-connect-agent-card.ts +17 -0
  90. package/src/actions/install-curated-community-app.ts +32 -0
  91. package/src/actions/list-connect-marketplace-apps.ts +17 -0
  92. package/src/actions/list-curated-community-apps.ts +12 -0
  93. package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
  94. package/src/actions/update-workspace-app-metadata.ts +48 -24
  95. package/src/components/community-apps-section.tsx +224 -0
  96. package/src/components/workspace-app-card.spec.tsx +226 -1
  97. package/src/components/workspace-app-card.tsx +115 -12
  98. package/src/lib/connect-apps.spec.ts +118 -66
  99. package/src/lib/connect-apps.ts +47 -82
  100. package/src/lib/workspace-apps.ts +1 -0
  101. package/src/routes/pages/apps.spec.tsx +201 -1
  102. package/src/routes/pages/apps.tsx +3 -0
  103. package/src/routes/pages/connect.spec.tsx +53 -6
  104. package/src/routes/pages/connect.tsx +14 -11
  105. package/src/server/index.ts +9 -0
  106. package/src/server/lib/app-creation-store.spec.ts +539 -11
  107. package/src/server/lib/app-creation-store.ts +332 -41
  108. package/src/server/lib/community-apps.spec.ts +367 -0
  109. package/src/server/lib/community-apps.ts +372 -0
  110. package/src/server/lib/community-source.spec.ts +151 -0
  111. package/src/server/lib/community-source.ts +186 -0
  112. package/src/server/lib/connect-apps.ts +149 -0
  113. package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
  114. package/src/server/lib/org-apps-directory-cache.ts +66 -0
  115. package/src/server/plugins/integrations.spec.ts +60 -0
  116. package/src/server/plugins/integrations.ts +3 -3
  117. package/src/shared/labs.ts +9 -0
@@ -3,6 +3,8 @@ import {
3
3
  useActionQuery,
4
4
  } from "@agent-native/core/client/hooks";
5
5
  import { useT } from "@agent-native/core/client/i18n";
6
+ import { useOrgRole } from "@agent-native/core/client/org";
7
+ import { parseAgentProfileMetadata } from "@agent-native/core/shared";
6
8
  import { APP_ACTION_MENU_CONTENT_CLASS } from "@agent-native/toolkit/app/chat/chat-first/app-open-actions";
7
9
  import { ShareButton } from "@agent-native/toolkit/app/sharing";
8
10
  import {
@@ -70,6 +72,23 @@ import { AppResourceEffectiveStack } from "./workspace-resource-effective-stack"
70
72
  const APP_CARD_ACTION_CLASS =
71
73
  "size-7 rounded-md p-0 text-muted-foreground transition-[background-color,color] hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-foreground";
72
74
 
75
+ function normalizeProfileDescription(value: string): string {
76
+ return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
77
+ }
78
+
79
+ function normalizeWhenToUseText(value: string): string {
80
+ return value
81
+ .replace(/\r\n?/g, "\n")
82
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, "");
83
+ }
84
+
85
+ function parseWhenToUseText(value: string): string[] {
86
+ return value
87
+ .split("\n")
88
+ .map((item) => item.trim())
89
+ .filter(Boolean);
90
+ }
91
+
73
92
  function deferWorkspaceAppOverlayOpen(
74
93
  event: { preventDefault: () => void },
75
94
  closeMenu: () => void,
@@ -108,6 +127,12 @@ export function WorkspaceAppCard({
108
127
  onTogglePinned?: () => void;
109
128
  }) {
110
129
  const t = useT();
130
+ const { org, role, isLoading: orgLoading, error: orgError } = useOrgRole();
131
+ const canEditMetadata =
132
+ !orgLoading &&
133
+ !orgError &&
134
+ !!org?.orgId &&
135
+ (role === "owner" || role === "admin");
111
136
  const href = workspaceAppHref(app);
112
137
  const directHref =
113
138
  app.status !== "pending" &&
@@ -130,14 +155,27 @@ export function WorkspaceAppCard({
130
155
  const [resourcesOpen, setResourcesOpen] = useState(false);
131
156
  const [draftName, setDraftName] = useState(app.name);
132
157
  const [draftDescription, setDraftDescription] = useState(
133
- app.description || "",
158
+ normalizeProfileDescription(app.description || ""),
159
+ );
160
+ const [draftWhenToUse, setDraftWhenToUse] = useState(
161
+ app.whenToUse?.join("\n") ?? "",
162
+ );
163
+ const draftWhenToUseValues = parseWhenToUseText(draftWhenToUse);
164
+ const validProfile = Boolean(
165
+ parseAgentProfileMetadata({
166
+ ...(draftDescription.trim()
167
+ ? { description: draftDescription.trim() }
168
+ : {}),
169
+ whenToUse: draftWhenToUseValues,
170
+ }),
134
171
  );
135
172
 
136
173
  useEffect(() => {
137
174
  if (editOpen) return;
138
175
  setDraftName(app.name);
139
- setDraftDescription(app.description || "");
140
- }, [app.description, app.name, editOpen]);
176
+ setDraftDescription(normalizeProfileDescription(app.description || ""));
177
+ setDraftWhenToUse(app.whenToUse?.join("\n") ?? "");
178
+ }, [app.description, app.name, app.whenToUse, editOpen]);
141
179
 
142
180
  const archive = useActionMutation("archive-workspace-app", {
143
181
  onError: (err) =>
@@ -179,10 +217,35 @@ export function WorkspaceAppCard({
179
217
  toast.error("App name is required.");
180
218
  return;
181
219
  }
220
+ const description = draftDescription.trim();
221
+ const currentDescription = normalizeProfileDescription(
222
+ app.description || "",
223
+ ).trim();
224
+ const currentWhenToUse = app.whenToUse ?? [];
225
+ const nameChanged = name !== app.name.trim();
226
+ const descriptionChanged = description !== currentDescription;
227
+ const whenToUseChanged =
228
+ draftWhenToUseValues.length !== currentWhenToUse.length ||
229
+ draftWhenToUseValues.some(
230
+ (example, index) => example !== currentWhenToUse[index],
231
+ );
232
+ if (
233
+ !parseAgentProfileMetadata({
234
+ ...(description ? { description } : {}),
235
+ whenToUse: draftWhenToUseValues,
236
+ })
237
+ ) {
238
+ return;
239
+ }
240
+ if (!nameChanged && !descriptionChanged && !whenToUseChanged) {
241
+ setEditOpen(false);
242
+ return;
243
+ }
182
244
  updateMetadata.mutate({
183
245
  appId: app.id,
184
- name,
185
- description: draftDescription.trim(),
246
+ ...(nameChanged ? { name } : {}),
247
+ ...(descriptionChanged ? { description } : {}),
248
+ ...(whenToUseChanged ? { whenToUse: draftWhenToUseValues } : {}),
186
249
  });
187
250
  };
188
251
  const pinLabel = t(
@@ -247,6 +310,7 @@ export function WorkspaceAppCard({
247
310
  />
248
311
  <WorkspaceAppSettings
249
312
  app={app}
313
+ canEditMetadata={canEditMetadata}
250
314
  isArchived={isArchived}
251
315
  isPending={isPending}
252
316
  onEdit={() => setEditOpen(true)}
@@ -284,10 +348,42 @@ export function WorkspaceAppCard({
284
348
  <Textarea
285
349
  id={`app-description-${app.id}`}
286
350
  value={draftDescription}
287
- maxLength={500}
351
+ maxLength={1000}
288
352
  rows={4}
289
- onChange={(event) => setDraftDescription(event.target.value)}
353
+ onChange={(event) =>
354
+ setDraftDescription(
355
+ normalizeProfileDescription(event.target.value),
356
+ )
357
+ }
358
+ />
359
+ </div>
360
+ <div className="space-y-2">
361
+ <Label htmlFor={`app-when-to-use-${app.id}`}>
362
+ {t("dispatch.pages.appProfileWhenToUse", {
363
+ defaultValue: "When to use",
364
+ })}
365
+ </Label>
366
+ <Textarea
367
+ id={`app-when-to-use-${app.id}`}
368
+ value={draftWhenToUse}
369
+ aria-invalid={!validProfile}
370
+ rows={3}
371
+ placeholder={t(
372
+ "dispatch.pages.appProfileWhenToUsePlaceholder",
373
+ {
374
+ defaultValue: "One example request per line",
375
+ },
376
+ )}
377
+ onChange={(event) =>
378
+ setDraftWhenToUse(normalizeWhenToUseText(event.target.value))
379
+ }
290
380
  />
381
+ <p className="text-xs text-muted-foreground">
382
+ {t("dispatch.pages.appProfileWhenToUseHint", {
383
+ defaultValue:
384
+ "Add up to 6 examples, with no more than 140 characters each.",
385
+ })}
386
+ </p>
291
387
  </div>
292
388
  <DialogFooter>
293
389
  <Button
@@ -297,7 +393,10 @@ export function WorkspaceAppCard({
297
393
  >
298
394
  Cancel
299
395
  </Button>
300
- <Button type="submit" disabled={updateMetadata.isPending}>
396
+ <Button
397
+ type="submit"
398
+ disabled={updateMetadata.isPending || !validProfile}
399
+ >
301
400
  {updateMetadata.isPending ? "Saving..." : "Save"}
302
401
  </Button>
303
402
  </DialogFooter>
@@ -406,6 +505,7 @@ type WorkspaceAppMetadataLabels = {
406
505
 
407
506
  function WorkspaceAppSettings({
408
507
  app,
508
+ canEditMetadata,
409
509
  isArchived,
410
510
  isPending,
411
511
  onEdit,
@@ -417,6 +517,7 @@ function WorkspaceAppSettings({
417
517
  labels,
418
518
  }: {
419
519
  app: WorkspaceAppSummary;
520
+ canEditMetadata: boolean;
420
521
  isArchived: boolean;
421
522
  isPending: boolean;
422
523
  onEdit: () => void;
@@ -477,10 +578,12 @@ function WorkspaceAppSettings({
477
578
  )
478
579
  }
479
580
  >
480
- <DropdownMenuItem onSelect={onEdit}>
481
- <IconEdit size={14} aria-hidden="true" />
482
- Edit details
483
- </DropdownMenuItem>
581
+ {canEditMetadata ? (
582
+ <DropdownMenuItem onSelect={onEdit}>
583
+ <IconEdit size={14} aria-hidden="true" />
584
+ Edit details
585
+ </DropdownMenuItem>
586
+ ) : null}
484
587
  {!isPending ? (
485
588
  <DropdownMenuItem
486
589
  onSelect={(event) =>
@@ -1,21 +1,41 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { ssrfSafeFetchMock } = vi.hoisted(() => ({
4
+ ssrfSafeFetchMock: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("@agent-native/core/extensions/url-safety", () => ({
8
+ ssrfSafeFetch: (...args: unknown[]) => ssrfSafeFetchMock(...args),
9
+ }));
2
10
 
3
11
  import {
4
- buildIdentityConnectUrl,
5
12
  fetchConnectAgentCard,
6
13
  fetchMarketplaceApps,
14
+ } from "../server/lib/connect-apps";
15
+ import {
16
+ buildAgentCardUrl,
17
+ buildIdentityConnectUrl,
7
18
  normalizeConnectUrl,
8
19
  parseConnectAgentCard,
9
20
  } from "./connect-apps";
10
21
 
11
22
  describe("connect apps helpers", () => {
23
+ beforeEach(() => ssrfSafeFetchMock.mockReset());
24
+
12
25
  it("rejects unsafe or credential-bearing URLs", () => {
13
26
  expect(normalizeConnectUrl("javascript:alert(1)")).toBeNull();
14
27
  expect(normalizeConnectUrl("https://user:pass@example.com")).toBeNull();
15
28
  expect(normalizeConnectUrl("https://127.0.0.1")).toBeNull();
16
29
  expect(normalizeConnectUrl("https://169.254.169.254")).toBeNull();
17
30
  expect(normalizeConnectUrl("https://[::1]")).toBeNull();
18
- expect(normalizeConnectUrl("http://localhost:3000")).not.toBeNull();
31
+ expect(normalizeConnectUrl("https://[::ffff:127.0.0.1]")).toBeNull();
32
+ expect(normalizeConnectUrl("https://[::ffff:7f00:1]")).toBeNull();
33
+ expect(normalizeConnectUrl("https://[::ffff:6440:1]")).toBeNull();
34
+ expect(normalizeConnectUrl("https://[::ffff:0:0]")).toBeNull();
35
+ expect(normalizeConnectUrl("https://[fc00::1]")).toBeNull();
36
+ expect(normalizeConnectUrl("https://[fe80::1]")).toBeNull();
37
+ expect(normalizeConnectUrl("https://fdroid.org")).not.toBeNull();
38
+ expect(normalizeConnectUrl("http://localhost:3000")).toBeNull();
19
39
  });
20
40
 
21
41
  it("accepts a connect-capable card only from the requested origin", () => {
@@ -84,24 +104,17 @@ describe("connect apps helpers", () => {
84
104
  expect(url.searchParams.get("return")).toBe("/_agent-native/open");
85
105
  });
86
106
 
87
- it("preserves a path-mounted app when building connection URLs", async () => {
88
- const identity = new URL(
89
- buildIdentityConnectUrl(
90
- "https://community.agent-native.com/account-tiering",
91
- ),
92
- );
93
- expect(identity.pathname).toBe(
94
- "/account-tiering/_agent-native/identity/login",
95
- );
96
- expect(identity.searchParams.get("return")).toBe(
97
- "/account-tiering/_agent-native/open",
107
+ it("builds a path-mounted agent card URL", () => {
108
+ expect(
109
+ buildAgentCardUrl("https://community.agent-native.com/account-tiering"),
110
+ ).toBe(
111
+ "https://community.agent-native.com/account-tiering/.well-known/agent-card.json",
98
112
  );
113
+ });
99
114
 
100
- const originalFetch = globalThis.fetch;
101
- let requestedUrl = "";
102
- globalThis.fetch = (async (input) => {
103
- requestedUrl = String(input);
104
- return new Response(
115
+ it("fetches agent cards through the server SSRF boundary", async () => {
116
+ ssrfSafeFetchMock.mockResolvedValue(
117
+ new Response(
105
118
  JSON.stringify({
106
119
  name: "Account Tiering",
107
120
  description: "Prioritize accounts",
@@ -109,57 +122,96 @@ describe("connect apps helpers", () => {
109
122
  capabilities: { connect: true },
110
123
  }),
111
124
  { status: 200 },
112
- );
113
- }) as typeof fetch;
114
- try {
115
- await fetchConnectAgentCard(
125
+ ),
126
+ );
127
+
128
+ await expect(
129
+ fetchConnectAgentCard(
116
130
  "https://community.agent-native.com/account-tiering",
117
- );
118
- expect(requestedUrl).toBe(
131
+ ),
132
+ ).resolves.toMatchObject({ name: "Account Tiering", connect: true });
133
+ expect(ssrfSafeFetchMock).toHaveBeenCalledWith(
134
+ "https://community.agent-native.com/account-tiering/.well-known/agent-card.json",
135
+ expect.objectContaining({
136
+ headers: { accept: "application/json" },
137
+ signal: expect.any(AbortSignal),
138
+ }),
139
+ expect.objectContaining({
140
+ maxRedirects: 3,
141
+ requireDispatcher: true,
142
+ httpsOnly: true,
143
+ assertUrlAllowed: expect.any(Function),
144
+ }),
145
+ );
146
+ const fetchPolicy = ssrfSafeFetchMock.mock.calls[0]?.[2] as {
147
+ assertUrlAllowed: (url: string) => void;
148
+ };
149
+ expect(() =>
150
+ fetchPolicy.assertUrlAllowed(
151
+ "https://evil.example.test/account-tiering/.well-known/agent-card.json",
152
+ ),
153
+ ).toThrow(/requested URL/);
154
+ expect(() =>
155
+ fetchPolicy.assertUrlAllowed(
119
156
  "https://community.agent-native.com/account-tiering/.well-known/agent-card.json",
120
- );
121
- } finally {
122
- globalThis.fetch = originalFetch;
123
- }
157
+ ),
158
+ ).not.toThrow();
159
+ });
160
+
161
+ it("rejects unsafe addresses before calling the server fetch boundary", async () => {
162
+ await expect(fetchConnectAgentCard("https://127.0.0.1")).rejects.toThrow(
163
+ /secure app URL/,
164
+ );
165
+ await expect(
166
+ fetchConnectAgentCard("http://localhost:3000"),
167
+ ).rejects.toThrow(/secure app URL/);
168
+ expect(ssrfSafeFetchMock).not.toHaveBeenCalled();
124
169
  });
125
170
 
126
171
  it("rejects unsafe marketplace entries before rendering links", async () => {
127
- const originalFetch = globalThis.fetch;
128
- globalThis.fetch = (async (input) =>
129
- new Response(
130
- typeof input === "string" && input === "https://feed.test"
131
- ? JSON.stringify({
132
- apps: [
133
- {
134
- id: "unsafe",
135
- name: "Unsafe",
136
- description: "bad",
137
- url: "javascript:alert(1)",
138
- capabilities: ["connect"],
139
- },
140
- {
141
- id: "safe",
142
- name: "Safe",
143
- description: "ok",
144
- url: "https://safe.example.test",
145
- capabilities: ["connect"],
146
- },
147
- ],
148
- })
149
- : JSON.stringify({
150
- name: "Safe",
151
- description: "ok",
152
- url: "https://safe.example.test",
153
- capabilities: { connect: true },
154
- }),
155
- { status: 200 },
156
- )) as typeof fetch;
157
- try {
158
- await expect(fetchMarketplaceApps("https://feed.test")).resolves.toEqual([
159
- expect.objectContaining({ id: "safe" }),
160
- ]);
161
- } finally {
162
- globalThis.fetch = originalFetch;
163
- }
172
+ ssrfSafeFetchMock
173
+ .mockResolvedValueOnce(
174
+ new Response(
175
+ JSON.stringify({
176
+ apps: [
177
+ {
178
+ id: "unsafe",
179
+ name: "Unsafe",
180
+ description: "bad",
181
+ url: "javascript:alert(1)",
182
+ capabilities: ["connect"],
183
+ },
184
+ {
185
+ id: "safe",
186
+ name: "Safe",
187
+ description: "ok",
188
+ url: "https://safe.example.test",
189
+ capabilities: ["connect"],
190
+ },
191
+ ],
192
+ }),
193
+ { status: 200 },
194
+ ),
195
+ )
196
+ .mockResolvedValueOnce(
197
+ new Response(
198
+ JSON.stringify({
199
+ name: "Safe",
200
+ description: "ok",
201
+ url: "https://safe.example.test/_agent-native/a2a",
202
+ capabilities: { connect: true },
203
+ }),
204
+ { status: 200 },
205
+ ),
206
+ );
207
+
208
+ await expect(fetchMarketplaceApps("https://feed.test")).resolves.toEqual([
209
+ expect.objectContaining({ id: "safe" }),
210
+ ]);
211
+ expect(ssrfSafeFetchMock).toHaveBeenCalledTimes(2);
212
+ expect(ssrfSafeFetchMock.mock.calls.map(([url]) => url)).toEqual([
213
+ "https://feed.test/",
214
+ "https://safe.example.test/.well-known/agent-card.json",
215
+ ]);
164
216
  });
165
217
  });
@@ -14,28 +14,58 @@ export interface MarketplaceApp {
14
14
  source?: "first-party" | "community";
15
15
  }
16
16
 
17
+ function isPrivateIpv4(hostname: string): boolean {
18
+ const octets = hostname.split(".").map(Number);
19
+ if (
20
+ octets.length !== 4 ||
21
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
22
+ ) {
23
+ return false;
24
+ }
25
+ const [first, second] = octets;
26
+ return (
27
+ first === 0 ||
28
+ first === 10 ||
29
+ first === 127 ||
30
+ (first === 100 && second >= 64 && second <= 127) ||
31
+ (first === 169 && second === 254) ||
32
+ (first === 172 && second >= 16 && second <= 31) ||
33
+ (first === 192 && second === 168)
34
+ );
35
+ }
36
+
37
+ function mappedIpv4(hostname: string): string | null {
38
+ const match = /^::ffff:(.*)$/i.exec(hostname);
39
+ if (!match) return null;
40
+ if (match[1].includes(".")) return match[1];
41
+ const words = match[1].split(":");
42
+ if (
43
+ words.length !== 2 ||
44
+ words.some((word) => !/^[\da-f]{1,4}$/i.test(word))
45
+ ) {
46
+ return null;
47
+ }
48
+ const high = Number.parseInt(words[0], 16);
49
+ const low = Number.parseInt(words[1], 16);
50
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
51
+ }
52
+
17
53
  export function normalizeConnectUrl(value: string): URL | null {
18
54
  try {
19
55
  const url = new URL(value.trim());
20
56
  const hostname = url.hostname.toLowerCase();
21
57
  const ipv6 = hostname.replace(/^\[|\]$/g, "");
22
- const privateIpv4 =
23
- /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(
24
- hostname,
25
- ) ||
26
- hostname === "0.0.0.0" ||
27
- /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(hostname);
58
+ const privateIpv4 = isPrivateIpv4(hostname);
59
+ const mappedAddress = mappedIpv4(ipv6);
28
60
  const privateIpv6 =
29
- ipv6 === "::" ||
30
- ipv6 === "::1" ||
31
- /^(?:fc|fd)/.test(ipv6) ||
32
- /^fe[89ab]/.test(ipv6) ||
33
- /^::ffff:(?:10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(
34
- ipv6,
35
- );
61
+ ipv6.includes(":") &&
62
+ (ipv6 === "::" ||
63
+ ipv6 === "::1" ||
64
+ /^(?:fc|fd)/.test(ipv6) ||
65
+ /^fe[89ab]/.test(ipv6) ||
66
+ Boolean(mappedAddress && isPrivateIpv4(mappedAddress)));
36
67
  if (
37
- (url.protocol !== "https:" &&
38
- !(url.protocol === "http:" && hostname === "localhost")) ||
68
+ url.protocol !== "https:" ||
39
69
  (hostname !== "localhost" && (privateIpv4 || privateIpv6)) ||
40
70
  url.username ||
41
71
  url.password
@@ -103,73 +133,8 @@ export function buildIdentityConnectUrl(appUrl: string): string {
103
133
  return url.toString();
104
134
  }
105
135
 
106
- export async function fetchMarketplaceApps(
107
- feedUrl = "https://www.agent-native.com/apps.json",
108
- ): Promise<MarketplaceApp[]> {
109
- const response = await fetch(feedUrl, {
110
- headers: { accept: "application/json" },
111
- signal: AbortSignal.timeout(5_000),
112
- });
113
- if (!response.ok)
114
- throw new Error(`Marketplace feed returned ${response.status}`);
115
- const length = Number(response.headers.get("content-length") ?? 0);
116
- if (length > 1_000_000) throw new Error("Marketplace feed is too large");
117
- const body = await response.text();
118
- if (body.length > 1_000_000) throw new Error("Marketplace feed is too large");
119
- const payload = JSON.parse(body) as { apps?: unknown };
120
- if (!Array.isArray(payload.apps))
121
- throw new Error("Marketplace feed is invalid");
122
- const candidates = payload.apps.filter((entry): entry is MarketplaceApp => {
123
- if (!entry || typeof entry !== "object") return false;
124
- const app = entry as Record<string, unknown>;
125
- const url =
126
- typeof app.url === "string" ? normalizeConnectUrl(app.url) : null;
127
- return (
128
- typeof app.id === "string" &&
129
- typeof app.name === "string" &&
130
- typeof app.description === "string" &&
131
- typeof app.url === "string" &&
132
- Boolean(url) &&
133
- Array.isArray(app.capabilities) &&
134
- app.capabilities.every((capability) => typeof capability === "string")
135
- );
136
- });
137
- const verified = await Promise.allSettled(
138
- candidates.map(async (app) => {
139
- const card = await fetchConnectAgentCard(app.url);
140
- return card.connect ? { ...app, capabilities: ["connect"] } : null;
141
- }),
142
- );
143
- return verified.flatMap((result) =>
144
- result.status === "fulfilled" && result.value ? [result.value] : [],
145
- );
146
- }
147
-
148
- export async function fetchConnectAgentCard(
149
- appUrl: string,
150
- ): Promise<ConnectAgentCard> {
151
- const target = normalizeConnectUrl(appUrl);
152
- if (!target) throw new Error("Enter a secure app URL.");
153
- const response = await fetch(
154
- appPathUrl(target.toString(), "/.well-known/agent-card.json").toString(),
155
- {
156
- headers: { accept: "application/json" },
157
- signal: AbortSignal.timeout(5_000),
158
- },
159
- );
160
- if (!response.ok) throw new Error(`Agent card returned ${response.status}`);
161
- const length = Number(response.headers.get("content-length") ?? 0);
162
- if (length > 256_000) throw new Error("Agent card is too large");
163
- const body = await response.text();
164
- if (body.length > 256_000) throw new Error("Agent card is too large");
165
- const card = parseConnectAgentCard(
166
- JSON.parse(body),
167
- target.origin,
168
- target.pathname,
169
- );
170
- if (!card)
171
- throw new Error("This app has an invalid or incompatible agent card.");
172
- return card;
136
+ export function buildAgentCardUrl(appUrl: string): string {
137
+ return appPathUrl(appUrl, "/.well-known/agent-card.json").toString();
173
138
  }
174
139
 
175
140
  function appPathUrl(appUrl: string, suffix: string): URL {
@@ -18,6 +18,7 @@ export interface WorkspaceAppSummary {
18
18
  id: string;
19
19
  name: string;
20
20
  description?: string;
21
+ whenToUse?: string[];
21
22
  defaultDescriptionKey?: string;
22
23
  path: string;
23
24
  homePath?: string;