@agent-native/dispatch 0.14.3 → 0.14.5

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 (140) hide show
  1. package/README.md +114 -128
  2. package/dist/actions/list_apps.d.ts +1 -0
  3. package/dist/actions/list_apps.js +16 -8
  4. package/dist/actions/list_apps.js.map +1 -1
  5. package/dist/actions/set-mcp-app-access.js +1 -4
  6. package/dist/actions/set-mcp-app-access.js.map +1 -1
  7. package/dist/components/action-query-error.d.ts +8 -0
  8. package/dist/components/action-query-error.d.ts.map +1 -0
  9. package/dist/components/action-query-error.js +10 -0
  10. package/dist/components/action-query-error.js.map +1 -0
  11. package/dist/components/app-keys-popover.d.ts +4 -0
  12. package/dist/components/app-keys-popover.d.ts.map +1 -1
  13. package/dist/components/app-keys-popover.js +26 -14
  14. package/dist/components/app-keys-popover.js.map +1 -1
  15. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  16. package/dist/components/dispatch-control-plane.js +31 -27
  17. package/dist/components/dispatch-control-plane.js.map +1 -1
  18. package/dist/components/index.d.ts +1 -0
  19. package/dist/components/index.d.ts.map +1 -1
  20. package/dist/components/index.js +1 -0
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Layout.d.ts +1 -0
  23. package/dist/components/layout/Layout.d.ts.map +1 -1
  24. package/dist/components/layout/Layout.js +19 -21
  25. package/dist/components/layout/Layout.js.map +1 -1
  26. package/dist/components/task-queue-health.d.ts.map +1 -1
  27. package/dist/components/task-queue-health.js +9 -1
  28. package/dist/components/task-queue-health.js.map +1 -1
  29. package/dist/components/ui/sheet.js +1 -1
  30. package/dist/components/ui/sheet.js.map +1 -1
  31. package/dist/components/ui/toast.js +1 -1
  32. package/dist/components/ui/toast.js.map +1 -1
  33. package/dist/components/workspace-app-card.d.ts.map +1 -1
  34. package/dist/components/workspace-app-card.js +8 -5
  35. package/dist/components/workspace-app-card.js.map +1 -1
  36. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  37. package/dist/components/workspace-resource-effective-stack.js +6 -1
  38. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  39. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  40. package/dist/components/workspace-resource-impact-preview.js +6 -1
  41. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  42. package/dist/routes/pages/$appId.d.ts.map +1 -1
  43. package/dist/routes/pages/$appId.js +6 -1
  44. package/dist/routes/pages/$appId.js.map +1 -1
  45. package/dist/routes/pages/agents.d.ts.map +1 -1
  46. package/dist/routes/pages/agents.js +5 -8
  47. package/dist/routes/pages/agents.js.map +1 -1
  48. package/dist/routes/pages/approval.d.ts.map +1 -1
  49. package/dist/routes/pages/approval.js +6 -1
  50. package/dist/routes/pages/approval.js.map +1 -1
  51. package/dist/routes/pages/approvals.d.ts.map +1 -1
  52. package/dist/routes/pages/approvals.js +8 -5
  53. package/dist/routes/pages/approvals.js.map +1 -1
  54. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  55. package/dist/routes/pages/apps.$appId.js +4 -2
  56. package/dist/routes/pages/apps.$appId.js.map +1 -1
  57. package/dist/routes/pages/apps.d.ts.map +1 -1
  58. package/dist/routes/pages/apps.js +10 -42
  59. package/dist/routes/pages/apps.js.map +1 -1
  60. package/dist/routes/pages/audit.d.ts.map +1 -1
  61. package/dist/routes/pages/audit.js +4 -2
  62. package/dist/routes/pages/audit.js.map +1 -1
  63. package/dist/routes/pages/automations.d.ts.map +1 -1
  64. package/dist/routes/pages/automations.js +0 -1
  65. package/dist/routes/pages/automations.js.map +1 -1
  66. package/dist/routes/pages/chat.d.ts.map +1 -1
  67. package/dist/routes/pages/chat.js +16 -2
  68. package/dist/routes/pages/chat.js.map +1 -1
  69. package/dist/routes/pages/destinations.d.ts.map +1 -1
  70. package/dist/routes/pages/destinations.js +5 -3
  71. package/dist/routes/pages/destinations.js.map +1 -1
  72. package/dist/routes/pages/identities.d.ts.map +1 -1
  73. package/dist/routes/pages/identities.js +5 -3
  74. package/dist/routes/pages/identities.js.map +1 -1
  75. package/dist/routes/pages/integrations.d.ts.map +1 -1
  76. package/dist/routes/pages/integrations.js +9 -3
  77. package/dist/routes/pages/integrations.js.map +1 -1
  78. package/dist/routes/pages/metrics.d.ts.map +1 -1
  79. package/dist/routes/pages/metrics.js +1 -1
  80. package/dist/routes/pages/metrics.js.map +1 -1
  81. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  82. package/dist/routes/pages/thread-debug.js +7 -6
  83. package/dist/routes/pages/thread-debug.js.map +1 -1
  84. package/dist/routes/pages/vault.d.ts.map +1 -1
  85. package/dist/routes/pages/vault.js +23 -7
  86. package/dist/routes/pages/vault.js.map +1 -1
  87. package/dist/routes/pages/workspace.d.ts.map +1 -1
  88. package/dist/routes/pages/workspace.js +9 -3
  89. package/dist/routes/pages/workspace.js.map +1 -1
  90. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  91. package/dist/server/lib/dispatch-store.js +2 -2
  92. package/dist/server/lib/dispatch-store.js.map +1 -1
  93. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  94. package/dist/server/lib/mcp-access-store.js +30 -2
  95. package/dist/server/lib/mcp-access-store.js.map +1 -1
  96. package/dist/server/plugins/agent-chat.d.ts +1 -1
  97. package/package.json +2 -2
  98. package/src/actions/list_apps.spec.ts +52 -0
  99. package/src/actions/list_apps.ts +16 -8
  100. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  101. package/src/actions/set-mcp-app-access.ts +1 -5
  102. package/src/components/action-query-error.spec.tsx +56 -0
  103. package/src/components/action-query-error.tsx +32 -0
  104. package/src/components/app-keys-popover.spec.tsx +85 -0
  105. package/src/components/app-keys-popover.tsx +76 -50
  106. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  107. package/src/components/dispatch-control-plane.tsx +71 -115
  108. package/src/components/index.ts +1 -0
  109. package/src/components/layout/Layout.spec.tsx +42 -2
  110. package/src/components/layout/Layout.tsx +22 -27
  111. package/src/components/task-queue-health.tsx +9 -1
  112. package/src/components/ui/sheet.tsx +1 -1
  113. package/src/components/ui/toast.tsx +1 -1
  114. package/src/components/workspace-app-card.spec.tsx +80 -0
  115. package/src/components/workspace-app-card.tsx +36 -25
  116. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  117. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  118. package/src/routes/pages/$appId.tsx +17 -1
  119. package/src/routes/pages/agents.tsx +24 -11
  120. package/src/routes/pages/approval.tsx +16 -4
  121. package/src/routes/pages/approvals.tsx +108 -89
  122. package/src/routes/pages/apps.$appId.tsx +9 -2
  123. package/src/routes/pages/apps.tsx +13 -177
  124. package/src/routes/pages/audit.tsx +29 -20
  125. package/src/routes/pages/automations.tsx +0 -1
  126. package/src/routes/pages/chat.spec.tsx +94 -0
  127. package/src/routes/pages/chat.tsx +30 -10
  128. package/src/routes/pages/destinations.tsx +75 -62
  129. package/src/routes/pages/identities.tsx +74 -63
  130. package/src/routes/pages/integrations.tsx +17 -10
  131. package/src/routes/pages/metrics.tsx +0 -1
  132. package/src/routes/pages/thread-debug.tsx +18 -12
  133. package/src/routes/pages/vault.tsx +59 -26
  134. package/src/routes/pages/workspace.tsx +69 -55
  135. package/src/server/lib/approval-fencing.spec.ts +113 -2
  136. package/src/server/lib/dispatch-store.ts +2 -0
  137. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  138. package/src/server/lib/mcp-access-store.ts +37 -2
  139. package/src/server/lib/mcp-gateway.spec.ts +79 -3
  140. package/src/styles/dispatch.css +5 -5
