@agent-native/dispatch 0.40.30 → 0.40.33

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 (100) hide show
  1. package/CHANGELOG.md +28 -25
  2. package/dist/actions/connect-external-agent.d.ts.map +1 -1
  3. package/dist/actions/connect-external-agent.js +2 -9
  4. package/dist/actions/connect-external-agent.js.map +1 -1
  5. package/dist/actions/list-connected-agents.js +7 -3
  6. package/dist/actions/list-connected-agents.js.map +1 -1
  7. package/dist/actions/list-dispatch-usage-metrics.js +1 -1
  8. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  9. package/dist/actions/list-workspace-connections.d.ts.map +1 -1
  10. package/dist/actions/list-workspace-connections.js +13 -18
  11. package/dist/actions/list-workspace-connections.js.map +1 -1
  12. package/dist/actions/provider-api-register.d.ts +8 -8
  13. package/dist/actions/provider-api-register.js +2 -14
  14. package/dist/actions/provider-api-register.js.map +1 -1
  15. package/dist/components/action-query-error.d.ts +2 -1
  16. package/dist/components/action-query-error.d.ts.map +1 -1
  17. package/dist/components/action-query-error.js +25 -2
  18. package/dist/components/action-query-error.js.map +1 -1
  19. package/dist/components/chat-home-apps.d.ts.map +1 -1
  20. package/dist/components/chat-home-apps.js +2 -2
  21. package/dist/components/chat-home-apps.js.map +1 -1
  22. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  23. package/dist/components/dispatch-control-plane.js +1 -1
  24. package/dist/components/dispatch-control-plane.js.map +1 -1
  25. package/dist/components/layout/Layout.d.ts +1 -0
  26. package/dist/components/layout/Layout.d.ts.map +1 -1
  27. package/dist/components/layout/Layout.js +7 -0
  28. package/dist/components/layout/Layout.js.map +1 -1
  29. package/dist/db/index.d.ts.map +1 -1
  30. package/dist/db/index.js +4 -9
  31. package/dist/db/index.js.map +1 -1
  32. package/dist/routes/pages/$appId.d.ts.map +1 -1
  33. package/dist/routes/pages/$appId.js +1 -1
  34. package/dist/routes/pages/$appId.js.map +1 -1
  35. package/dist/routes/pages/apps.js +1 -1
  36. package/dist/routes/pages/apps.js.map +1 -1
  37. package/dist/server/lib/admin-config.d.ts.map +1 -1
  38. package/dist/server/lib/admin-config.js +3 -1
  39. package/dist/server/lib/admin-config.js.map +1 -1
  40. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  41. package/dist/server/lib/app-creation-store.js +124 -65
  42. package/dist/server/lib/app-creation-store.js.map +1 -1
  43. package/dist/server/lib/community-apps.js +1 -1
  44. package/dist/server/lib/community-apps.js.map +1 -1
  45. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  46. package/dist/server/lib/mcp-access-store.js +2 -10
  47. package/dist/server/lib/mcp-access-store.js.map +1 -1
  48. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  49. package/dist/server/lib/thread-debug-store.js +2 -6
  50. package/dist/server/lib/thread-debug-store.js.map +1 -1
  51. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  52. package/dist/server/lib/usage-metrics-store.js +2 -11
  53. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  54. package/dist/server/lib/vault-store.d.ts.map +1 -1
  55. package/dist/server/lib/vault-store.js +2 -9
  56. package/dist/server/lib/vault-store.js.map +1 -1
  57. package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -1
  58. package/dist/server/lib/workspace-app-action-auth.js +7 -2
  59. package/dist/server/lib/workspace-app-action-auth.js.map +1 -1
  60. package/dist/server/plugins/agent-chat.js +1 -1
  61. package/dist/server/plugins/agent-chat.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/actions/connect-external-agent.spec.ts +25 -11
  64. package/src/actions/connect-external-agent.ts +2 -9
  65. package/src/actions/index.spec.ts +3 -0
  66. package/src/actions/list-connected-agents.spec.ts +17 -1
  67. package/src/actions/list-connected-agents.ts +7 -2
  68. package/src/actions/list-dispatch-usage-metrics.ts +1 -1
  69. package/src/actions/list-workspace-connections.spec.ts +14 -3
  70. package/src/actions/list-workspace-connections.ts +13 -17
  71. package/src/actions/provider-api-register.spec.ts +15 -9
  72. package/src/actions/provider-api-register.ts +5 -13
  73. package/src/components/action-query-error.spec.tsx +20 -0
  74. package/src/components/action-query-error.tsx +40 -0
  75. package/src/components/chat-home-apps.spec.tsx +32 -1
  76. package/src/components/chat-home-apps.tsx +6 -1
  77. package/src/components/dispatch-control-plane.tsx +1 -0
  78. package/src/components/layout/Layout.tsx +9 -0
  79. package/src/db/index.spec.ts +74 -0
  80. package/src/db/index.ts +4 -9
  81. package/src/routes/pages/$appId.tsx +1 -0
  82. package/src/routes/pages/apps.spec.tsx +26 -2
  83. package/src/routes/pages/apps.tsx +2 -0
  84. package/src/server/lib/admin-config.spec.ts +51 -0
  85. package/src/server/lib/admin-config.ts +2 -1
  86. package/src/server/lib/app-creation-store.spec.ts +308 -10
  87. package/src/server/lib/app-creation-store.ts +174 -70
  88. package/src/server/lib/community-apps.ts +1 -1
  89. package/src/server/lib/mcp-access-store.spec.ts +39 -12
  90. package/src/server/lib/mcp-access-store.ts +3 -10
  91. package/src/server/lib/thread-debug-store.spec.ts +64 -0
  92. package/src/server/lib/thread-debug-store.ts +2 -9
  93. package/src/server/lib/usage-metrics-store.spec.ts +64 -0
  94. package/src/server/lib/usage-metrics-store.ts +2 -17
  95. package/src/server/lib/vault-store.spec.ts +26 -9
  96. package/src/server/lib/vault-store.ts +3 -10
  97. package/src/server/lib/workspace-app-action-auth.spec.ts +37 -6
  98. package/src/server/lib/workspace-app-action-auth.ts +12 -2
  99. package/src/server/plugins/agent-chat.spec.ts +14 -0
  100. package/src/server/plugins/agent-chat.ts +1 -1
