@agent-native/dispatch 0.40.30-nightly-20261021064625 → 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.
- package/CHANGELOG.md +7 -47
- package/dist/actions/connect-external-agent.d.ts.map +1 -1
- package/dist/actions/connect-external-agent.js +12 -13
- package/dist/actions/connect-external-agent.js.map +1 -1
- package/dist/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/delete-workspace-resource.d.ts +13 -13
- package/dist/actions/import-agent.d.ts +13 -13
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +8 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/inspect-connect-agent-card.d.ts +5 -0
- package/dist/actions/inspect-connect-agent-card.d.ts.map +1 -0
- package/dist/actions/inspect-connect-agent-card.js +15 -0
- package/dist/actions/inspect-connect-agent-card.js.map +1 -0
- package/dist/actions/install-curated-community-app.d.ts +36 -0
- package/dist/actions/install-curated-community-app.d.ts.map +1 -0
- package/dist/actions/install-curated-community-app.js +26 -0
- package/dist/actions/install-curated-community-app.js.map +1 -0
- package/dist/actions/list-connect-marketplace-apps.d.ts +3 -0
- package/dist/actions/list-connect-marketplace-apps.d.ts.map +1 -0
- package/dist/actions/list-connect-marketplace-apps.js +15 -0
- package/dist/actions/list-connect-marketplace-apps.js.map +1 -0
- package/dist/actions/list-connected-agents.d.ts +2 -0
- package/dist/actions/list-curated-community-apps.d.ts +3 -0
- package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
- package/dist/actions/list-curated-community-apps.js +10 -0
- package/dist/actions/list-curated-community-apps.js.map +1 -0
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/actions/remix-workspace-template.d.ts +6 -0
- package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
- package/dist/actions/update-workspace-app-metadata.js +26 -5
- package/dist/actions/update-workspace-app-metadata.js.map +1 -1
- package/dist/components/community-apps-section.d.ts +2 -0
- package/dist/components/community-apps-section.d.ts.map +1 -0
- package/dist/components/community-apps-section.js +90 -0
- package/dist/components/community-apps-section.js.map +1 -0
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +63 -9
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/lib/connect-apps.d.ts +1 -2
- package/dist/lib/connect-apps.d.ts.map +1 -1
- package/dist/lib/connect-apps.js +41 -65
- package/dist/lib/connect-apps.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +1 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +2 -1
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/connect.d.ts.map +1 -1
- package/dist/routes/pages/connect.js +5 -7
- package/dist/routes/pages/connect.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts +11 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +258 -39
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/community-apps.d.ts +45 -0
- package/dist/server/lib/community-apps.d.ts.map +1 -0
- package/dist/server/lib/community-apps.js +267 -0
- package/dist/server/lib/community-apps.js.map +1 -0
- package/dist/server/lib/community-source.d.ts +19 -0
- package/dist/server/lib/community-source.d.ts.map +1 -0
- package/dist/server/lib/community-source.js +118 -0
- package/dist/server/lib/community-source.js.map +1 -0
- package/dist/server/lib/connect-apps.d.ts +4 -0
- package/dist/server/lib/connect-apps.d.ts.map +1 -0
- package/dist/server/lib/connect-apps.js +99 -0
- package/dist/server/lib/connect-apps.js.map +1 -0
- package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
- package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
- package/dist/server/lib/org-apps-directory-cache.js +44 -0
- package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
- package/dist/server/plugins/integrations.js +3 -3
- package/dist/server/plugins/integrations.js.map +1 -1
- package/dist/shared/labs.d.ts +2 -0
- package/dist/shared/labs.d.ts.map +1 -0
- package/dist/shared/labs.js +8 -0
- package/dist/shared/labs.js.map +1 -0
- package/package.json +5 -4
- package/src/actions/connect-external-agent.spec.ts +101 -3
- package/src/actions/connect-external-agent.ts +12 -11
- package/src/actions/index.spec.ts +13 -0
- package/src/actions/index.ts +8 -0
- package/src/actions/inspect-connect-agent-card.ts +17 -0
- package/src/actions/install-curated-community-app.ts +32 -0
- package/src/actions/list-connect-marketplace-apps.ts +17 -0
- package/src/actions/list-curated-community-apps.ts +12 -0
- package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
- package/src/actions/update-workspace-app-metadata.ts +48 -24
- package/src/components/community-apps-section.tsx +224 -0
- package/src/components/workspace-app-card.spec.tsx +226 -1
- package/src/components/workspace-app-card.tsx +115 -12
- package/src/lib/connect-apps.spec.ts +118 -66
- package/src/lib/connect-apps.ts +47 -82
- package/src/lib/workspace-apps.ts +1 -0
- package/src/routes/pages/apps.spec.tsx +201 -1
- package/src/routes/pages/apps.tsx +3 -0
- package/src/routes/pages/connect.spec.tsx +53 -6
- package/src/routes/pages/connect.tsx +14 -11
- package/src/server/index.ts +9 -0
- package/src/server/lib/app-creation-store.spec.ts +539 -11
- package/src/server/lib/app-creation-store.ts +332 -41
- package/src/server/lib/community-apps.spec.ts +367 -0
- package/src/server/lib/community-apps.ts +372 -0
- package/src/server/lib/community-source.spec.ts +151 -0
- package/src/server/lib/community-source.ts +186 -0
- package/src/server/lib/connect-apps.ts +149 -0
- package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
- package/src/server/lib/org-apps-directory-cache.ts +66 -0
- package/src/server/plugins/integrations.spec.ts +60 -0
- package/src/server/plugins/integrations.ts +3 -3
- 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
|
-
|
|
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:
|
|
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={
|
|
351
|
+
maxLength={1000}
|
|
288
352
|
rows={4}
|
|
289
|
-
onChange={(event) =>
|
|
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
|
|
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
|
-
|
|
481
|
-
<
|
|
482
|
-
|
|
483
|
-
|
|
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("
|
|
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("
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
fetchConnectAgentCard(
|
|
116
130
|
"https://community.agent-native.com/account-tiering",
|
|
117
|
-
)
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
});
|
package/src/lib/connect-apps.ts
CHANGED
|
@@ -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
|
-
|
|
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 === "::
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
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 {
|