@@ -5,11 +5,15 @@ import path from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  // Each test runs real migrations against a fresh SQLite file; under full
8
- // workspace concurrency that setup can exceed the 5s default timeout.
9
- vi.setConfig({ testTimeout: 30_000 });
8
+ // workspace concurrency (and a shared machine running other suites) that
9
+ // setup can far exceed the 5s default, so give it generous headroom. The
10
+ // tests themselves complete in a few seconds uncontended.
11
+ vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
10
12
 
11
13
  const ownerEmail = "owner+approval-fencing@example.test";
12
14
  const orgId = "org_approval_fencing";
15
+ const otherOwnerEmail = "owner+approval-fencing-other@example.test";
16
+ const otherOrgId = "org_approval_fencing_other";
13
17
 
14
18
  const originalEnv = {
15
19
  APP_NAME: process.env.APP_NAME,
@@ -210,6 +214,113 @@ describe("dispatch approval request status fencing", () => {
210
214
  expect(await rejectedAuditCount()).toBe(1);
211
215
  });
212
216
  });
217
+
218
+ it("does not let a caller from a different tenant approve another tenant's request", async () => {
219
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
220
+ await Promise.all([
221
+ import("@agent-native/core/server"),
222
+ import("@agent-native/core/db"),
223
+ import("./dispatch-store.js"),
224
+ ]);
225
+ const exec = getDbExec();
226
+
227
+ const requestId = await runWithRequestContext(
228
+ { userEmail: ownerEmail, orgId },
229
+ async () => {
230
+ const created = await dispatchStore.createApprovalRequest({
231
+ changeType: "approval-policy.update",
232
+ targetType: "dispatch-settings",
233
+ targetId: "dispatch-approval-policy",
234
+ summary: "Enable approval policy for tenant A",
235
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
236
+ });
237
+ return (created as any).id as string;
238
+ },
239
+ );
240
+
241
+ await runWithRequestContext(
242
+ { userEmail: otherOwnerEmail, orgId: otherOrgId },
243
+ async () => {
244
+ await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
245
+ "Approval request not found",
246
+ );
247
+ },
248
+ );
249
+
250
+ const rows = await exec.execute({
251
+ sql: "SELECT status, reviewed_by, reviewed_at FROM dispatch_approval_requests WHERE id = ?",
252
+ args: [requestId],
253
+ });
254
+ expect(rows.rows[0]).toMatchObject({
255
+ status: "pending",
256
+ reviewed_by: null,
257
+ reviewed_at: null,
258
+ });
259
+
260
+ const approvedAuditRows = await exec.execute({
261
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.approved' AND target_id = ?",
262
+ args: [requestId],
263
+ });
264
+ expect(Number((approvedAuditRows.rows[0] as any).count)).toBe(0);
265
+
266
+ // Confirm no side effect landed either: the policy change must not have
267
+ // been applied to tenant A's org settings by the foreign-tenant attempt.
268
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
269
+ expect(await dispatchStore.getApprovalPolicy()).toEqual({
270
+ enabled: false,
271
+ approverEmails: [],
272
+ });
273
+ });
274
+ });
275
+
276
+ it("does not let a caller from a different tenant reject another tenant's request", async () => {
277
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
278
+ await Promise.all([
279
+ import("@agent-native/core/server"),
280
+ import("@agent-native/core/db"),
281
+ import("./dispatch-store.js"),
282
+ ]);
283
+ const exec = getDbExec();
284
+
285
+ const requestId = await runWithRequestContext(
286
+ { userEmail: ownerEmail, orgId },
287
+ async () => {
288
+ const created = await dispatchStore.createApprovalRequest({
289
+ changeType: "approval-policy.update",
290
+ targetType: "dispatch-settings",
291
+ targetId: "dispatch-approval-policy",
292
+ summary: "Enable approval policy for tenant A (reject case)",
293
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
294
+ });
295
+ return (created as any).id as string;
296
+ },
297
+ );
298
+
299
+ await runWithRequestContext(
300
+ { userEmail: otherOwnerEmail, orgId: otherOrgId },
301
+ async () => {
302
+ await expect(
303
+ dispatchStore.rejectRequest(requestId, "not my call"),
304
+ ).rejects.toThrow("Approval request not found");
305
+ },
306
+ );
307
+
308
+ const rows = await exec.execute({
309
+ sql: "SELECT status, reviewed_by, reviewed_at FROM dispatch_approval_requests WHERE id = ?",
310
+ args: [requestId],
311
+ });
312
+ expect(rows.rows[0]).toMatchObject({
313
+ status: "pending",
314
+ reviewed_by: null,
315
+ reviewed_at: null,
316
+ });
317
+
318
+ const rejectedAuditRows = await exec.execute({
319
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.rejected' AND target_id = ?",
320
+ args: [requestId],
321
+ });
322
+ expect(Number((rejectedAuditRows.rows[0] as any).count)).toBe(0);
323
+ });
213
324
  });