@@ -80,8 +80,16 @@ vi.mock("@agent-native/core/client/i18n", () => ({
80
80
  }));
81
81
 
82
82
  vi.mock("../../components/action-query-error", () => ({
83
- ActionQueryError: ({ error }: { error: Error }) => (
84
- <div role="alert">{error.message}</div>
83
+ ActionQueryError: ({
84
+ error,
85
+ queryName,
86
+ }: {
87
+ error: Error;
88
+ queryName?: string;
89
+ }) => (
90
+ <div role="alert">
91
+ {queryName}: {error.message}
92
+ </div>
85
93
  ),
86
94
  }));
87
95
 
@@ -167,6 +175,22 @@ describe("Dispatch apps route", () => {
167
175
  expect(container.textContent).toContain("No workspace apps");
168
176
  });
169
177
 
178
+ it("identifies each app discovery query when both fail", async () => {
179
+ queryState.workspaceAppsError = new Error("Workspace app list failed");
180
+ queryState.connectedAppsError = new Error("Connected agent list failed");
181
+
182
+ await renderAppsRoute();
183
+
184
+ const errors = Array.from(
185
+ container.querySelectorAll('[role="alert"]'),
186
+ (alert) => alert.textContent,
187
+ );
188
+ expect(errors).toContain("list-workspace-apps: Workspace app list failed");
189
+ expect(errors).toContain(
190
+ "list-connected-agents: Connected agent list failed",
191
+ );
192
+ });
193
+
170
194
  it("hides community apps while the Lab is off", async () => {
171
195
  await renderAppsRoute();
172
196
 
@@ -185,12 +185,14 @@ function AppsRoute() {
185
185
  <ActionQueryError
186
186
  error={appsQuery.error}
187
187
  onRetry={() => void appsQuery.refetch()}
188
+ queryName="list-workspace-apps"
188
189
  />
189
190
  ) : null}
190
191
  {connectedAppsQuery.isError ? (
191
192
  <ActionQueryError
192
193
  error={connectedAppsQuery.error}
193
194
  onRetry={() => void connectedAppsQuery.refetch()}
195
+ queryName="list-connected-agents"
194
196
  />
195
197
  ) : null}
196
198
  {showAppSkeletons ? (
@@ -0,0 +1,51 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const getAppConfigMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("@agent-native/core/server", async (importOriginal) => ({
6
+ ...(await importOriginal<typeof import("@agent-native/core/server")>()),
7
+ getAppConfig: () => getAppConfigMock(),
8
+ }));
9
+
10
+ import { runWithRequestContext } from "@agent-native/core/server";
11
+
12
+ import { isDispatchEnvironmentAdmin } from "./admin-config.js";
13
+
14
+ describe("isDispatchEnvironmentAdmin", () => {
15
+ afterEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it("recognizes configured environment administrators outside delegated requests", () => {
20
+ getAppConfigMock.mockReturnValue({
21
+ dispatch: {
22
+ adminEmails: ["platform@example.test"],
23
+ workspaceOwnerEmails: [],
24
+ defaultOwnerEmail: null,
25
+ },
26
+ });
27
+
28
+ expect(isDispatchEnvironmentAdmin("PLATFORM@example.test")).toBe(true);
29
+ });
30
+
31
+ it("does not turn an org-member assertion for an environment admin into global access", async () => {
32
+ getAppConfigMock.mockReturnValue({
33
+ dispatch: {
34
+ adminEmails: ["platform@example.test"],
35
+ workspaceOwnerEmails: [],
36
+ defaultOwnerEmail: null,
37
+ },
38
+ });
39
+
40
+ await runWithRequestContext(
41
+ {
42
+ userEmail: "platform@example.test",
43
+ orgId: "org-1",
44
+ a2aIdentityAssurance: "org-member",
45
+ },
46
+ () => {
47
+ expect(isDispatchEnvironmentAdmin("platform@example.test")).toBe(false);
48
+ },
49
+ );
50
+ });
51
+ });
@@ -1,8 +1,9 @@
1
- import { getAppConfig } from "@agent-native/core/server";
1
+ import { getAppConfig, getRequestContext } from "@agent-native/core/server";
2
2
 
3
3
  export function isDispatchEnvironmentAdmin(email: string): boolean {
4
4
  const normalized = email.trim().toLowerCase();
5
5
  if (!normalized) return false;
6
+ if (getRequestContext()?.a2aIdentityAssurance === "org-member") return false;
6
7
  const config = getAppConfig().dispatch;
7
8
  return [
8
9
  ...config.adminEmails,
@@ -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";
@@ -29,6 +30,20 @@ import {
29
30
  const originalFetch = globalThis.fetch;
30
31
  const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
31
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
+
32
47
  const mocks = vi.hoisted(() => {
33
48
  const settings = new Map<string, unknown>();
34
49
  const state = {
@@ -55,6 +70,8 @@ const mocks = vi.hoisted(() => {
55
70
  getOrgDomain: vi.fn(async (orgId: string) =>
56
71
  orgId ? "example.test" : null,
57
72
  ),
73
+ getOrgA2ASecret: vi.fn(async () => null as string | null),
74
+ signA2AToken: vi.fn(),
58
75
  isWorkspaceAppAccessAllowed: vi.fn(
59
76
  async (): Promise<boolean | "unavailable"> => true,
60
77
  ),
@@ -62,6 +79,7 @@ const mocks = vi.hoisted(() => {
62
79
  role: "viewer",
63
80
  resource: {},
64
81
  })),
82
+ readOrgMemberRole: vi.fn(async () => state.orgRole),
65
83
  getDbExec: vi.fn(() => ({
66
84
  execute: vi.fn(async (statement: unknown) => {
67
85
  const sql =
@@ -131,6 +149,18 @@ const mocks = vi.hoisted(() => {
131
149
  };
132
150
  });
133
151
 
152
+ vi.mock("@agent-native/core/a2a", async (importOriginal) => {
153
+ const actual =
154
+ await importOriginal<typeof import("@agent-native/core/a2a")>();
155
+ mocks.signA2AToken.mockImplementation((...args: any[]) =>
156
+ actual.signA2AToken(...args),
157
+ );
158
+ return {
159
+ ...actual,
160
+ signA2AToken: (...args: any[]) => mocks.signA2AToken(...args),
161
+ };
162
+ });
163
+
134
164
  vi.mock("../../db/index.js", async (importOriginal) => {
135
165
  const actual = await importOriginal<typeof import("../../db/index.js")>();
136
166
  return { ...actual, getDb: mocks.getDb };
@@ -166,6 +196,7 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
166
196
  await importOriginal<typeof import("@agent-native/core/org")>();
167
197
  return {
168
198
  ...actual,
199
+ getOrgA2ASecret: (...args: any[]) => mocks.getOrgA2ASecret(...args),
169
200
  getOrgDomain: (...args: any[]) => mocks.getOrgDomain(...args),
170
201
  isWorkspaceAppAccessAllowed: (...args: any[]) =>
171
202
  mocks.isWorkspaceAppAccessAllowed(...args),
@@ -186,6 +217,7 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
186
217
  await importOriginal<typeof import("@agent-native/core/server")>();
187
218
  return {
188
219
  ...actual,
220
+ readOrgMemberRole: (...args: any[]) => mocks.readOrgMemberRole(...args),
189
221
  resolveBuilderCredentialsDetailed: (...args: any[]) =>
190
222
  mocks.resolveBuilderCredentialsDetailed(...args),
191
223
  createBuilderProject: (...args: any[]) =>
@@ -218,6 +250,8 @@ afterEach(() => {
218
250
  mocks.settings.clear();
219
251
  mocks.getOrgSetting.mockReset();
220
252
  mocks.getOrgSetting.mockResolvedValue(null);
253
+ mocks.getOrgA2ASecret.mockReset();
254
+ mocks.getOrgA2ASecret.mockResolvedValue(null);
221
255
  mocks.isWorkspaceAppAccessAllowed.mockReset();
222
256
  mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
223
257
  mocks.mutateSetting.mockReset();
@@ -229,6 +263,8 @@ afterEach(() => {
229
263
  },
230
264
  );
231
265
  mocks.state.orgRole = "admin";
266
+ mocks.readOrgMemberRole.mockReset();
267
+ mocks.readOrgMemberRole.mockImplementation(async () => mocks.state.orgRole);
232
268
  mocks.state.workspaceApps = [];
233
269
  orgAppsDirectoryCache.clear();
234
270
  orgAppsDirectoryAccessCache.clear();
@@ -600,6 +636,130 @@ describe("listWorkspaceApps", () => {
600
636
  expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
601
637
  });
602
638
 
639
+ it("signs the hosted registry request with the caller's organization secret", async () => {
640
+ const fetchMock = vi.fn(
641
+ async () =>
642
+ new Response(JSON.stringify([]), {
643
+ headers: { "content-type": "application/json" },
644
+ }),
645
+ );
646
+ vi.stubGlobal("fetch", fetchMock);
647
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
648
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
649
+ mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
650
+
651
+ await runWithRequestContext(
652
+ { userEmail: "dev@example.test", orgId: "org-1" },
653
+ () => listWorkspaceApps({ includeAgentCards: false }),
654
+ );
655
+
656
+ const authorization = fetchMock.mock.calls[0]?.[1]?.headers
657
+ ?.Authorization as string;
658
+ const token = authorization.slice("Bearer ".length);
659
+ const payload = verifyJwtWithSecret(token, "workspace-org-secret");
660
+ expect(payload).toMatchObject({
661
+ sub: "dev@example.test",
662
+ org_domain: "example.test",
663
+ aud: "https://agent-workspace.builder.io",
664
+ });
665
+ expect(() =>
666
+ verifyJwtWithSecret(token, "dispatch-deployment-secret"),
667
+ ).toThrow();
668
+ });
669
+
670
+ it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
671
+ const fetchMock = vi.fn();
672
+ vi.stubGlobal("fetch", fetchMock);
673
+ vi.stubEnv("A2A_SECRET", "");
674
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
675
+ mocks.getOrgA2ASecret.mockResolvedValueOnce(null);
676
+ stubManifest([
677
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
678
+ { id: "clips", name: "Clips", path: "/clips" },
679
+ ]);
680
+
681
+ await expect(
682
+ runWithRequestContext(
683
+ { userEmail: "dev@example.test", orgId: "org-1" },
684
+ () => listWorkspaceApps({ includeAgentCards: false }),
685
+ ),
686
+ ).rejects.toMatchObject({
687
+ statusCode: 503,
688
+ errorCode: "workspace_gateway_a2a_secret_missing",
689
+ });
690
+ expect(fetchMock).not.toHaveBeenCalled();
691
+ });
692
+
693
+ it("reports organization A2A secret lookup failures before using manifest apps", async () => {
694
+ const fetchMock = vi.fn();
695
+ vi.stubGlobal("fetch", fetchMock);
696
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
697
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
698
+ stubManifest([
699
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
700
+ { id: "clips", name: "Clips", path: "/clips" },
701
+ ]);
702
+ mocks.getOrgA2ASecret.mockRejectedValueOnce(
703
+ new Error("database unavailable"),
704
+ );
705
+
706
+ await expect(
707
+ runWithRequestContext(
708
+ { userEmail: "dev@example.test", orgId: "org-1" },
709
+ () => listWorkspaceApps({ includeAgentCards: false }),
710
+ ),
711
+ ).rejects.toMatchObject({
712
+ statusCode: 503,
713
+ errorCode: "workspace_gateway_org_a2a_secret_unavailable",
714
+ });
715
+ expect(fetchMock).not.toHaveBeenCalled();
716
+ });
717
+
718
+ it("reports token signing failures before using manifest apps", async () => {
719
+ const fetchMock = vi.fn();
720
+ vi.stubGlobal("fetch", fetchMock);
721
+ vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
722
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
723
+ stubManifest([
724
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
725
+ { id: "clips", name: "Clips", path: "/clips" },
726
+ ]);
727
+ mocks.signA2AToken.mockRejectedValueOnce(new Error("signing unavailable"));
728
+
729
+ await expect(
730
+ runWithRequestContext(
731
+ { userEmail: "dev@example.test", orgId: "org-1" },
732
+ () => listWorkspaceApps({ includeAgentCards: false }),
733
+ ),
734
+ ).rejects.toMatchObject({
735
+ statusCode: 503,
736
+ errorCode: "workspace_gateway_token_signing_failed",
737
+ });
738
+ expect(fetchMock).not.toHaveBeenCalled();
739
+ });
740
+
741
+ it("does not hide an organization lookup failure behind manifest apps", async () => {
742
+ const fetchMock = vi.fn();
743
+ vi.stubGlobal("fetch", fetchMock);
744
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
745
+ stubManifest([
746
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
747
+ { id: "clips", name: "Clips", path: "/clips" },
748
+ ]);
749
+ mocks.getOrgDomain.mockRejectedValueOnce(new Error("database unavailable"));
750
+
751
+ await expect(
752
+ runWithRequestContext(
753
+ { userEmail: "dev@example.test", orgId: "org-1" },
754
+ () => listWorkspaceApps({ includeAgentCards: false }),
755
+ ),
756
+ ).rejects.toMatchObject({
757
+ statusCode: 503,
758
+ errorCode: "workspace_gateway_org_domain_unavailable",
759
+ });
760
+ expect(fetchMock).not.toHaveBeenCalled();
761
+ });
762
+
603
763
  it("binds hosted registry tokens to the mounted Dispatch path", async () => {
604
764
  const fetchMock = vi.fn(async () => {
605
765
  return new Response(
@@ -793,6 +953,122 @@ describe("listWorkspaceApps", () => {
793
953
  expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
794
954
  });
795
955
 
956
+ it("treats an empty hosted registry response as a successful empty list", async () => {
957
+ const fetchMock = vi.fn(
958
+ async () =>
959
+ new Response(JSON.stringify([]), {
960
+ headers: { "content-type": "application/json" },
961
+ }),
962
+ );
963
+ vi.stubGlobal("fetch", fetchMock);
964
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
965
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
966
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
967
+
968
+ const apps = await runWithRequestContext(
969
+ { userEmail: "dev@example.test" },
970
+ () => listWorkspaceApps({ includeAgentCards: false }),
971
+ );
972
+
973
+ expect(apps).toEqual([]);
974
+ expect(fetchMock).toHaveBeenCalledTimes(1);
975
+ });
976
+
977
+ it.each([206, 500, 502, 503])(
978
+ "surfaces hosted registry HTTP %i failures instead of using manifest apps",
979
+ async (status) => {
980
+ const fetchMock = vi.fn(
981
+ async () => new Response("gateway unavailable", { status }),
982
+ );
983
+ vi.stubGlobal("fetch", fetchMock);
984
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
985
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
986
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
987
+
988
+ await expect(
989
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
990
+ listWorkspaceApps({ includeAgentCards: false }),
991
+ ),
992
+ ).rejects.toMatchObject({
993
+ statusCode: 503,
994
+ errorCode: `workspace_gateway_http_${status}`,
995
+ });
996
+ expect(fetchMock).toHaveBeenCalledTimes(1);
997
+ },
998
+ );
999
+
1000
+ it("surfaces hosted registry transport failures instead of using manifest apps", async () => {
1001
+ const fetchMock = vi.fn(async () => {
1002
+ throw new TypeError("Network connection failed");
1003
+ });
1004
+ vi.stubGlobal("fetch", fetchMock);
1005
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1006
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1007
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1008
+
1009
+ await expect(
1010
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1011
+ listWorkspaceApps({ includeAgentCards: false }),
1012
+ ),
1013
+ ).rejects.toMatchObject({
1014
+ statusCode: 503,
1015
+ errorCode: "workspace_gateway_unavailable",
1016
+ });
1017
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1018
+ });
1019
+
1020
+ it("surfaces malformed hosted registry responses instead of using manifest apps", async () => {
1021
+ const fetchMock = vi.fn(
1022
+ async () =>
1023
+ new Response("not json", {
1024
+ headers: { "content-type": "application/json" },
1025
+ }),
1026
+ );
1027
+ vi.stubGlobal("fetch", fetchMock);
1028
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1029
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1030
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1031
+
1032
+ await expect(
1033
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1034
+ listWorkspaceApps({ includeAgentCards: false }),
1035
+ ),
1036
+ ).rejects.toMatchObject({
1037
+ statusCode: 503,
1038
+ errorCode: "workspace_gateway_invalid_response",
1039
+ });
1040
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1041
+ });
1042
+
1043
+ it.each([
1044
+ [{ id: "dispatch", name: "Dispatch", path: "/dispatch" }, null],
1045
+ [
1046
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
1047
+ { id: "DISPATCH", name: "Duplicate Dispatch", path: "/other" },
1048
+ ],
1049
+ ])("rejects incomplete hosted registry lists: %j", async (apps) => {
1050
+ const fetchMock = vi.fn(
1051
+ async () =>
1052
+ new Response(JSON.stringify(apps), {
1053
+ headers: { "content-type": "application/json" },
1054
+ }),
1055
+ );
1056
+ vi.stubGlobal("fetch", fetchMock);
1057
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1058
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
1059
+ stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
1060
+
1061
+ await expect(
1062
+ runWithRequestContext({ userEmail: "dev@example.test" }, () =>
1063
+ listWorkspaceApps({ includeAgentCards: false }),
1064
+ ),
1065
+ ).rejects.toMatchObject({
1066
+ statusCode: 503,
1067
+ errorCode: "workspace_gateway_invalid_response",
1068
+ });
1069
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1070
+ });
1071
+
796
1072
  it("passes the Vercel protection bypass to the hosted workspace registry", async () => {
797
1073
  const fetchMock = vi.fn(async () => {
798
1074
  return new Response(
@@ -859,10 +1135,10 @@ describe("listWorkspaceApps", () => {
859
1135
  );
860
1136
  });
861
1137
 
862
- it("does not recursively call the hosted registry action", async () => {
1138
+ it("skips a same-origin hosted registry without credentials", async () => {
863
1139
  const fetchMock = vi.fn();
864
1140
  vi.stubGlobal("fetch", fetchMock);
865
- vi.stubEnv("A2A_SECRET", "test-a2a-secret");
1141
+ vi.stubEnv("A2A_SECRET", "");
866
1142
  vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
867
1143
 
868
1144
  const apps = await runWithRequestContext(
@@ -891,9 +1167,13 @@ describe("listWorkspaceApps", () => {
891
1167
  { userEmail: "dev@example.test", orgId: "org-without-domain" },
892
1168
  () => listWorkspaceApps({ includeAgentCards: false }),
893
1169
  ),
894
- ).rejects.toThrow(
895
- "Workspace apps gateway cannot authenticate without a resolved organization domain.",
896
- );
1170
+ ).rejects.toMatchObject({
1171
+ name: "WorkspaceAppsGatewayAuthorizationError",
1172
+ statusCode: 401,
1173
+ errorCode: "workspace_gateway_org_domain_missing",
1174
+ message:
1175
+ "Workspace apps gateway cannot authenticate without a resolved organization domain.",
1176
+ });
897
1177
  expect(fetchMock).not.toHaveBeenCalled();
898
1178
  });
899
1179
 
@@ -2048,11 +2328,9 @@ describe("listWorkspaceApps", () => {
2048
2328
  it("reports owner-role lookup failures as unavailable instead of unauthorized", async () => {
2049
2329
  stubNoPendingContext();
2050
2330
  stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
2051
- mocks.getDbExec.mockReturnValue({
2052
- execute: vi.fn(async () => {
2053
- throw new Error("database unavailable");
2054
- }),
2055
- } as any);
2331
+ mocks.readOrgMemberRole.mockRejectedValue(
2332
+ new Error("database unavailable"),
2333
+ );
2056
2334
 
2057
2335
  await expect(
2058
2336
  runWithRequestContext(
@@ -2927,4 +3205,24 @@ describe("setAppCreationSettings", () => {
2927
3205
  expect(mocks.writeAppSecret).not.toHaveBeenCalled();
2928
3206
  expect(mocks.deleteAppSecret).not.toHaveBeenCalled();
2929
3207
  });
3208
+
3209
+ it("caps a delegated owner before changing the shared Builder project", async () => {
3210
+ mocks.readOrgMemberRole.mockResolvedValueOnce("member");
3211
+
3212
+ await expect(
3213
+ runWithRequestContext(
3214
+ {
3215
+ userEmail: "dev@example.test",
3216
+ orgId,
3217
+ a2aIdentityAssurance: "org-member",
3218
+ },
3219
+ () => setAppCreationSettings({ builderProjectId: projectId }),
3220
+ ),
3221
+ ).rejects.toMatchObject({ statusCode: 403 });
3222
+ expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
3223
+ orgId,
3224
+ "dev@example.test",
3225
+ );
3226
+ expect(mocks.putSetting).not.toHaveBeenCalled();
3227
+ });
2930
3228
  });