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

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 (150) hide show
  1. package/CHANGELOG.md +15 -60
  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-connected-agents.js +7 -3
  26. package/dist/actions/list-connected-agents.js.map +1 -1
  27. package/dist/actions/list-curated-community-apps.d.ts +3 -0
  28. package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
  29. package/dist/actions/list-curated-community-apps.js +10 -0
  30. package/dist/actions/list-curated-community-apps.js.map +1 -0
  31. package/dist/actions/list-workspace-connections.d.ts.map +1 -1
  32. package/dist/actions/list-workspace-connections.js +13 -18
  33. package/dist/actions/list-workspace-connections.js.map +1 -1
  34. package/dist/actions/provider-api-register.d.ts +14 -14
  35. package/dist/actions/remix-workspace-template.d.ts +6 -0
  36. package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
  37. package/dist/actions/update-workspace-app-metadata.js +26 -5
  38. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  39. package/dist/components/action-query-error.d.ts +2 -1
  40. package/dist/components/action-query-error.d.ts.map +1 -1
  41. package/dist/components/action-query-error.js +25 -2
  42. package/dist/components/action-query-error.js.map +1 -1
  43. package/dist/components/chat-home-apps.d.ts.map +1 -1
  44. package/dist/components/chat-home-apps.js +2 -2
  45. package/dist/components/chat-home-apps.js.map +1 -1
  46. package/dist/components/community-apps-section.d.ts +2 -0
  47. package/dist/components/community-apps-section.d.ts.map +1 -0
  48. package/dist/components/community-apps-section.js +90 -0
  49. package/dist/components/community-apps-section.js.map +1 -0
  50. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  51. package/dist/components/dispatch-control-plane.js +1 -1
  52. package/dist/components/dispatch-control-plane.js.map +1 -1
  53. package/dist/components/layout/Layout.d.ts +1 -0
  54. package/dist/components/layout/Layout.d.ts.map +1 -1
  55. package/dist/components/layout/Layout.js +7 -0
  56. package/dist/components/layout/Layout.js.map +1 -1
  57. package/dist/components/workspace-app-card.d.ts.map +1 -1
  58. package/dist/components/workspace-app-card.js +63 -9
  59. package/dist/components/workspace-app-card.js.map +1 -1
  60. package/dist/lib/connect-apps.d.ts +1 -2
  61. package/dist/lib/connect-apps.d.ts.map +1 -1
  62. package/dist/lib/connect-apps.js +41 -65
  63. package/dist/lib/connect-apps.js.map +1 -1
  64. package/dist/lib/workspace-apps.d.ts +1 -0
  65. package/dist/lib/workspace-apps.d.ts.map +1 -1
  66. package/dist/lib/workspace-apps.js.map +1 -1
  67. package/dist/routes/pages/$appId.d.ts.map +1 -1
  68. package/dist/routes/pages/$appId.js +1 -1
  69. package/dist/routes/pages/$appId.js.map +1 -1
  70. package/dist/routes/pages/apps.d.ts.map +1 -1
  71. package/dist/routes/pages/apps.js +3 -2
  72. package/dist/routes/pages/apps.js.map +1 -1
  73. package/dist/routes/pages/connect.d.ts.map +1 -1
  74. package/dist/routes/pages/connect.js +5 -7
  75. package/dist/routes/pages/connect.js.map +1 -1
  76. package/dist/server/index.d.ts +2 -0
  77. package/dist/server/index.d.ts.map +1 -1
  78. package/dist/server/index.js +2 -0
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/lib/app-creation-store.d.ts +11 -1
  81. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  82. package/dist/server/lib/app-creation-store.js +379 -92
  83. package/dist/server/lib/app-creation-store.js.map +1 -1
  84. package/dist/server/lib/community-apps.d.ts +45 -0
  85. package/dist/server/lib/community-apps.d.ts.map +1 -0
  86. package/dist/server/lib/community-apps.js +267 -0
  87. package/dist/server/lib/community-apps.js.map +1 -0
  88. package/dist/server/lib/community-source.d.ts +19 -0
  89. package/dist/server/lib/community-source.d.ts.map +1 -0
  90. package/dist/server/lib/community-source.js +118 -0
  91. package/dist/server/lib/community-source.js.map +1 -0
  92. package/dist/server/lib/connect-apps.d.ts +4 -0
  93. package/dist/server/lib/connect-apps.d.ts.map +1 -0
  94. package/dist/server/lib/connect-apps.js +99 -0
  95. package/dist/server/lib/connect-apps.js.map +1 -0
  96. package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
  97. package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
  98. package/dist/server/lib/org-apps-directory-cache.js +44 -0
  99. package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
  100. package/dist/server/plugins/integrations.js +3 -3
  101. package/dist/server/plugins/integrations.js.map +1 -1
  102. package/dist/shared/labs.d.ts +2 -0
  103. package/dist/shared/labs.d.ts.map +1 -0
  104. package/dist/shared/labs.js +8 -0
  105. package/dist/shared/labs.js.map +1 -0
  106. package/package.json +5 -4
  107. package/src/actions/connect-external-agent.spec.ts +101 -3
  108. package/src/actions/connect-external-agent.ts +12 -11
  109. package/src/actions/index.spec.ts +13 -0
  110. package/src/actions/index.ts +8 -0
  111. package/src/actions/inspect-connect-agent-card.ts +17 -0
  112. package/src/actions/install-curated-community-app.ts +32 -0
  113. package/src/actions/list-connect-marketplace-apps.ts +17 -0
  114. package/src/actions/list-connected-agents.spec.ts +17 -1
  115. package/src/actions/list-connected-agents.ts +7 -2
  116. package/src/actions/list-curated-community-apps.ts +12 -0
  117. package/src/actions/list-workspace-connections.spec.ts +14 -3
  118. package/src/actions/list-workspace-connections.ts +13 -17
  119. package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
  120. package/src/actions/update-workspace-app-metadata.ts +48 -24
  121. package/src/components/action-query-error.spec.tsx +20 -0
  122. package/src/components/action-query-error.tsx +40 -0
  123. package/src/components/chat-home-apps.spec.tsx +32 -1
  124. package/src/components/chat-home-apps.tsx +6 -1
  125. package/src/components/community-apps-section.tsx +224 -0
  126. package/src/components/dispatch-control-plane.tsx +1 -0
  127. package/src/components/layout/Layout.tsx +9 -0
  128. package/src/components/workspace-app-card.spec.tsx +226 -1
  129. package/src/components/workspace-app-card.tsx +115 -12
  130. package/src/lib/connect-apps.spec.ts +118 -66
  131. package/src/lib/connect-apps.ts +47 -82
  132. package/src/lib/workspace-apps.ts +1 -0
  133. package/src/routes/pages/$appId.tsx +1 -0
  134. package/src/routes/pages/apps.spec.tsx +227 -3
  135. package/src/routes/pages/apps.tsx +5 -0
  136. package/src/routes/pages/connect.spec.tsx +53 -6
  137. package/src/routes/pages/connect.tsx +14 -11
  138. package/src/server/index.ts +9 -0
  139. package/src/server/lib/app-creation-store.spec.ts +820 -16
  140. package/src/server/lib/app-creation-store.ts +504 -101
  141. package/src/server/lib/community-apps.spec.ts +367 -0
  142. package/src/server/lib/community-apps.ts +372 -0
  143. package/src/server/lib/community-source.spec.ts +151 -0
  144. package/src/server/lib/community-source.ts +186 -0
  145. package/src/server/lib/connect-apps.ts +149 -0
  146. package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
  147. package/src/server/lib/org-apps-directory-cache.ts +66 -0
  148. package/src/server/plugins/integrations.spec.ts +60 -0
  149. package/src/server/plugins/integrations.ts +3 -3
  150. package/src/shared/labs.ts +9 -0
