@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.
- package/README.md +114 -128
- package/dist/actions/list_apps.d.ts +1 -0
- package/dist/actions/list_apps.js +16 -8
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/set-mcp-app-access.js +1 -4
- package/dist/actions/set-mcp-app-access.js.map +1 -1
- package/dist/components/action-query-error.d.ts +8 -0
- package/dist/components/action-query-error.d.ts.map +1 -0
- package/dist/components/action-query-error.js +10 -0
- package/dist/components/action-query-error.js.map +1 -0
- package/dist/components/app-keys-popover.d.ts +4 -0
- package/dist/components/app-keys-popover.d.ts.map +1 -1
- package/dist/components/app-keys-popover.js +26 -14
- package/dist/components/app-keys-popover.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +31 -27
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +19 -21
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/task-queue-health.d.ts.map +1 -1
- package/dist/components/task-queue-health.js +9 -1
- package/dist/components/task-queue-health.js.map +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/sheet.js.map +1 -1
- package/dist/components/ui/toast.js +1 -1
- package/dist/components/ui/toast.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +8 -5
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
- package/dist/components/workspace-resource-effective-stack.js +6 -1
- package/dist/components/workspace-resource-effective-stack.js.map +1 -1
- package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
- package/dist/components/workspace-resource-impact-preview.js +6 -1
- package/dist/components/workspace-resource-impact-preview.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +6 -1
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/agents.d.ts.map +1 -1
- package/dist/routes/pages/agents.js +5 -8
- package/dist/routes/pages/agents.js.map +1 -1
- package/dist/routes/pages/approval.d.ts.map +1 -1
- package/dist/routes/pages/approval.js +6 -1
- package/dist/routes/pages/approval.js.map +1 -1
- package/dist/routes/pages/approvals.d.ts.map +1 -1
- package/dist/routes/pages/approvals.js +8 -5
- package/dist/routes/pages/approvals.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +4 -2
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +10 -42
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/audit.d.ts.map +1 -1
- package/dist/routes/pages/audit.js +4 -2
- package/dist/routes/pages/audit.js.map +1 -1
- package/dist/routes/pages/automations.d.ts.map +1 -1
- package/dist/routes/pages/automations.js +0 -1
- package/dist/routes/pages/automations.js.map +1 -1
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +16 -2
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/routes/pages/destinations.d.ts.map +1 -1
- package/dist/routes/pages/destinations.js +5 -3
- package/dist/routes/pages/destinations.js.map +1 -1
- package/dist/routes/pages/identities.d.ts.map +1 -1
- package/dist/routes/pages/identities.js +5 -3
- package/dist/routes/pages/identities.js.map +1 -1
- package/dist/routes/pages/integrations.d.ts.map +1 -1
- package/dist/routes/pages/integrations.js +9 -3
- package/dist/routes/pages/integrations.js.map +1 -1
- package/dist/routes/pages/metrics.d.ts.map +1 -1
- package/dist/routes/pages/metrics.js +1 -1
- package/dist/routes/pages/metrics.js.map +1 -1
- package/dist/routes/pages/thread-debug.d.ts.map +1 -1
- package/dist/routes/pages/thread-debug.js +7 -6
- package/dist/routes/pages/thread-debug.js.map +1 -1
- package/dist/routes/pages/vault.d.ts.map +1 -1
- package/dist/routes/pages/vault.js +23 -7
- package/dist/routes/pages/vault.js.map +1 -1
- package/dist/routes/pages/workspace.d.ts.map +1 -1
- package/dist/routes/pages/workspace.js +9 -3
- package/dist/routes/pages/workspace.js.map +1 -1
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +2 -2
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
- package/dist/server/lib/mcp-access-store.js +30 -2
- package/dist/server/lib/mcp-access-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.d.ts +1 -1
- package/package.json +2 -2
- package/src/actions/list_apps.spec.ts +52 -0
- package/src/actions/list_apps.ts +16 -8
- package/src/actions/set-mcp-app-access.spec.ts +73 -0
- package/src/actions/set-mcp-app-access.ts +1 -5
- package/src/components/action-query-error.spec.tsx +56 -0
- package/src/components/action-query-error.tsx +32 -0
- package/src/components/app-keys-popover.spec.tsx +85 -0
- package/src/components/app-keys-popover.tsx +76 -50
- package/src/components/dispatch-control-plane.spec.tsx +106 -0
- package/src/components/dispatch-control-plane.tsx +71 -115
- package/src/components/index.ts +1 -0
- package/src/components/layout/Layout.spec.tsx +42 -2
- package/src/components/layout/Layout.tsx +22 -27
- package/src/components/task-queue-health.tsx +9 -1
- package/src/components/ui/sheet.tsx +1 -1
- package/src/components/ui/toast.tsx +1 -1
- package/src/components/workspace-app-card.spec.tsx +80 -0
- package/src/components/workspace-app-card.tsx +36 -25
- package/src/components/workspace-resource-effective-stack.tsx +13 -1
- package/src/components/workspace-resource-impact-preview.tsx +12 -1
- package/src/routes/pages/$appId.tsx +17 -1
- package/src/routes/pages/agents.tsx +24 -11
- package/src/routes/pages/approval.tsx +16 -4
- package/src/routes/pages/approvals.tsx +108 -89
- package/src/routes/pages/apps.$appId.tsx +9 -2
- package/src/routes/pages/apps.tsx +13 -177
- package/src/routes/pages/audit.tsx +29 -20
- package/src/routes/pages/automations.tsx +0 -1
- package/src/routes/pages/chat.spec.tsx +94 -0
- package/src/routes/pages/chat.tsx +30 -10
- package/src/routes/pages/destinations.tsx +75 -62
- package/src/routes/pages/identities.tsx +74 -63
- package/src/routes/pages/integrations.tsx +17 -10
- package/src/routes/pages/metrics.tsx +0 -1
- package/src/routes/pages/thread-debug.tsx +18 -12
- package/src/routes/pages/vault.tsx +59 -26
- package/src/routes/pages/workspace.tsx +69 -55
- package/src/server/lib/approval-fencing.spec.ts +113 -2
- package/src/server/lib/dispatch-store.ts +2 -0
- package/src/server/lib/mcp-access-store.spec.ts +116 -4
- package/src/server/lib/mcp-access-store.ts +37 -2
- package/src/server/lib/mcp-gateway.spec.ts +79 -3
- 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
|
|
9
|
-
|
|
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
|
|
36
|
+
it("defaults to Dispatch only", () => {
|
|
10
37
|
expect(normalizeMcpAppAccessSettings(null)).toEqual({
|
|
11
|
-
mode: "
|
|
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 === "
|
|
59
|
+
const mode = record.mode === "all-apps" ? "all-apps" : "selected-apps";
|
|
49
60
|
return {
|
|
50
61
|
mode,
|
|
51
|
-
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:
|
|
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(
|
|
94
|
-
mocks.getOrgSetting.mockResolvedValue(
|
|
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(
|
package/src/styles/dispatch.css
CHANGED
|
@@ -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(
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
106
|
+
min-height: 6.75rem;
|
|
107
107
|
}
|
|
108
108
|
}
|