214
325
 
215
326
  describe("vault request status fencing", () => {
@@ -681,6 +681,7 @@ export async function approveRequest(requestId: string) {
681
681
  .where(
682
682
  and(
683
683
  eq(schema.dispatchApprovalRequests.id, requestId),
684
+ ctxScope(schema.dispatchApprovalRequests, ctx),
684
685
  eq(schema.dispatchApprovalRequests.status, "applying"),
685
686
  ),
686
687
  )
@@ -714,6 +715,7 @@ export async function rejectRequest(requestId: string, reason?: string | null) {
714
715
  .where(
715
716
  and(
716
717
  eq(schema.dispatchApprovalRequests.id, requestId),
718
+ ctxScope(schema.dispatchApprovalRequests, ctx),
717
719
  eq(schema.dispatchApprovalRequests.status, "pending"),
718
720
  ),
719
721
  )
@@ -1,15 +1,42 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ execute: vi.fn(),
5
+ getOrgSetting: vi.fn(),
6
+ getUserSetting: vi.fn(),
7
+ putOrgSetting: vi.fn(),
8
+ putUserSetting: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@agent-native/core/db", () => ({
12
+ getDbExec: () => ({ execute: mocks.execute }),
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/settings", () => ({
16
+ getOrgSetting: mocks.getOrgSetting,
17
+ getUserSetting: mocks.getUserSetting,
18
+ putOrgSetting: mocks.putOrgSetting,
19
+ putUserSetting: mocks.putUserSetting,
20
+ }));
21
+
22
+ import { runWithRequestContext } from "@agent-native/core/server";
2
23
 
3
24
  import {
4
25
  isAppAllowedByMcpAccess,
5
26
  normalizeMcpAppAccessSettings,
27
+ setDispatchMcpAppAccessSettings,
6
28
  } from "./mcp-access-store.js";
7
29
 
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ mocks.execute.mockResolvedValue({ rows: [] });
33
+ });
34
+
8
35
  describe("normalizeMcpAppAccessSettings", () => {
9
- it("defaults to all apps", () => {
36
+ it("defaults to Dispatch only", () => {
10
37
  expect(normalizeMcpAppAccessSettings(null)).toEqual({
11
- mode: "all-apps",
12
- selectedAppIds: [],
38
+ mode: "selected-apps",
39
+ selectedAppIds: ["dispatch"],
13
40
  updatedAt: undefined,
14
41
  updatedBy: undefined,
15
42
  });
@@ -32,6 +59,91 @@ describe("normalizeMcpAppAccessSettings", () => {
32
59
  });
33
60
  });
34
61
 
62
+ describe("setDispatchMcpAppAccessSettings", () => {
63
+ it.each(["owner", "admin"])(
64
+ "lets an organization %s change the app grants",
65
+ async (role) => {
66
+ mocks.execute.mockResolvedValue({ rows: [{ role }] });
67
+
68
+ const result = await runWithRequestContext(
69
+ { userEmail: "manager@example.test", orgId: "org-1" },
70
+ () =>
71
+ setDispatchMcpAppAccessSettings({
72
+ mode: "selected-apps",
73
+ selectedAppIds: ["dispatch", "analytics"],
74
+ }),
75
+ );
76
+
77
+ expect(mocks.execute).toHaveBeenCalledWith({
78
+ sql: expect.stringContaining("FROM org_members"),
79
+ args: ["org-1", "manager@example.test"],
80
+ });
81
+ expect(mocks.putOrgSetting).toHaveBeenCalledWith(
82
+ "org-1",
83
+ "dispatch-mcp-app-access",
84
+ expect.objectContaining({
85
+ mode: "selected-apps",
86
+ selectedAppIds: ["dispatch", "analytics"],
87
+ updatedBy: "manager@example.test",
88
+ }),
89
+ );
90
+ expect(result.mode).toBe("selected-apps");
91
+ },
92
+ );
93
+
94
+ it("rejects an organization member without changing grants", async () => {
95
+ mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
96
+
97
+ await expect(
98
+ runWithRequestContext(
99
+ { userEmail: "member@example.test", orgId: "org-1" },
100
+ () =>
101
+ setDispatchMcpAppAccessSettings({
102
+ mode: "all-apps",
103
+ }),
104
+ ),
105
+ ).rejects.toMatchObject({
106
+ message: expect.stringMatching(/Only organization owners and admins/),
107
+ statusCode: 403,
108
+ });
109
+ expect(mocks.putOrgSetting).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("fails closed when organization membership cannot be verified", async () => {
113
+ mocks.execute.mockRejectedValue(new Error("org lookup unavailable"));
114
+
115
+ await expect(
116
+ runWithRequestContext(
117
+ { userEmail: "owner@example.test", orgId: "org-1" },
118
+ () =>
119
+ setDispatchMcpAppAccessSettings({
120
+ mode: "all-apps",
121
+ }),
122
+ ),
123
+ ).rejects.toThrow(/Only organization owners and admins/);
124
+ expect(mocks.putOrgSetting).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("lets an authenticated solo user manage only their own grants", async () => {
128
+ await runWithRequestContext({ userEmail: "solo@example.test" }, () =>
129
+ setDispatchMcpAppAccessSettings({
130
+ mode: "selected-apps",
131
+ selectedAppIds: [],
132
+ }),
133
+ );
134
+
135
+ expect(mocks.execute).not.toHaveBeenCalled();
136
+ expect(mocks.putUserSetting).toHaveBeenCalledWith(
137
+ "solo@example.test",
138
+ "dispatch-mcp-app-access",
139
+ expect.objectContaining({
140
+ mode: "selected-apps",
141
+ selectedAppIds: [],
142
+ }),
143
+ );
144
+ });
145
+ });
146
+
35
147
  describe("isAppAllowedByMcpAccess", () => {
36
148
  it("allows every app in all-apps mode", () => {
37
149
  expect(
@@ -1,3 +1,4 @@
1
+ import { getDbExec } from "@agent-native/core/db";
1
2
  import {
2
3
  getRequestOrgId,
3
4
  getRequestUserEmail,
@@ -20,6 +21,16 @@ export interface DispatchMcpAppAccessSettings {
20
21
  updatedBy?: string;
21
22
  }
22
23
 
24
+ class McpAppAccessError extends Error {
25
+ statusCode: number;
26
+
27
+ constructor(message: string, statusCode: number) {
28
+ super(message);
29
+ this.name = "McpAppAccessError";
30
+ this.statusCode = statusCode;
31
+ }
32
+ }
33
+
23
34
  interface AccessScope {
24
35
  kind: "org" | "user";
25
36
  id: string;
@@ -45,10 +56,11 @@ export function normalizeMcpAppAccessSettings(
45
56
  raw && typeof raw === "object" && !Array.isArray(raw)
46
57
  ? (raw as Record<string, unknown>)
47
58
  : {};
48
- const mode = record.mode === "selected-apps" ? "selected-apps" : "all-apps";
59
+ const mode = record.mode === "all-apps" ? "all-apps" : "selected-apps";
49
60
  return {
50
61
  mode,
51
- selectedAppIds: uniqueAppIds(record.selectedAppIds),
62
+ selectedAppIds:
63
+ raw == null ? ["dispatch"] : uniqueAppIds(record.selectedAppIds),
52
64
  updatedAt:
53
65
  typeof record.updatedAt === "string" ? record.updatedAt : undefined,
54
66
  updatedBy:
@@ -64,6 +76,28 @@ function currentAccessScope(): AccessScope {
64
76
  return { kind: "user", id: actor, actor };
65
77
  }
66
78
 
79
+ async function assertCanManageMcpAppAccess(scope: AccessScope): Promise<void> {
80
+ if (scope.kind === "user") return;
81
+
82
+ let role: unknown = null;
83
+ try {
84
+ const { rows } = await getDbExec().execute({
85
+ sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
86
+ args: [scope.id, scope.actor.toLowerCase()],
87
+ });
88
+ role = rows[0]?.role;
89
+ } catch {
90
+ // Fail closed when org membership cannot be verified.
91
+ }
92
+
93
+ if (role !== "owner" && role !== "admin") {
94
+ throw new McpAppAccessError(
95
+ "Only organization owners and admins can change Dispatch MCP app access.",
96
+ 403,
97
+ );
98
+ }
99
+ }
100
+
67
101
  export async function getDispatchMcpAppAccessSettings(): Promise<DispatchMcpAppAccessSettings> {
68
102
  const scope = currentAccessScope();
69
103
  const raw =
@@ -78,6 +112,7 @@ export async function setDispatchMcpAppAccessSettings(input: {
78
112
  selectedAppIds?: string[];
79
113
  }): Promise<DispatchMcpAppAccessSettings> {
80
114
  const scope = currentAccessScope();
115
+ await assertCanManageMcpAppAccess(scope);
81
116
  const next: DispatchMcpAppAccessSettings = {
82
117
  mode: input.mode,
83
118
  selectedAppIds: uniqueAppIds(input.selectedAppIds),
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
12
12
  managerStop: vi.fn(),
13
13
  managerCallTool: vi.fn(),
14
14
  managerConstructor: vi.fn(),
15
+ callAgent: vi.fn(),
15
16
  signA2AToken: vi.fn(),
16
17
  getOrgA2ASecret: vi.fn(),
17
18
  getOrgDomain: vi.fn(),
@@ -39,7 +40,7 @@ vi.mock("@agent-native/core/server", async (importOriginal) => {
39
40
  });
40
41
 
41
42
  vi.mock("@agent-native/core/a2a", () => ({
42
- callAgent: vi.fn(),
43
+ callAgent: mocks.callAgent,
43
44
  signA2AToken: mocks.signA2AToken,
44
45
  }));
45
46
 
@@ -74,6 +75,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
74
75
 
75
76
  import {
76
77
  createGrantedDispatchMcpEmbedSession,
78
+ askGrantedDispatchMcpApp,
77
79
  listGrantedDispatchMcpApps,
78
80
  listGrantedDispatchMcpAppOrigins,
79
81
  openGrantedDispatchMcpApp,
@@ -90,8 +92,8 @@ const analyticsAgent = {
90
92
 
91
93
  beforeEach(() => {
92
94
  mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
93
- mocks.getUserSetting.mockResolvedValue(null);
94
- mocks.getOrgSetting.mockResolvedValue(null);
95
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
96
+ mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
95
97
  mocks.createEmbedSessionTicket.mockResolvedValue({
96
98
  ticket: "ticket-123",
97
99
  ticketHash: "hash-123",
@@ -104,6 +106,7 @@ beforeEach(() => {
104
106
  startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
105
107
  },
106
108
  });
109
+ mocks.callAgent.mockResolvedValue("Created the requested dashboard.");
107
110
  mocks.signA2AToken.mockResolvedValue("signed-token");
108
111
  mocks.getOrgA2ASecret.mockResolvedValue(null);
109
112
  mocks.getOrgDomain.mockResolvedValue(null);
@@ -115,7 +118,21 @@ afterEach(() => {
115
118
  });
116
119
 
117
120
  describe("Dispatch MCP gateway app discovery", () => {
121
+ it("defaults to exposing Dispatch only until an owner or admin grants more apps", async () => {
122
+ mocks.getUserSetting.mockResolvedValue(null);
123
+ const apps = await runWithRequestContext(
124
+ {
125
+ userEmail: "owner@example.test",
126
+ requestOrigin: "http://localhost:8092",
127
+ },
128
+ () => listGrantedDispatchMcpApps(),
129
+ );
130
+
131
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
132
+ });
133
+
118
134
  it("includes Dispatch itself so agents can target extension routes", async () => {
135
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
119
136
  const apps = await runWithRequestContext(
120
137
  {
121
138
  userEmail: "owner@example.test",
@@ -222,6 +239,7 @@ describe("Dispatch MCP gateway app discovery", () => {
222
239
  color: "#111827",
223
240
  },
224
241
  ]);
242
+ mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
225
243
 
226
244
  await expect(
227
245
  runWithRequestContext(
@@ -235,6 +253,64 @@ describe("Dispatch MCP gateway app discovery", () => {
235
253
  });
236
254
  });
237
255
 
256
+ describe("askGrantedDispatchMcpApp", () => {
257
+ it("routes the authenticated user and active org identity to the granted app", async () => {
258
+ mocks.getOrgSetting.mockResolvedValue({
259
+ mode: "selected-apps",
260
+ selectedAppIds: ["analytics"],
261
+ });
262
+ mocks.getOrgDomain.mockResolvedValue("builder.io");
263
+ mocks.getOrgA2ASecret.mockResolvedValue("org-specific-secret");
264
+
265
+ const result = await runWithRequestContext(
266
+ {
267
+ userEmail: "owner@example.test",
268
+ orgId: "org-1",
269
+ requestOrigin: "http://localhost:8092",
270
+ },
271
+ () =>
272
+ askGrantedDispatchMcpApp(
273
+ "analytics",
274
+ "Build a weekly active users dashboard.",
275
+ ),
276
+ );
277
+
278
+ expect(mocks.callAgent).toHaveBeenCalledWith(
279
+ "http://localhost:8086",
280
+ "Build a weekly active users dashboard.",
281
+ {
282
+ userEmail: "owner@example.test",
283
+ orgDomain: "builder.io",
284
+ orgSecret: "org-specific-secret",
285
+ timeoutMs: 5 * 60_000,
286
+ },
287
+ );
288
+ expect(result).toEqual({
289
+ app: "analytics",
290
+ routedVia: "a2a",
291
+ response: "Created the requested dashboard.",
292
+ });
293
+ });
294
+
295
+ it("rejects delegation to an app outside the grant", async () => {
296
+ mocks.getUserSetting.mockResolvedValue({
297
+ mode: "selected-apps",
298
+ selectedAppIds: ["dispatch"],
299
+ });
300
+
301
+ await expect(
302
+ runWithRequestContext(
303
+ {
304
+ userEmail: "owner@example.test",
305
+ requestOrigin: "http://localhost:8092",
306
+ },
307
+ () => askGrantedDispatchMcpApp("analytics", "Show signups."),
308
+ ),
309
+ ).rejects.toThrow(/not granted/);
310
+ expect(mocks.callAgent).not.toHaveBeenCalled();
311
+ });
312
+ });
313
+
238
314
  describe("openGrantedDispatchMcpApp", () => {
239
315
  it("opens Dispatch extension routes through the Dispatch app id", async () => {
240
316
  const result = await runWithRequestContext(
@@ -20,7 +20,7 @@
20
20
  .dispatch-chat-panel [data-agent-empty-state="centered"] .dispatch-chat-intro {
21
21
  display: grid;
22
22
  width: 100%;
23
- max-width: min(760px, 90%);
23
+ max-width: min(570px, 90%);
24
24
  gap: 0.5rem;
25
25
  margin: 0 auto 1rem;
26
26
  text-align: center;
@@ -50,12 +50,12 @@
50
50
 
51
51
  .dispatch-chat-panel [data-agent-empty-state="centered"] .agent-composer-area {
52
52
  width: 100%;
53
- max-width: min(760px, 90%);
53
+ max-width: min(570px, 90%);
54
54
  padding: 0;
55
55
  }
56
56
 
57
57
  .dispatch-chat-panel [data-agent-empty-state="centered"] .agent-composer-root {
58
- min-height: 10.5rem;
58
+ min-height: 7.875rem;
59
59
  border-color: hsl(var(--border));
60
60
  border-radius: 1rem;
61
61
  background: hsl(var(--card));
@@ -72,7 +72,7 @@
72
72
  .dispatch-chat-panel
73
73
  [data-agent-empty-state="centered"]
74
74
  .agent-composer-prosemirror {
75
- min-height: 6.75rem;
75
+ min-height: 5.0625rem;
76
76
  font-size: 1rem;
77
77
  line-height: 1.625rem;
78
78
  }
@@ -103,6 +103,6 @@
103
103
  .dispatch-chat-panel
104
104
  [data-agent-empty-state="centered"]
105
105
  .agent-composer-root {
106
- min-height: 9rem;
106
+ min-height: 6.75rem;
107
107
  }
108
108
  }