@@ -0,0 +1,372 @@
1
+ import { fail } from "@agent-native/core/action";
2
+ import { getUserLabEnabled } from "@agent-native/core/labs/server";
3
+ import {
4
+ getWorkspaceAppIdValidationError,
5
+ normalizeWorkspaceAppId,
6
+ } from "@agent-native/core/shared";
7
+ import {
8
+ BUILTIN_AGENT_CATALOG_IDS,
9
+ HIDDEN_FIRST_PARTY_AGENT_IDS,
10
+ } from "@agent-native/core/shared/first-party-agents";
11
+
12
+ import { COMMUNITY_APPS_LAB } from "../../shared/labs.js";
13
+ import type {
14
+ StartWorkspaceAppCreationResult,
15
+ WorkspaceAppSummary,
16
+ } from "./app-creation-store.js";
17
+ import {
18
+ getAppCreationSettings,
19
+ getWorkspaceInfo,
20
+ isLocalAppCreationRuntime,
21
+ listWorkspaceApps,
22
+ startWorkspaceAppCreation,
23
+ } from "./app-creation-store.js";
24
+ import {
25
+ loadPinnedCommunitySourceAttachments,
26
+ type PinnedCommunitySource,
27
+ } from "./community-source.js";
28
+ import { CURATED_WORKSPACE_TEMPLATES } from "./curated-workspace-templates.js";
29
+ import {
30
+ currentOrgId,
31
+ currentOwnerEmail,
32
+ recordAudit,
33
+ } from "./dispatch-store.js";
34
+
35
+ export interface CuratedCommunityAppSource extends PinnedCommunitySource {
36
+ id: string;
37
+ name: string;
38
+ description: string;
39
+ appId: string;
40
+ license: string;
41
+ provenance: string;
42
+ }
43
+
44
+ export interface CuratedCommunityApp extends CuratedCommunityAppSource {
45
+ installed: boolean;
46
+ pending: boolean;
47
+ installable: boolean;
48
+ installUnavailableReason?:
49
+ | "workspace-required"
50
+ | "app-id-conflict"
51
+ | "builder-install-unverified";
52
+ }
53
+
54
+ export const CURATED_COMMUNITY_APP_SOURCES: readonly CuratedCommunityAppSource[] =
55
+ [
56
+ {
57
+ id: "community-qr",
58
+ name: "QR",
59
+ description: "Generate public PNG or SVG QR images from links or text.",
60
+ appId: "community-qr",
61
+ repo: "BuilderIO/agent-native",
62
+ ref: "main",
63
+ license: "MIT",
64
+ provenance:
65
+ "Reviewed Agent-Native Framework template, sanitized for independent installation.",
66
+ templatePath: "templates/community/qr",
67
+ files: [
68
+ {
69
+ path: "AGENTS.md",
70
+ blobSha: "7dc9e18bc426dfd21526f91225cc615759450816",
71
+ },
72
+ {
73
+ path: "LICENSE",
74
+ blobSha: "69c7182efb3eb1a8b7f6fd5989f3e4fd737d0645",
75
+ },
76
+ {
77
+ path: "README.md",
78
+ blobSha: "e05e72bb9213732f56ccbc96f7dcb1c3a064cb19",
79
+ },
80
+ {
81
+ path: "actions/generate-qr-code.spec.ts",
82
+ blobSha: "3459217b7252c6ee0076c68cfdb83001cc74f29e",
83
+ },
84
+ {
85
+ path: "actions/generate-qr-code.ts",
86
+ blobSha: "98d30e218bfed796d5e402d5ce19a8626d8da0cf",
87
+ },
88
+ {
89
+ path: "actions/run.ts",
90
+ blobSha: "b46ab15b667c34741364ba08f010f7aca9d0f02e",
91
+ },
92
+ {
93
+ path: "agent-native.json",
94
+ blobSha: "accc32476792b84557f69e2b48b317c27b5546dc",
95
+ },
96
+ {
97
+ path: "package.json",
98
+ blobSha: "c7803d346368f927621329b2acbf5f6cc744dc18",
99
+ },
100
+ {
101
+ path: "server/db/index.ts",
102
+ blobSha: "17007b456530f75a72e7209bdfe9f4bd4c9224e0",
103
+ },
104
+ {
105
+ path: "server/db/schema.ts",
106
+ blobSha: "2e4cd01c5f46463e78bc7beb6e954f3e526c0b57",
107
+ },
108
+ {
109
+ path: "server/plugins/db.ts",
110
+ blobSha: "78a5b431c1ad1ea14f593123c090f5376a36df10",
111
+ },
112
+ {
113
+ path: "tsconfig.json",
114
+ blobSha: "5267473a32dcab8e5dc2c9d9a720afed7f40174c",
115
+ },
116
+ ],
117
+ },
118
+ ];
119
+
120
+ const communitySourceById = new Map(
121
+ CURATED_COMMUNITY_APP_SOURCES.map((source) => [source.id, source]),
122
+ );
123
+
124
+ function requireCommunitySource(sourceId: string): CuratedCommunityAppSource {
125
+ const source = communitySourceById.get(sourceId.trim());
126
+ if (!source) {
127
+ fail("That community app is not in Dispatch's reviewed install catalog.", {
128
+ errorCode: "unknown_community_app_source",
129
+ statusCode: 404,
130
+ });
131
+ }
132
+ return source;
133
+ }
134
+
135
+ function communitySourceFailureKind(
136
+ error: unknown,
137
+ ): "unavailable" | "integrity" {
138
+ if (!(error instanceof Error) || !("failureKind" in error)) {
139
+ return "unavailable";
140
+ }
141
+ return error.failureKind === "integrity" ? "integrity" : "unavailable";
142
+ }
143
+
144
+ function requireNonShadowingCommunityAppId(appId: string): void {
145
+ const error = getWorkspaceAppIdValidationError(appId);
146
+ if (error) {
147
+ fail(error, { errorCode: "invalid_community_app_id", statusCode: 400 });
148
+ }
149
+ if (
150
+ CURATED_WORKSPACE_TEMPLATES.some((template) => template.id === appId) ||
151
+ BUILTIN_AGENT_CATALOG_IDS.includes(appId) ||
152
+ HIDDEN_FIRST_PARTY_AGENT_IDS.has(appId)
153
+ ) {
154
+ fail("Community apps cannot use a first-party template app id.", {
155
+ errorCode: "community_app_id_shadows_first_party",
156
+ statusCode: 409,
157
+ });
158
+ }
159
+ }
160
+
161
+ async function requireCommunityAppsLab(): Promise<void> {
162
+ const userEmail = currentOwnerEmail().trim();
163
+ const orgId = currentOrgId();
164
+ if (!userEmail || !orgId) {
165
+ fail("Sign in to an organization to browse community apps.", {
166
+ errorCode: "community_apps_identity_required",
167
+ statusCode: 403,
168
+ });
169
+ }
170
+ const enabled = await getUserLabEnabled(userEmail, COMMUNITY_APPS_LAB, {
171
+ orgId,
172
+ });
173
+ if (!enabled) {
174
+ fail("Community app catalog and installs are disabled for this account.", {
175
+ errorCode: "community_apps_lab_disabled",
176
+ statusCode: 403,
177
+ });
178
+ }
179
+ }
180
+
181
+ async function hasAssociatedWorkspace(
182
+ localRuntime = isLocalAppCreationRuntime(),
183
+ ): Promise<boolean> {
184
+ if (localRuntime) return Boolean(getWorkspaceInfo().rootPath);
185
+ return Boolean((await getAppCreationSettings()).builderProjectId);
186
+ }
187
+
188
+ export async function listCuratedCommunityApps(): Promise<
189
+ CuratedCommunityApp[]
190
+ > {
191
+ await requireCommunityAppsLab();
192
+ const localRuntime = isLocalAppCreationRuntime();
193
+ const [apps, workspaceAvailable] = await Promise.all([
194
+ listWorkspaceApps({ includeAgentCards: false, includeArchived: true }),
195
+ hasAssociatedWorkspace(localRuntime),
196
+ ]);
197
+ return CURATED_COMMUNITY_APP_SOURCES.map((source) => {
198
+ const appWithId = apps.find((app) => app.id === source.appId);
199
+ const installedApp =
200
+ appWithId?.communitySourceId === source.id ? appWithId : null;
201
+ const idConflict = Boolean(appWithId && !installedApp);
202
+ const pending = installedApp?.status === "pending";
203
+ const installUnavailableReason = appWithId
204
+ ? idConflict
205
+ ? "app-id-conflict"
206
+ : undefined
207
+ : !workspaceAvailable
208
+ ? "workspace-required"
209
+ : !localRuntime
210
+ ? "builder-install-unverified"
211
+ : undefined;
212
+ return {
213
+ ...source,
214
+ installed: Boolean(installedApp && !pending),
215
+ pending,
216
+ installable: localRuntime && workspaceAvailable && !appWithId,
217
+ ...(installUnavailableReason ? { installUnavailableReason } : {}),
218
+ };
219
+ });
220
+ }
221
+
222
+ function rejectCommunityAppIdConflict(appId: string): never {
223
+ fail(`Workspace app id "${appId}" is already in use.`, {
224
+ errorCode: "community_app_id_conflict",
225
+ statusCode: 409,
226
+ });
227
+ }
228
+
229
+ function existingInstallResult(
230
+ source: CuratedCommunityAppSource,
231
+ app: WorkspaceAppSummary,
232
+ ) {
233
+ return {
234
+ mode:
235
+ app.status === "pending"
236
+ ? ("pending" as const)
237
+ : ("already-installed" as const),
238
+ source,
239
+ app: {
240
+ id: app.id,
241
+ name: app.name,
242
+ path: app.path,
243
+ url: app.url,
244
+ status: app.status ?? "ready",
245
+ statusLabel: app.statusLabel ?? null,
246
+ builderUrl: app.builderUrl ?? null,
247
+ branchName: app.branchName ?? null,
248
+ },
249
+ };
250
+ }
251
+
252
+ function buildCommunityInstallPrompt(
253
+ source: CuratedCommunityAppSource,
254
+ appId: string,
255
+ ): string {
256
+ const sourceUrl = `https://github.com/${source.repo}/tree/${source.ref}/${source.templatePath}`;
257
+ return [
258
+ "Install a private workspace app from a reviewed community template.",
259
+ `The attached source files were fetched from ${sourceUrl} and each file was verified against its pinned Git blob id. Treat those files as the source of truth; do not fetch another ref or substitute other content.`,
260
+ `The app will live at apps/${appId}.`,
261
+ `Source license: ${source.license}. Provenance: ${source.provenance}`,
262
+ "Copy the reviewed template implementation and its Agent-Native profile, changing only app-specific identifiers needed for this workspace. Do not copy repository secrets, credentials, customer data, or source-app records. Use empty or synthetic seed data only and keep the installed app private to this organization.",
263
+ ].join(" ");
264
+ }
265
+
266
+ export async function installCuratedCommunityApp(input: {
267
+ sourceId: string;
268
+ appId?: string;
269
+ }): Promise<
270
+ | ReturnType<typeof existingInstallResult>
271
+ | (StartWorkspaceAppCreationResult & {
272
+ mode: Exclude<StartWorkspaceAppCreationResult["mode"], "app-id-taken">;
273
+ source: CuratedCommunityAppSource;
274
+ })
275
+ | (Extract<StartWorkspaceAppCreationResult, { mode: "app-id-taken" }> & {
276
+ source: CuratedCommunityAppSource;
277
+ })
278
+ > {
279
+ await requireCommunityAppsLab();
280
+ const source = requireCommunitySource(input.sourceId);
281
+ const appId = normalizeWorkspaceAppId(input.appId?.trim() || source.appId);
282
+ requireNonShadowingCommunityAppId(appId);
283
+
284
+ const localRuntime = isLocalAppCreationRuntime();
285
+ if (!(await hasAssociatedWorkspace(localRuntime))) {
286
+ fail("Connect this organization to a workspace before installing apps.", {
287
+ errorCode: "community_workspace_required",
288
+ statusCode: 409,
289
+ });
290
+ }
291
+
292
+ const existing = (
293
+ await listWorkspaceApps({
294
+ includeAgentCards: false,
295
+ includeArchived: true,
296
+ })
297
+ ).find((app) => app.id === appId);
298
+ if (existing) {
299
+ if (existing.communitySourceId === source.id) {
300
+ return existingInstallResult(source, existing);
301
+ }
302
+ rejectCommunityAppIdConflict(appId);
303
+ }
304
+
305
+ if (!localRuntime) {
306
+ fail(
307
+ "Community template installs in Builder workspaces are disabled until the generated branch files can be read back and verified.",
308
+ {
309
+ errorCode: "community_builder_install_unverified",
310
+ statusCode: 503,
311
+ },
312
+ );
313
+ }
314
+
315
+ let attachments: Awaited<
316
+ ReturnType<typeof loadPinnedCommunitySourceAttachments>
317
+ >;
318
+ try {
319
+ attachments = await loadPinnedCommunitySourceAttachments(source, appId);
320
+ } catch (error) {
321
+ fail(
322
+ "Dispatch could not verify the pinned community template source. Try again later.",
323
+ {
324
+ errorCode:
325
+ communitySourceFailureKind(error) === "integrity"
326
+ ? "community_source_integrity_failed"
327
+ : "community_source_unavailable",
328
+ statusCode: 503,
329
+ },
330
+ );
331
+ }
332
+
333
+ const result = await startWorkspaceAppCreation({
334
+ prompt: buildCommunityInstallPrompt(source, appId),
335
+ appId,
336
+ description: source.description,
337
+ template: source.id,
338
+ communitySourceId: source.id,
339
+ attachments,
340
+ });
341
+
342
+ if (result.mode === "app-id-taken") {
343
+ const raced = (
344
+ await listWorkspaceApps({
345
+ includeAgentCards: false,
346
+ includeArchived: true,
347
+ })
348
+ ).find((app) => app.id === appId);
349
+ if (raced?.communitySourceId === source.id) {
350
+ return existingInstallResult(source, raced);
351
+ }
352
+ if (raced) rejectCommunityAppIdConflict(appId);
353
+ return { ...result, source };
354
+ }
355
+
356
+ await recordAudit({
357
+ action: "workspace-app.community-install-requested",
358
+ targetType: "workspace-app",
359
+ targetId: appId,
360
+ summary: `Requested ${source.name} community app installation`,
361
+ metadata: {
362
+ sourceId: source.id,
363
+ repo: source.repo,
364
+ ref: source.ref,
365
+ blobShas: source.files.map((file) => file.blobSha),
366
+ license: source.license,
367
+ },
368
+ });
369
+ return { ...result, source } as typeof result & {
370
+ source: CuratedCommunityAppSource;
371
+ };
372
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ loadPinnedCommunitySourceAttachments,
5
+ PinnedCommunitySourceError,
6
+ } from "./community-source.js";
7
+
8
+ const SOURCE = {
9
+ id: "community-fixture",
10
+ repo: "BuilderIO/agent-native",
11
+ ref: "main",
12
+ templatePath: "templates/community/qr",
13
+ files: [
14
+ {
15
+ path: "README.md",
16
+ blobSha: "f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f",
17
+ },
18
+ ],
19
+ };
20
+
21
+ describe("pinned community source attachments", () => {
22
+ it("loads HTTPS files at the pinned source ref and verifies Git blob ids", async () => {
23
+ const fetchSource = vi.fn(async () => new Response("abc", { status: 200 }));
24
+
25
+ await expect(
26
+ loadPinnedCommunitySourceAttachments(
27
+ SOURCE,
28
+ "community-qr",
29
+ fetchSource as any,
30
+ ),
31
+ ).resolves.toEqual([
32
+ {
33
+ type: "upload",
34
+ contentType: "text/plain",
35
+ name: "community-qr/README.md",
36
+ text: "abc",
37
+ dataUrl: "",
38
+ id: "community-fixture:README.md",
39
+ },
40
+ ]);
41
+
42
+ expect(fetchSource).toHaveBeenCalledWith(
43
+ "https://raw.githubusercontent.com/BuilderIO/agent-native/" +
44
+ `${SOURCE.ref}/templates/community/qr/README.md`,
45
+ expect.objectContaining({
46
+ headers: { Accept: "text/plain" },
47
+ signal: expect.any(AbortSignal),
48
+ }),
49
+ expect.objectContaining({
50
+ followRedirects: false,
51
+ maxRedirects: 0,
52
+ httpsOnly: true,
53
+ }),
54
+ );
55
+ expect(fetchSource.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
56
+ expect(fetchSource.mock.calls[0]?.[1]?.signal.aborted).toBe(false);
57
+ });
58
+
59
+ it("rejects content that does not match the pinned Git blob id", async () => {
60
+ const fetchSource = vi.fn(
61
+ async () => new Response("changed", { status: 200 }),
62
+ );
63
+
64
+ await expect(
65
+ loadPinnedCommunitySourceAttachments(
66
+ SOURCE,
67
+ "community-qr",
68
+ fetchSource as any,
69
+ ),
70
+ ).rejects.toMatchObject({
71
+ name: "PinnedCommunitySourceError",
72
+ failureKind: "integrity",
73
+ message: expect.stringContaining("failed its Git blob check"),
74
+ });
75
+ });
76
+
77
+ it("rejects source refs outside main", async () => {
78
+ await expect(
79
+ loadPinnedCommunitySourceAttachments(
80
+ { ...SOURCE, ref: "untrusted-branch" },
81
+ "community-qr",
82
+ vi.fn() as any,
83
+ ),
84
+ ).rejects.toMatchObject({
85
+ name: "PinnedCommunitySourceError",
86
+ failureKind: "integrity",
87
+ message: expect.stringContaining("no valid pinned file set"),
88
+ });
89
+ });
90
+
91
+ it("rejects redirects and non-success responses without accepting source", async () => {
92
+ const fetchSource = vi.fn(
93
+ async () =>
94
+ new Response(null, {
95
+ status: 302,
96
+ headers: { Location: "https://example.test/file" },
97
+ }),
98
+ );
99
+
100
+ await expect(
101
+ loadPinnedCommunitySourceAttachments(
102
+ SOURCE,
103
+ "community-qr",
104
+ fetchSource as any,
105
+ ),
106
+ ).rejects.toMatchObject({
107
+ name: "PinnedCommunitySourceError",
108
+ failureKind: "unavailable",
109
+ message: expect.stringContaining("could not be loaded (302)"),
110
+ });
111
+ });
112
+
113
+ it("reports transport failures separately from pinned content failures", async () => {
114
+ const fetchSource = vi.fn(async () => {
115
+ throw new TypeError("network unavailable");
116
+ });
117
+
118
+ await expect(
119
+ loadPinnedCommunitySourceAttachments(
120
+ SOURCE,
121
+ "community-qr",
122
+ fetchSource as any,
123
+ ),
124
+ ).rejects.toMatchObject({
125
+ name: "PinnedCommunitySourceError",
126
+ failureKind: "unavailable",
127
+ message: expect.stringContaining("network request failed"),
128
+ });
129
+ });
130
+
131
+ it("classifies oversized source bodies as integrity failures", async () => {
132
+ const fetchSource = vi.fn(
133
+ async () =>
134
+ new Response("large", {
135
+ status: 200,
136
+ headers: { "content-length": "131073" },
137
+ }),
138
+ );
139
+
140
+ await expect(
141
+ loadPinnedCommunitySourceAttachments(
142
+ SOURCE,
143
+ "community-qr",
144
+ fetchSource as any,
145
+ ),
146
+ ).rejects.toMatchObject({
147
+ failureKind: "integrity",
148
+ message: expect.stringContaining("exceeds the size limit"),
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,186 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
4
+ import type { BuilderAgentAttachment } from "@agent-native/core/server";
5
+
6
+ export interface PinnedCommunitySource {
7
+ id: string;
8
+ repo: string;
9
+ ref: string;
10
+ templatePath: string;
11
+ files: readonly { path: string; blobSha: string }[];
12
+ }
13
+
14
+ export type PinnedCommunitySourceFailureKind = "unavailable" | "integrity";
15
+
16
+ export class PinnedCommunitySourceError extends Error {
17
+ constructor(
18
+ readonly failureKind: PinnedCommunitySourceFailureKind,
19
+ message: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "PinnedCommunitySourceError";
23
+ }
24
+ }
25
+
26
+ const SOURCE_FILE_MAX_BYTES = 128 * 1024;
27
+ const SOURCE_TOTAL_MAX_BYTES = 64 * 1024;
28
+ const SOURCE_FETCH_TIMEOUT_MS = 10_000;
29
+
30
+ async function readSourceResponse(response: Response): Promise<Buffer> {
31
+ const contentLength = Number(response.headers.get("content-length"));
32
+ if (Number.isFinite(contentLength) && contentLength > SOURCE_FILE_MAX_BYTES) {
33
+ throw new PinnedCommunitySourceError(
34
+ "integrity",
35
+ "Pinned community source file exceeds the size limit.",
36
+ );
37
+ }
38
+ if (!response.body) return Buffer.alloc(0);
39
+
40
+ const reader = response.body.getReader();
41
+ const chunks: Buffer[] = [];
42
+ let size = 0;
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ size += value.byteLength;
47
+ if (size > SOURCE_FILE_MAX_BYTES) {
48
+ await reader.cancel();
49
+ throw new PinnedCommunitySourceError(
50
+ "integrity",
51
+ "Pinned community source file exceeds the size limit.",
52
+ );
53
+ }
54
+ chunks.push(Buffer.from(value));
55
+ }
56
+ return Buffer.concat(chunks, size);
57
+ }
58
+
59
+ async function readPinnedSourceFile(
60
+ source: PinnedCommunitySource,
61
+ file: PinnedCommunitySource["files"][number],
62
+ appId: string,
63
+ fetchSource: typeof ssrfSafeFetch,
64
+ ): Promise<{ attachment: BuilderAgentAttachment; size: number }> {
65
+ // Blob hashes pin the content, so source commits need not survive squash merges.
66
+ if (
67
+ !/^[a-f0-9]{40}$/i.test(file.blobSha) ||
68
+ source.ref !== "main" ||
69
+ file.path.startsWith("/") ||
70
+ file.path.split("/").some((part) => !part || part === "." || part === "..")
71
+ ) {
72
+ throw new PinnedCommunitySourceError(
73
+ "integrity",
74
+ "Curated community source contains an invalid file pin.",
75
+ );
76
+ }
77
+
78
+ const fileUrl = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${source.templatePath}/${file.path
79
+ .split("/")
80
+ .map(encodeURIComponent)
81
+ .join("/")}`;
82
+ let response: Response;
83
+ try {
84
+ response = await fetchSource(
85
+ fileUrl,
86
+ {
87
+ headers: { Accept: "text/plain" },
88
+ signal: AbortSignal.timeout(SOURCE_FETCH_TIMEOUT_MS),
89
+ },
90
+ {
91
+ followRedirects: false,
92
+ maxRedirects: 0,
93
+ httpsOnly: true,
94
+ assertUrlAllowed: (candidate) => {
95
+ if (new URL(candidate).hostname !== "raw.githubusercontent.com") {
96
+ throw new Error(
97
+ "Curated community source redirected to an untrusted host.",
98
+ );
99
+ }
100
+ },
101
+ },
102
+ );
103
+ } catch {
104
+ throw new PinnedCommunitySourceError(
105
+ "unavailable",
106
+ "Pinned community source network request failed.",
107
+ );
108
+ }
109
+ if (!response.ok) {
110
+ throw new PinnedCommunitySourceError(
111
+ "unavailable",
112
+ `Pinned community source file could not be loaded (${response.status}).`,
113
+ );
114
+ }
115
+
116
+ let bytes: Buffer;
117
+ let content: string;
118
+ try {
119
+ bytes = await readSourceResponse(response);
120
+ } catch (error) {
121
+ if (error instanceof PinnedCommunitySourceError) throw error;
122
+ throw new PinnedCommunitySourceError(
123
+ "unavailable",
124
+ "Pinned community source response could not be read.",
125
+ );
126
+ }
127
+ try {
128
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
129
+ } catch {
130
+ throw new PinnedCommunitySourceError(
131
+ "integrity",
132
+ "Pinned community source file is not valid UTF-8.",
133
+ );
134
+ }
135
+ const blobSha = createHash("sha1")
136
+ .update(`blob ${bytes.byteLength}\0`)
137
+ .update(bytes)
138
+ .digest("hex");
139
+ if (blobSha !== file.blobSha) {
140
+ throw new PinnedCommunitySourceError(
141
+ "integrity",
142
+ "Pinned community source file failed its Git blob check.",
143
+ );
144
+ }
145
+
146
+ return {
147
+ size: bytes.byteLength,
148
+ attachment: {
149
+ type: "upload",
150
+ contentType: file.path.endsWith(".json")
151
+ ? "application/json"
152
+ : "text/plain",
153
+ name: `${appId}/${file.path}`,
154
+ text: content,
155
+ dataUrl: "",
156
+ id: `${source.id}:${file.path}`,
157
+ },
158
+ };
159
+ }
160
+
161
+ export async function loadPinnedCommunitySourceAttachments(
162
+ source: PinnedCommunitySource,
163
+ appId: string,
164
+ fetchSource: typeof ssrfSafeFetch = ssrfSafeFetch,
165
+ ): Promise<BuilderAgentAttachment[]> {
166
+ if (source.ref !== "main" || source.files.length === 0) {
167
+ throw new PinnedCommunitySourceError(
168
+ "integrity",
169
+ "Curated community source has no valid pinned file set.",
170
+ );
171
+ }
172
+ const files = await Promise.all(
173
+ source.files.map((file) =>
174
+ readPinnedSourceFile(source, file, appId, fetchSource),
175
+ ),
176
+ );
177
+ if (
178
+ files.reduce((total, file) => total + file.size, 0) > SOURCE_TOTAL_MAX_BYTES
179
+ ) {
180
+ throw new PinnedCommunitySourceError(
181
+ "integrity",
182
+ "Pinned community template exceeds the total size limit.",
183
+ );
184
+ }
185
+ return files.map(({ attachment }) => attachment);
186
+ }