@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
@@ -1,3 +1,4 @@
1
+ import { createHmac } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -10,23 +11,44 @@ import {
10
11
  import { afterEach, describe, expect, it, vi } from "vitest";
11
12
 
12
13
  import {
14
+ archiveWorkspaceApp,
13
15
  generateWorkspaceAppDescription,
14
16
  getAppCreationSettings,
15
17
  listAvailableWorkspaceTemplates,
18
+ listOrganizationVisibleWorkspaceApps,
16
19
  listWorkspaceApps,
17
20
  scaffoldWorkspaceAppFromTemplate,
18
21
  setAppCreationSettings,
19
22
  startWorkspaceAppCreation,
20
23
  updateWorkspaceAppMetadata,
21
24
  } from "./app-creation-store.js";
25
+ import {
26
+ orgAppsDirectoryAccessCache,
27
+ orgAppsDirectoryCache,
28
+ } from "./org-apps-directory-cache.js";
22
29
 
23
30
  const originalFetch = globalThis.fetch;
24
31
  const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
25
32
 
33
+ function verifyJwtWithSecret(
34
+ token: string,
35
+ secret: string,
36
+ ): Record<string, unknown> {
37
+ const [header, payload, signature] = token.split(".");
38
+ const expected = createHmac("sha256", secret)
39
+ .update(`${header}.${payload}`)
40
+ .digest("base64url");
41
+ if (signature !== expected || !payload) {
42
+ throw new Error("JWT signature did not match the provided secret.");
43
+ }
44
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
45
+ }
46
+
26
47
  const mocks = vi.hoisted(() => {
27
48
  const settings = new Map<string, unknown>();
28
49
  const state = {
29
50
  orgRole: "admin" as string | null,
51
+ workspaceApps: [] as Record<string, unknown>[],
30
52
  };
31
53
  const executedSql: string[] = [];
32
54
  return {
@@ -48,6 +70,8 @@ const mocks = vi.hoisted(() => {
48
70
  getOrgDomain: vi.fn(async (orgId: string) =>
49
71
  orgId ? "example.test" : null,
50
72
  ),
73
+ getOrgA2ASecret: vi.fn(async () => null as string | null),
74
+ signA2AToken: vi.fn(),
51
75
  isWorkspaceAppAccessAllowed: vi.fn(
52
76
  async (): Promise<boolean | "unavailable"> => true,
53
77
  ),
@@ -62,6 +86,20 @@ const mocks = vi.hoisted(() => {
62
86
  ? statement
63
87
  : String((statement as { sql?: unknown })?.sql ?? "");
64
88
  executedSql.push(sql);
89
+ if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
90
+ const args = (statement as { args?: unknown[] }).args ?? [];
91
+ return {
92
+ rows: state.workspaceApps.filter((app) => args.includes(app.id)),
93
+ rowsAffected: 0,
94
+ };
95
+ }
96
+ if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
97
+ const args = (statement as { args?: unknown[] }).args ?? [];
98
+ return {
99
+ rows: state.workspaceApps.filter((app) => args.includes(app.id)),
100
+ rowsAffected: 0,
101
+ };
102
+ }
65
103
  if (sql.includes("SELECT id FROM workspace_apps")) {
66
104
  return { rows: [], rowsAffected: 0 };
67
105
  }
@@ -71,6 +109,22 @@ const mocks = vi.hoisted(() => {
71
109
  };
72
110
  }),
73
111
  })),
112
+ getDb: vi.fn(() => ({
113
+ select: vi.fn(() => ({
114
+ from: vi.fn(() => ({
115
+ where: vi.fn(async () =>
116
+ state.workspaceApps
117
+ .filter(
118
+ (app) =>
119
+ app.org_id === "org-123" &&
120
+ app.visibility === "org" &&
121
+ app.org_enabled === 1,
122
+ )
123
+ .map((app) => ({ id: String(app.id) })),
124
+ ),
125
+ })),
126
+ })),
127
+ })),
74
128
  resolveBuilderCredentialsDetailed: vi.fn(async () => ({
75
129
  privateKey: null as string | null,
76
130
  publicKey: null as string | null,
@@ -94,6 +148,23 @@ const mocks = vi.hoisted(() => {
94
148
  };
95
149
  });
96
150
 
151
+ vi.mock("@agent-native/core/a2a", async (importOriginal) => {
152
+ const actual =
153
+ await importOriginal<typeof import("@agent-native/core/a2a")>();
154
+ mocks.signA2AToken.mockImplementation((...args: any[]) =>
155
+ actual.signA2AToken(...args),
156
+ );
157
+ return {
158
+ ...actual,
159
+ signA2AToken: (...args: any[]) => mocks.signA2AToken(...args),
160
+ };
161
+ });
162
+
163
+ vi.mock("../../db/index.js", async (importOriginal) => {
164
+ const actual = await importOriginal<typeof import("../../db/index.js")>();
165
+ return { ...actual, getDb: mocks.getDb };
166
+ });
167
+
97
168
  vi.mock("@agent-native/core/secrets", async (importOriginal) => {
98
169
  const actual =
99
170
  await importOriginal<typeof import("@agent-native/core/secrets")>();
@@ -124,6 +195,7 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
124
195
  await importOriginal<typeof import("@agent-native/core/org")>();
125
196
  return {
126
197
  ...actual,
198
+ getOrgA2ASecret: (...args: any[]) => mocks.getOrgA2ASecret(...args),
127
199
  getOrgDomain: (...args: any[]) => mocks.getOrgDomain(...args),
128
200
  isWorkspaceAppAccessAllowed: (...args: any[]) =>
129
201
  mocks.isWorkspaceAppAccessAllowed(...args),
@@ -176,6 +248,8 @@ afterEach(() => {
176
248
  mocks.settings.clear();
177
249
  mocks.getOrgSetting.mockReset();
178
250
  mocks.getOrgSetting.mockResolvedValue(null);
251
+ mocks.getOrgA2ASecret.mockReset();
252
+ mocks.getOrgA2ASecret.mockResolvedValue(null);
179
253
  mocks.isWorkspaceAppAccessAllowed.mockReset();
180
254
  mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
181
255
  mocks.mutateSetting.mockReset();
@@ -187,6 +261,9 @@ afterEach(() => {
187
261
  },
188
262
  );
189
263
  mocks.state.orgRole = "admin";
264
+ mocks.state.workspaceApps = [];
265
+ orgAppsDirectoryCache.clear();
266
+ orgAppsDirectoryAccessCache.clear();
190
267
  mocks.getDbExec.mockReset();
191
268
  mocks.getDbExec.mockImplementation(() => ({
192
269
  execute: vi.fn(async (statement: unknown) => {
@@ -195,6 +272,24 @@ afterEach(() => {
195
272
  ? statement
196
273
  : String((statement as { sql?: unknown })?.sql ?? "");
197
274
  mocks.executedSql.push(sql);
275
+ if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
276
+ const args = (statement as { args?: unknown[] }).args ?? [];
277
+ return {
278
+ rows: mocks.state.workspaceApps.filter((app) =>
279
+ args.includes(app.id),
280
+ ),
281
+ rowsAffected: 0,
282
+ };
283
+ }
284
+ if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
285
+ const args = (statement as { args?: unknown[] }).args ?? [];
286
+ return {
287
+ rows: mocks.state.workspaceApps.filter((app) =>
288
+ args.includes(app.id),
289
+ ),
290
+ rowsAffected: 0,
291
+ };
292
+ }
198
293
  if (sql.includes("SELECT id FROM workspace_apps")) {
199
294
  return { rows: [], rowsAffected: 0 };
200
295
  }
@@ -373,6 +468,71 @@ describe("listWorkspaceApps", () => {
373
468
  ]);
374
469
  });
375
470
 
471
+ it("lists only ready org-visible apps for an organization principal", async () => {
472
+ stubNoPendingContext();
473
+ stubManifest([
474
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
475
+ { id: "qr", name: "QR", path: "/qr" },
476
+ { id: "private-app", name: "Private", path: "/private-app" },
477
+ { id: "disabled-app", name: "Disabled", path: "/disabled-app" },
478
+ { id: "foreign-app", name: "Foreign", path: "/foreign-app" },
479
+ ]);
480
+ mocks.settings.set("dispatch-app-creation-settings:org:org-123", {
481
+ pendingApps: [pendingApp("pending-app")],
482
+ });
483
+ mocks.state.workspaceApps = [
484
+ {
485
+ id: "dispatch",
486
+ owner_email: "owner@example.test",
487
+ org_id: "org-123",
488
+ visibility: "org",
489
+ org_enabled: 1,
490
+ },
491
+ {
492
+ id: "qr",
493
+ owner_email: "owner@example.test",
494
+ org_id: "org-123",
495
+ visibility: "org",
496
+ org_enabled: 1,
497
+ },
498
+ {
499
+ id: "private-app",
500
+ owner_email: "dev@example.test",
501
+ org_id: "org-123",
502
+ visibility: "private",
503
+ org_enabled: 1,
504
+ },
505
+ {
506
+ id: "disabled-app",
507
+ owner_email: "owner@example.test",
508
+ org_id: "org-123",
509
+ visibility: "org",
510
+ org_enabled: 0,
511
+ },
512
+ {
513
+ id: "foreign-app",
514
+ owner_email: "owner@example.test",
515
+ org_id: "another-org",
516
+ visibility: "org",
517
+ org_enabled: 1,
518
+ },
519
+ ];
520
+
521
+ const apps = await runWithRequestContext({ orgId: "org-123" }, () =>
522
+ listOrganizationVisibleWorkspaceApps(),
523
+ );
524
+
525
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "qr"]);
526
+ expect(mocks.executedSql.some((sql) => sql.includes("INSERT INTO"))).toBe(
527
+ false,
528
+ );
529
+ expect(
530
+ mocks.executedSql.some((sql) =>
531
+ sql.includes("INSERT INTO workspace_apps"),
532
+ ),
533
+ ).toBe(false);
534
+ });
535
+
376
536
  it.each([401, 403])(
377
537
  "keeps a local gateway denial on the unverified fallback path (%i)",
378
538
  async (status) => {
@@ -472,6 +632,130 @@ describe("listWorkspaceApps", () => {
472
632
  expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
473
633
  });
474
634
 
635
+ it("signs the hosted registry request with the caller's organization secret", async () => {
636
+ const fetchMock = vi.fn(
637
+ async () =>
638
+ new Response(JSON.stringify([]), {
639
+ headers: { "content-type": "application/json" },
640
+ }),
641
+ );
642
+ vi.stubGlobal("fetch", fetchMock);
643
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
644
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
645
+ mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
646
+
647
+ await runWithRequestContext(
648
+ { userEmail: "dev@example.test", orgId: "org-1" },
649
+ () => listWorkspaceApps({ includeAgentCards: false }),
650
+ );
651
+
652
+ const authorization = fetchMock.mock.calls[0]?.[1]?.headers
653
+ ?.Authorization as string;
654
+ const token = authorization.slice("Bearer ".length);
655
+ const payload = verifyJwtWithSecret(token, "workspace-org-secret");
656
+ expect(payload).toMatchObject({
657
+ sub: "dev@example.test",
658
+ org_domain: "example.test",
659
+ aud: "https://agent-workspace.builder.io",
660
+ });
661
+ expect(() =>
662
+ verifyJwtWithSecret(token, "dispatch-deployment-secret"),
663
+ ).toThrow();
664
+ });
665
+
666
+ it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
667
+ const fetchMock = vi.fn();
668
+ vi.stubGlobal("fetch", fetchMock);
669
+ vi.stubEnv("A2A_SECRET", "");
670
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
671
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(null);
672
+ stubManifest([
673
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
674
+ { id: "clips", name: "Clips", path: "/clips" },
675
+ ]);
676
+
677
+ await expect(
678
+ runWithRequestContext(
679
+ { userEmail: "dev@example.test", orgId: "org-1" },
680
+ () => listWorkspaceApps({ includeAgentCards: false }),
681
+ ),
682
+ ).rejects.toMatchObject({
683
+ statusCode: 503,
684
+ errorCode: "workspace_gateway_a2a_secret_missing",
685
+ });
686
+ expect(fetchMock).not.toHaveBeenCalled();
687
+ });
688
+
689
+ it("reports organization A2A secret lookup failures before using manifest apps", async () => {
690
+ const fetchMock = vi.fn();
691
+ vi.stubGlobal("fetch", fetchMock);
692
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
693
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
694
+ stubManifest([
695
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
696
+ { id: "clips", name: "Clips", path: "/clips" },
697
+ ]);
698
+ mocks.getOrgA2ASecret.mockRejectedValueOnce(
699
+ new Error("database unavailable"),
700
+ );
701
+
702
+ await expect(
703
+ runWithRequestContext(
704
+ { userEmail: "dev@example.test", orgId: "org-1" },
705
+ () => listWorkspaceApps({ includeAgentCards: false }),
706
+ ),
707
+ ).rejects.toMatchObject({
708
+ statusCode: 503,
709
+ errorCode: "workspace_gateway_org_a2a_secret_unavailable",
710
+ });
711
+ expect(fetchMock).not.toHaveBeenCalled();
712
+ });
713
+
714
+ it("reports token signing failures before using manifest apps", async () => {
715
+ const fetchMock = vi.fn();
716
+ vi.stubGlobal("fetch", fetchMock);
717
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
718
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
719
+ stubManifest([
720
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
721
+ { id: "clips", name: "Clips", path: "/clips" },
722
+ ]);
723
+ mocks.signA2AToken.mockRejectedValueOnce(new Error("signing unavailable"));
724
+
725
+ await expect(
726
+ runWithRequestContext(
727
+ { userEmail: "dev@example.test", orgId: "org-1" },
728
+ () => listWorkspaceApps({ includeAgentCards: false }),
729
+ ),
730
+ ).rejects.toMatchObject({
731
+ statusCode: 503,
732
+ errorCode: "workspace_gateway_token_signing_failed",
733
+ });
734
+ expect(fetchMock).not.toHaveBeenCalled();
735
+ });
736
+
737
+ it("does not hide an organization lookup failure behind manifest apps", async () => {
738
+ const fetchMock = vi.fn();
739
+ vi.stubGlobal("fetch", fetchMock);
740
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
741
+ stubManifest([
742
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
743
+ { id: "clips", name: "Clips", path: "/clips" },
744
+ ]);
745
+ mocks.getOrgDomain.mockRejectedValueOnce(new Error("database unavailable"));
746
+
747
+ await expect(
748
+ runWithRequestContext(
749
+ { userEmail: "dev@example.test", orgId: "org-1" },
750
+ () => listWorkspaceApps({ includeAgentCards: false }),
751
+ ),
752
+ ).rejects.toMatchObject({
753
+ statusCode: 503,
754
+ errorCode: "workspace_gateway_org_domain_unavailable",
755
+ });
756
+ expect(fetchMock).not.toHaveBeenCalled();
757
+ });
758
+
475
759
  it("binds hosted registry tokens to the mounted Dispatch path", async () => {
476
760
  const fetchMock = vi.fn(async () => {
477
761
  return new Response(
@@ -665,6 +949,122 @@ describe("listWorkspaceApps", () => {
665
949
  expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
666
950
  });
667
951
 
952
+ it("treats an empty hosted registry response as a successful empty list", async () => {
953
+ const fetchMock = vi.fn(
954
+ async () =>
955
+ new Response(JSON.stringify([]), {
956
+ headers: { "content-type": "application/json" },
957
+ }),
958
+ );
959
+ vi.stubGlobal("fetch", fetchMock);
960
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
961
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
962
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
963
+
964
+ const apps = await runWithRequestContext(
965
+ { userEmail: "dev@example.test" },
966
+ () => listWorkspaceApps({ includeAgentCards: false }),
967
+ );
968
+
969
+ expect(apps).toEqual([]);
970
+ expect(fetchMock).toHaveBeenCalledTimes(1);
971
+ });
972
+
973
+ it.each([206, 500, 502, 503])(
974
+ "surfaces hosted registry HTTP %i failures instead of using manifest apps",
975
+ async (status) => {
976
+ const fetchMock = vi.fn(
977
+ async () => new Response("gateway unavailable", { status }),
978
+ );
979
+ vi.stubGlobal("fetch", fetchMock);
980
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
981
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
982
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
983
+
984
+ await expect(
985
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
986
+ listWorkspaceApps({ includeAgentCards: false }),
987
+ ),
988
+ ).rejects.toMatchObject({
989
+ statusCode: 503,
990
+ errorCode: `workspace_gateway_http_${status}`,
991
+ });
992
+ expect(fetchMock).toHaveBeenCalledTimes(1);
993
+ },
994
+ );
995
+
996
+ it("surfaces hosted registry transport failures instead of using manifest apps", async () => {
997
+ const fetchMock = vi.fn(async () => {
998
+ throw new TypeError("Network connection failed");
999
+ });
1000
+ vi.stubGlobal("fetch", fetchMock);
1001
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1002
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1003
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1004
+
1005
+ await expect(
1006
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1007
+ listWorkspaceApps({ includeAgentCards: false }),
1008
+ ),
1009
+ ).rejects.toMatchObject({
1010
+ statusCode: 503,
1011
+ errorCode: "workspace_gateway_unavailable",
1012
+ });
1013
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1014
+ });
1015
+
1016
+ it("surfaces malformed hosted registry responses instead of using manifest apps", async () => {
1017
+ const fetchMock = vi.fn(
1018
+ async () =>
1019
+ new Response("not json", {
1020
+ headers: { "content-type": "application/json" },
1021
+ }),
1022
+ );
1023
+ vi.stubGlobal("fetch", fetchMock);
1024
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1025
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1026
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1027
+
1028
+ await expect(
1029
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1030
+ listWorkspaceApps({ includeAgentCards: false }),
1031
+ ),
1032
+ ).rejects.toMatchObject({
1033
+ statusCode: 503,
1034
+ errorCode: "workspace_gateway_invalid_response",
1035
+ });
1036
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1037
+ });
1038
+
1039
+ it.each([
1040
+ [{ id: "dispatch", name: "Dispatch", path: "/dispatch" }, null],
1041
+ [
1042
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
1043
+ { id: "DISPATCH", name: "Duplicate Dispatch", path: "/other" },
1044
+ ],
1045
+ ])("rejects incomplete hosted registry lists: %j", async (apps) => {
1046
+ const fetchMock = vi.fn(
1047
+ async () =>
1048
+ new Response(JSON.stringify(apps), {
1049
+ headers: { "content-type": "application/json" },
1050
+ }),
1051
+ );
1052
+ vi.stubGlobal("fetch", fetchMock);
1053
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1054
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1055
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1056
+
1057
+ await expect(
1058
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1059
+ listWorkspaceApps({ includeAgentCards: false }),
1060
+ ),
1061
+ ).rejects.toMatchObject({
1062
+ statusCode: 503,
1063
+ errorCode: "workspace_gateway_invalid_response",
1064
+ });
1065
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1066
+ });
1067
+
668
1068
  it("passes the Vercel protection bypass to the hosted workspace registry", async () => {
669
1069
  const fetchMock = vi.fn(async () => {
670
1070
  return new Response(
@@ -731,10 +1131,10 @@ describe("listWorkspaceApps", () => {
731
1131
  );
732
1132
  });
733
1133
 
734
- it("does not recursively call the hosted registry action", async () => {
1134
+ it("skips a same-origin hosted registry without credentials", async () => {
735
1135
  const fetchMock = vi.fn();
736
1136
  vi.stubGlobal("fetch", fetchMock);
737
- vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1137
+ vi.stubEnv("A2A_SECRET", "");
738
1138
  vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
739
1139
 
740
1140
  const apps = await runWithRequestContext(
@@ -763,9 +1163,13 @@ describe("listWorkspaceApps", () => {
763
1163
  { userEmail: "dev@example.test", orgId: "org-without-domain" },
764
1164
  () => listWorkspaceApps({ includeAgentCards: false }),
765
1165
  ),
766
- ).rejects.toThrow(
767
- "Workspace apps gateway cannot authenticate without a resolved organization domain.",
768
- );
1166
+ ).rejects.toMatchObject({
1167
+ name: "WorkspaceAppsGatewayAuthorizationError",
1168
+ statusCode: 401,
1169
+ errorCode: "workspace_gateway_org_domain_missing",
1170
+ message:
1171
+ "Workspace apps gateway cannot authenticate without a resolved organization domain.",
1172
+ });
769
1173
  expect(fetchMock).not.toHaveBeenCalled();
770
1174
  });
771
1175
 
@@ -1508,6 +1912,51 @@ describe("listWorkspaceApps", () => {
1508
1912
  );
1509
1913
  });
1510
1914
 
1915
+ it("keeps the curated source id on a pending app summary", async () => {
1916
+ stubNoPendingContext();
1917
+ stubManifest();
1918
+ mocks.settings.set(settingsKey, {
1919
+ pendingApps: [
1920
+ pendingApp("community-qr", {
1921
+ communitySourceId: "community-qr",
1922
+ }),
1923
+ ],
1924
+ });
1925
+
1926
+ const apps = await runWithRequestContext(
1927
+ { userEmail: "dev@example.test" },
1928
+ () => listWorkspaceApps({ includeAgentCards: false }),
1929
+ );
1930
+
1931
+ expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
1932
+ status: "pending",
1933
+ communitySourceId: "community-qr",
1934
+ });
1935
+ });
1936
+
1937
+ it("keeps the curated source id on ready app metadata", async () => {
1938
+ stubNoPendingContext();
1939
+ stubManifest([
1940
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
1941
+ { id: "community-qr", name: "QR", path: "/community-qr" },
1942
+ ]);
1943
+ mocks.settings.set("workspace-app-metadata:user:dev@example.test", {
1944
+ apps: {
1945
+ "community-qr": { communitySourceId: "community-qr" },
1946
+ },
1947
+ });
1948
+
1949
+ const apps = await runWithRequestContext(
1950
+ { userEmail: "dev@example.test" },
1951
+ () => listWorkspaceApps({ includeAgentCards: false }),
1952
+ );
1953
+
1954
+ expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
1955
+ status: "ready",
1956
+ communitySourceId: "community-qr",
1957
+ });
1958
+ });
1959
+
1511
1960
  it("keeps unscoped legacy pending rows visible when there is no deploy context", async () => {
1512
1961
  stubNoPendingContext();
1513
1962
  stubManifest();
@@ -1643,7 +2092,7 @@ describe("listWorkspaceApps", () => {
1643
2092
  expect(apps.find((app) => app.id === "mail")?.status).toBe("ready");
1644
2093
  });
1645
2094
 
1646
- it("lets workspace admins update app display metadata", async () => {
2095
+ it("lets workspace admins update app profile metadata", async () => {
1647
2096
  stubNoPendingContext();
1648
2097
  stubManifest([
1649
2098
  { id: "dispatch", name: "Dispatch", path: "/dispatch" },
@@ -1662,24 +2111,26 @@ describe("listWorkspaceApps", () => {
1662
2111
  appId: "todo",
1663
2112
  name: "Todo Board",
1664
2113
  description: "Tracks team work.",
2114
+ whenToUse: ["Create a task from a meeting note"],
1665
2115
  }),
1666
2116
  );
1667
2117
 
1668
2118
  expect(updated.name).toBe("Todo Board");
1669
2119
  expect(updated.description).toBe("Tracks team work.");
2120
+ expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
1670
2121
  expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
1671
2122
  apps: {
1672
2123
  todo: expect.objectContaining({
1673
2124
  name: "Todo Board",
1674
2125
  description: "Tracks team work.",
2126
+ whenToUse: ["Create a task from a meeting note"],
1675
2127
  updatedBy: "dev@example.test",
1676
2128
  }),
1677
2129
  },
1678
2130
  });
1679
2131
  });
1680
2132
 
1681
- it("lets workspace members update app display metadata", async () => {
1682
- mocks.state.orgRole = "member";
2133
+ it("does not overwrite app profile metadata when its settings are unreadable", async () => {
1683
2134
  stubNoPendingContext();
1684
2135
  stubManifest([
1685
2136
  {
@@ -1689,30 +2140,262 @@ describe("listWorkspaceApps", () => {
1689
2140
  path: "/todo",
1690
2141
  },
1691
2142
  ]);
2143
+ const key = "workspace-app-metadata:org:org-123";
2144
+ const existing = { apps: { other: { name: "Preserve me" } } };
2145
+ mocks.settings.set(key, existing);
2146
+ let metadataReads = 0;
2147
+ mocks.getSetting.mockImplementation(async (settingKey: string) => {
2148
+ if (settingKey === key && ++metadataReads === 2) {
2149
+ throw new Error("metadata settings unavailable");
2150
+ }
2151
+ return mocks.settings.get(settingKey) ?? null;
2152
+ });
2153
+
2154
+ await expect(
2155
+ runWithRequestContext(
2156
+ { userEmail: "dev@example.test", orgId: "org-123" },
2157
+ () => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
2158
+ ),
2159
+ ).rejects.toThrow("metadata settings unavailable");
2160
+
2161
+ expect(metadataReads).toBe(2);
2162
+ expect(mocks.putSetting).not.toHaveBeenCalled();
2163
+ expect(mocks.settings.get(key)).toEqual(existing);
2164
+ });
2165
+
2166
+ it("does not overwrite workspace settings when archiving after a read failure", async () => {
2167
+ const key = "dispatch-app-creation-settings:org:org-123";
2168
+ const existing = {
2169
+ builderProjectId: "project-123",
2170
+ pendingCommunityReservations: ["community-qr"],
2171
+ archivedAppIds: [],
2172
+ };
2173
+ mocks.settings.set(key, existing);
2174
+ mocks.getSetting.mockRejectedValueOnce(
2175
+ new Error("workspace settings unavailable"),
2176
+ );
2177
+
2178
+ await expect(
2179
+ runWithRequestContext(
2180
+ { userEmail: "dev@example.test", orgId: "org-123" },
2181
+ () => archiveWorkspaceApp({ appId: "todo" }),
2182
+ ),
2183
+ ).rejects.toThrow("workspace settings unavailable");
2184
+
2185
+ expect(mocks.putSetting).not.toHaveBeenCalled();
2186
+ expect(mocks.settings.get(key)).toEqual(existing);
2187
+ });
2188
+
2189
+ it("preserves generated name and description when only examples change", async () => {
2190
+ stubNoPendingContext();
2191
+ stubManifest([
2192
+ {
2193
+ id: "todo",
2194
+ name: "Todo",
2195
+ description: "Manifest description",
2196
+ path: "/todo",
2197
+ },
2198
+ ]);
2199
+ mocks.settings.set("workspace-app-metadata:org:org-123", {
2200
+ apps: {
2201
+ todo: {
2202
+ description: "Generated description",
2203
+ generated: true,
2204
+ },
2205
+ },
2206
+ });
1692
2207
 
1693
2208
  const updated = await runWithRequestContext(
1694
2209
  { userEmail: "dev@example.test", orgId: "org-123" },
1695
2210
  () =>
1696
2211
  updateWorkspaceAppMetadata({
1697
2212
  appId: "todo",
1698
- name: "Todo Board",
1699
- description: "Tracks team work.",
2213
+ whenToUse: ["Create a task from a meeting note"],
1700
2214
  }),
1701
2215
  );
1702
2216
 
1703
- expect(updated).toMatchObject({
1704
- name: "Todo Board",
1705
- description: "Tracks team work.",
1706
- });
2217
+ expect(updated.name).toBe("Todo");
2218
+ expect(updated.description).toBe("Manifest description");
2219
+ expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
1707
2220
  expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
1708
2221
  apps: {
1709
2222
  todo: expect.objectContaining({
1710
- name: "Todo Board",
1711
- description: "Tracks team work.",
2223
+ description: "Generated description",
2224
+ generated: true,
2225
+ whenToUse: ["Create a task from a meeting note"],
1712
2226
  updatedBy: "dev@example.test",
1713
2227
  }),
1714
2228
  },
1715
2229
  });
2230
+ expect(
2231
+ (mocks.settings.get("workspace-app-metadata:org:org-123") as any).apps
2232
+ .todo,
2233
+ ).not.toHaveProperty("name");
2234
+ });
2235
+
2236
+ it("invalidates org directory discovery after a profile edit", async () => {
2237
+ stubNoPendingContext();
2238
+ stubManifest([
2239
+ { id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
2240
+ ]);
2241
+ const load = vi.fn(async () => [
2242
+ {
2243
+ id: "todo",
2244
+ name: "Old profile",
2245
+ url: "https://todo.example.test",
2246
+ },
2247
+ ]);
2248
+ await orgAppsDirectoryCache.get("org-123|organization", load);
2249
+ const loadAccess = vi.fn(async () => new Set(["todo"]));
2250
+ await orgAppsDirectoryAccessCache.get("org-123|organization", loadAccess);
2251
+
2252
+ await runWithRequestContext(
2253
+ { userEmail: "dev@example.test", orgId: "org-123" },
2254
+ () =>
2255
+ updateWorkspaceAppMetadata({
2256
+ appId: "todo",
2257
+ description: "Updated profile",
2258
+ }),
2259
+ );
2260
+
2261
+ const refreshedLoad = vi.fn(async () => [
2262
+ {
2263
+ id: "todo",
2264
+ name: "Updated profile",
2265
+ url: "https://todo.example.test",
2266
+ },
2267
+ ]);
2268
+ await expect(
2269
+ orgAppsDirectoryCache.get("org-123|organization", refreshedLoad),
2270
+ ).resolves.toEqual([
2271
+ {
2272
+ id: "todo",
2273
+ name: "Updated profile",
2274
+ url: "https://todo.example.test",
2275
+ },
2276
+ ]);
2277
+ expect(refreshedLoad).toHaveBeenCalledOnce();
2278
+
2279
+ const refreshedAccess = vi.fn(async () => new Set(["todo"]));
2280
+ await expect(
2281
+ orgAppsDirectoryAccessCache.get("org-123|organization", refreshedAccess),
2282
+ ).resolves.toEqual(new Set(["todo"]));
2283
+ expect(refreshedAccess).toHaveBeenCalledOnce();
2284
+ });
2285
+
2286
+ it("clears explicit null metadata and preserves an empty when-to-use list", async () => {
2287
+ stubNoPendingContext();
2288
+ stubManifest([
2289
+ {
2290
+ id: "todo",
2291
+ name: "Manifest name",
2292
+ description: "Manifest description",
2293
+ path: "/todo",
2294
+ },
2295
+ ]);
2296
+ mocks.settings.set("workspace-app-metadata:org:org-123", {
2297
+ apps: {
2298
+ todo: {
2299
+ name: "Override name",
2300
+ description: "Override description",
2301
+ whenToUse: ["Old example"],
2302
+ },
2303
+ },
2304
+ });
2305
+
2306
+ await runWithRequestContext(
2307
+ { userEmail: "dev@example.test", orgId: "org-123" },
2308
+ () =>
2309
+ updateWorkspaceAppMetadata({
2310
+ appId: "todo",
2311
+ name: null,
2312
+ description: null,
2313
+ whenToUse: [],
2314
+ }),
2315
+ );
2316
+
2317
+ expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
2318
+ apps: {
2319
+ todo: expect.objectContaining({ whenToUse: [] }),
2320
+ },
2321
+ });
2322
+ });
2323
+
2324
+ it("reports owner-role lookup failures as unavailable instead of unauthorized", async () => {
2325
+ stubNoPendingContext();
2326
+ stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
2327
+ mocks.getDbExec.mockReturnValue({
2328
+ execute: vi.fn(async () => {
2329
+ throw new Error("database unavailable");
2330
+ }),
2331
+ } as any);
2332
+
2333
+ await expect(
2334
+ runWithRequestContext(
2335
+ { userEmail: "dev@example.test", orgId: "org-123" },
2336
+ () => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
2337
+ ),
2338
+ ).rejects.toMatchObject({
2339
+ name: "AppCreationOwnerRoleUnavailableError",
2340
+ statusCode: 503,
2341
+ });
2342
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2343
+ false,
2344
+ );
2345
+ });
2346
+
2347
+ it("rejects malformed profile examples before writing", async () => {
2348
+ stubNoPendingContext();
2349
+ stubManifest([
2350
+ { id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
2351
+ ]);
2352
+ const tooManyExamples = Array.from(
2353
+ { length: 7 },
2354
+ (_, index) => `Example ${index}`,
2355
+ );
2356
+
2357
+ await expect(
2358
+ runWithRequestContext(
2359
+ { userEmail: "dev@example.test", orgId: "org-123" },
2360
+ () =>
2361
+ updateWorkspaceAppMetadata({
2362
+ appId: "todo",
2363
+ whenToUse: tooManyExamples,
2364
+ }),
2365
+ ),
2366
+ ).rejects.toThrow(/up to 6 non-empty examples/);
2367
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2368
+ false,
2369
+ );
2370
+ });
2371
+
2372
+ it("rejects workspace member updates to app profile metadata", async () => {
2373
+ mocks.state.orgRole = "member";
2374
+ stubNoPendingContext();
2375
+ stubManifest([
2376
+ {
2377
+ id: "todo",
2378
+ name: "Todo",
2379
+ description: "Original description",
2380
+ path: "/todo",
2381
+ },
2382
+ ]);
2383
+
2384
+ await expect(
2385
+ runWithRequestContext(
2386
+ { userEmail: "dev@example.test", orgId: "org-123" },
2387
+ () =>
2388
+ updateWorkspaceAppMetadata({
2389
+ appId: "todo",
2390
+ name: "Todo Board",
2391
+ description: "Tracks team work.",
2392
+ whenToUse: ["Create a task from a meeting note"],
2393
+ }),
2394
+ ),
2395
+ ).rejects.toMatchObject({ statusCode: 403 });
2396
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2397
+ false,
2398
+ );
1716
2399
  });
1717
2400
 
1718
2401
  it("generates a concise seed description from an app prompt", () => {
@@ -1812,6 +2495,82 @@ describe("startWorkspaceAppCreation", () => {
1812
2495
  });
1813
2496
  });
1814
2497
 
2498
+ it("persists the curated source id for local-agent app creation", async () => {
2499
+ const result = await runWithRequestContext(
2500
+ { userEmail: "dev@example.test", orgId: "org-123" },
2501
+ () =>
2502
+ startWorkspaceAppCreation({
2503
+ prompt: "Install the curated QR template",
2504
+ appId: "community-qr",
2505
+ communitySourceId: "community-qr",
2506
+ attachments: [
2507
+ {
2508
+ type: "upload",
2509
+ contentType: "text/plain",
2510
+ name: "community-qr/README.md",
2511
+ text: "Pinned template source",
2512
+ dataUrl: "",
2513
+ id: "community-qr:README.md",
2514
+ },
2515
+ ],
2516
+ }),
2517
+ );
2518
+
2519
+ expect(result).toMatchObject({
2520
+ mode: "local-agent",
2521
+ attachments: [
2522
+ {
2523
+ type: "file",
2524
+ contentType: "text/plain",
2525
+ name: "community-qr/README.md",
2526
+ text: "Pinned template source",
2527
+ },
2528
+ ],
2529
+ });
2530
+ expect(
2531
+ mocks.settings.get("workspace-app-metadata:org:org-123"),
2532
+ ).toMatchObject({
2533
+ apps: {
2534
+ "community-qr": { communitySourceId: "community-qr" },
2535
+ },
2536
+ });
2537
+ expect(
2538
+ mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
2539
+ ).toMatchObject({
2540
+ pendingApps: [
2541
+ expect.objectContaining({
2542
+ id: "community-qr",
2543
+ communitySourceId: "community-qr",
2544
+ createdBy: "dev@example.test",
2545
+ }),
2546
+ ],
2547
+ });
2548
+ });
2549
+
2550
+ it("atomically reserves local community installs against duplicate requests", async () => {
2551
+ const input = {
2552
+ prompt: "Install the curated QR template",
2553
+ appId: "community-qr",
2554
+ communitySourceId: "community-qr",
2555
+ };
2556
+ const context = { userEmail: "dev@example.test", orgId: "org-123" };
2557
+
2558
+ const first = await runWithRequestContext(context, () =>
2559
+ startWorkspaceAppCreation(input),
2560
+ );
2561
+ const duplicate = await runWithRequestContext(context, () =>
2562
+ startWorkspaceAppCreation(input),
2563
+ );
2564
+
2565
+ expect(first.mode).toBe("local-agent");
2566
+ expect(duplicate).toMatchObject({
2567
+ mode: "app-id-taken",
2568
+ appId: "community-qr",
2569
+ conflict: "pending",
2570
+ owner: "dev@example.test",
2571
+ });
2572
+ });
2573
+
1815
2574
  it("rejects a cross-member collision with an active pending app id", async () => {
1816
2575
  stubHostedRuntime();
1817
2576
  stubBuilderProjectConfigured();
@@ -1961,6 +2720,51 @@ describe("startWorkspaceAppCreation", () => {
1961
2720
  expect(mocks.mutateSetting).toHaveBeenCalled();
1962
2721
  });
1963
2722
 
2723
+ it("persists the curated source id with a hosted pending reservation", async () => {
2724
+ stubHostedRuntime();
2725
+ stubBuilderProjectConfigured();
2726
+ mocks.resolveBuilderCredentialsDetailed.mockResolvedValue(
2727
+ credentials({
2728
+ privateKey: "priv",
2729
+ publicKey: "pub",
2730
+ userId: "builder-user-42",
2731
+ }),
2732
+ );
2733
+ mocks.runBuilderAgent.mockResolvedValue({
2734
+ branchName: "community-qr1",
2735
+ url: "https://builder.io/app/projects/project-1/community-qr1",
2736
+ status: "processing",
2737
+ });
2738
+
2739
+ await runWithRequestContext(
2740
+ { userEmail: "dev@example.test", orgId: "org-123" },
2741
+ () =>
2742
+ startWorkspaceAppCreation({
2743
+ prompt: "Install the curated QR template",
2744
+ appId: "community-qr",
2745
+ communitySourceId: "community-qr",
2746
+ }),
2747
+ );
2748
+
2749
+ expect(
2750
+ mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
2751
+ ).toMatchObject({
2752
+ pendingApps: [
2753
+ expect.objectContaining({
2754
+ id: "community-qr",
2755
+ communitySourceId: "community-qr",
2756
+ }),
2757
+ ],
2758
+ });
2759
+ expect(
2760
+ mocks.settings.get("workspace-app-metadata:org:org-123"),
2761
+ ).toMatchObject({
2762
+ apps: {
2763
+ "community-qr": { communitySourceId: "community-qr" },
2764
+ },
2765
+ });
2766
+ });
2767
+
1964
2768
  it("releases the reservation when Builder handoff fails", async () => {
1965
2769
  stubHostedRuntime();
1966
2770
  stubBuilderProjectConfigured();