@agent-native/dispatch 0.8.6 → 0.8.8
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/dist/actions/list_apps.js +1 -1
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/open_app.d.ts.map +1 -1
- package/dist/actions/open_app.js +7 -4
- package/dist/actions/open_app.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +4 -2
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/routes/pages/metrics.d.ts.map +1 -1
- package/dist/routes/pages/metrics.js +3 -1
- package/dist/routes/pages/metrics.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +104 -10
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +160 -4
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +1 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +1 -0
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/list_apps.ts +1 -1
- package/src/actions/open_app.ts +11 -4
- package/src/components/workspace-app-card.tsx +29 -18
- package/src/routes/pages/metrics.tsx +4 -1
- package/src/server/lib/app-creation-store.spec.ts +240 -0
- package/src/server/lib/app-creation-store.ts +130 -11
- package/src/server/lib/mcp-gateway.spec.ts +295 -0
- package/src/server/lib/mcp-gateway.ts +187 -4
- package/src/server/lib/usage-metrics-store.ts +2 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
discoverAgents: vi.fn(),
|
|
5
|
+
getUserSetting: vi.fn(),
|
|
6
|
+
getOrgSetting: vi.fn(),
|
|
7
|
+
createEmbedSessionTicket: vi.fn(),
|
|
8
|
+
buildEmbedStartPath: vi.fn((ticket: string) => {
|
|
9
|
+
return `/_agent-native/embed/start?ticket=${encodeURIComponent(ticket)}`;
|
|
10
|
+
}),
|
|
11
|
+
managerStart: vi.fn(),
|
|
12
|
+
managerStop: vi.fn(),
|
|
13
|
+
managerCallTool: vi.fn(),
|
|
14
|
+
managerConstructor: vi.fn(),
|
|
15
|
+
signA2AToken: vi.fn(),
|
|
16
|
+
getOrgA2ASecret: vi.fn(),
|
|
17
|
+
getOrgDomain: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("@agent-native/core/server/agent-discovery", () => ({
|
|
21
|
+
discoverAgents: mocks.discoverAgents,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@agent-native/core/settings", () => ({
|
|
25
|
+
getUserSetting: mocks.getUserSetting,
|
|
26
|
+
getOrgSetting: mocks.getOrgSetting,
|
|
27
|
+
putUserSetting: vi.fn(),
|
|
28
|
+
putOrgSetting: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
32
|
+
const actual =
|
|
33
|
+
await importOriginal<typeof import("@agent-native/core/server")>();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
createEmbedSessionTicket: mocks.createEmbedSessionTicket,
|
|
37
|
+
buildEmbedStartPath: mocks.buildEmbedStartPath,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock("@agent-native/core/a2a", () => ({
|
|
42
|
+
callAgent: vi.fn(),
|
|
43
|
+
signA2AToken: mocks.signA2AToken,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
47
|
+
getOrgA2ASecret: mocks.getOrgA2ASecret,
|
|
48
|
+
getOrgDomain: mocks.getOrgDomain,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
vi.mock("@agent-native/core/mcp-client", () => ({
|
|
52
|
+
buildMcpToolName: (serverId: string, toolName: string) =>
|
|
53
|
+
`mcp__${serverId}__${toolName}`,
|
|
54
|
+
McpClientManager: class MockMcpClientManager {
|
|
55
|
+
constructor(config: unknown) {
|
|
56
|
+
mocks.managerConstructor(config);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
start() {
|
|
60
|
+
return mocks.managerStart();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
stop() {
|
|
64
|
+
return mocks.managerStop();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
callTool(name: string, args: unknown) {
|
|
68
|
+
return mocks.managerCallTool(name, args);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
import {
|
|
74
|
+
createGrantedDispatchMcpEmbedSession,
|
|
75
|
+
listGrantedDispatchMcpApps,
|
|
76
|
+
openGrantedDispatchMcpApp,
|
|
77
|
+
} from "./mcp-gateway.js";
|
|
78
|
+
import { runWithRequestContext } from "@agent-native/core/server";
|
|
79
|
+
|
|
80
|
+
const analyticsAgent = {
|
|
81
|
+
id: "analytics",
|
|
82
|
+
name: "Analytics",
|
|
83
|
+
description: "Dashboards and metrics",
|
|
84
|
+
url: "http://localhost:8086",
|
|
85
|
+
color: "#6366F1",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
|
|
90
|
+
mocks.getUserSetting.mockResolvedValue(null);
|
|
91
|
+
mocks.getOrgSetting.mockResolvedValue(null);
|
|
92
|
+
mocks.createEmbedSessionTicket.mockResolvedValue({
|
|
93
|
+
ticket: "ticket-123",
|
|
94
|
+
ticketHash: "hash-123",
|
|
95
|
+
expiresAt: 12345,
|
|
96
|
+
});
|
|
97
|
+
mocks.managerStart.mockResolvedValue(undefined);
|
|
98
|
+
mocks.managerStop.mockResolvedValue(undefined);
|
|
99
|
+
mocks.managerCallTool.mockResolvedValue({
|
|
100
|
+
structuredContent: {
|
|
101
|
+
startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
mocks.signA2AToken.mockResolvedValue("signed-token");
|
|
105
|
+
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
106
|
+
mocks.getOrgDomain.mockResolvedValue(null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
vi.unstubAllEnvs();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("Dispatch MCP gateway app discovery", () => {
|
|
115
|
+
it("includes Dispatch itself so agents can target extension routes", async () => {
|
|
116
|
+
const apps = await runWithRequestContext(
|
|
117
|
+
{
|
|
118
|
+
userEmail: "owner@example.test",
|
|
119
|
+
requestOrigin: "http://localhost:8092",
|
|
120
|
+
},
|
|
121
|
+
() => listGrantedDispatchMcpApps(),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch", "analytics"]);
|
|
125
|
+
expect(apps[0]).toMatchObject({
|
|
126
|
+
id: "dispatch",
|
|
127
|
+
name: "Agent-Native Dispatch",
|
|
128
|
+
url: "http://localhost:8092",
|
|
129
|
+
granted: true,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("honors selected app grants for the Dispatch self target", async () => {
|
|
134
|
+
mocks.getUserSetting.mockResolvedValue({
|
|
135
|
+
mode: "selected-apps",
|
|
136
|
+
selectedAppIds: ["dispatch"],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const apps = await runWithRequestContext(
|
|
140
|
+
{
|
|
141
|
+
userEmail: "owner@example.test",
|
|
142
|
+
requestOrigin: "http://localhost:8092",
|
|
143
|
+
},
|
|
144
|
+
() => listGrantedDispatchMcpApps(),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("openGrantedDispatchMcpApp", () => {
|
|
152
|
+
it("opens Dispatch extension routes through the Dispatch app id", async () => {
|
|
153
|
+
const result = await runWithRequestContext(
|
|
154
|
+
{
|
|
155
|
+
userEmail: "owner@example.test",
|
|
156
|
+
requestOrigin: "http://localhost:8092",
|
|
157
|
+
},
|
|
158
|
+
() =>
|
|
159
|
+
openGrantedDispatchMcpApp({
|
|
160
|
+
app: "dispatch",
|
|
161
|
+
path: "/extensions/ext-1/github-stars-over-time",
|
|
162
|
+
embed: true,
|
|
163
|
+
chrome: "minimal",
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
app: "dispatch",
|
|
169
|
+
path: "/extensions/ext-1/github-stars-over-time",
|
|
170
|
+
url: "http://localhost:8092/extensions/ext-1/github-stars-over-time",
|
|
171
|
+
embed: true,
|
|
172
|
+
chrome: "minimal",
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects Dispatch-owned extension routes on sibling apps", async () => {
|
|
177
|
+
await expect(
|
|
178
|
+
runWithRequestContext(
|
|
179
|
+
{
|
|
180
|
+
userEmail: "owner@example.test",
|
|
181
|
+
requestOrigin: "http://localhost:8092",
|
|
182
|
+
},
|
|
183
|
+
() =>
|
|
184
|
+
openGrantedDispatchMcpApp({
|
|
185
|
+
app: "analytics",
|
|
186
|
+
path: "/extensions/ext-1/github-stars-over-time",
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
).rejects.toThrow(/belongs to Dispatch/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("rejects traversal that normalizes into Dispatch-owned routes on sibling apps", async () => {
|
|
193
|
+
await expect(
|
|
194
|
+
runWithRequestContext(
|
|
195
|
+
{
|
|
196
|
+
userEmail: "owner@example.test",
|
|
197
|
+
requestOrigin: "http://localhost:8092",
|
|
198
|
+
},
|
|
199
|
+
() =>
|
|
200
|
+
openGrantedDispatchMcpApp({
|
|
201
|
+
app: "analytics",
|
|
202
|
+
path: "/../dispatch/extensions/ext-1",
|
|
203
|
+
}),
|
|
204
|
+
),
|
|
205
|
+
).rejects.toThrow(/safe app-relative route/);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
210
|
+
it("mints Dispatch self embeds locally instead of recursively calling Dispatch MCP", async () => {
|
|
211
|
+
const result = await runWithRequestContext(
|
|
212
|
+
{
|
|
213
|
+
userEmail: "owner@example.test",
|
|
214
|
+
requestOrigin: "http://localhost:8092",
|
|
215
|
+
},
|
|
216
|
+
() =>
|
|
217
|
+
createGrantedDispatchMcpEmbedSession({
|
|
218
|
+
app: "dispatch",
|
|
219
|
+
path: "/extensions/ext-1/github-stars-over-time",
|
|
220
|
+
chrome: "minimal",
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(mocks.createEmbedSessionTicket).toHaveBeenCalledWith({
|
|
225
|
+
ownerEmail: "owner@example.test",
|
|
226
|
+
orgId: undefined,
|
|
227
|
+
targetPath: "/extensions/ext-1/github-stars-over-time",
|
|
228
|
+
scope: "minimal",
|
|
229
|
+
});
|
|
230
|
+
expect(mocks.managerConstructor).not.toHaveBeenCalled();
|
|
231
|
+
expect(result).toEqual({
|
|
232
|
+
app: "dispatch",
|
|
233
|
+
startUrl:
|
|
234
|
+
"http://localhost:8092/_agent-native/embed/start?ticket=ticket-123",
|
|
235
|
+
targetPath: "/extensions/ext-1/github-stars-over-time",
|
|
236
|
+
expiresAt: 12345,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("rejects traversal into Dispatch-owned embed routes on sibling apps", async () => {
|
|
241
|
+
await expect(
|
|
242
|
+
runWithRequestContext(
|
|
243
|
+
{
|
|
244
|
+
userEmail: "owner@example.test",
|
|
245
|
+
requestOrigin: "http://localhost:8092",
|
|
246
|
+
},
|
|
247
|
+
() =>
|
|
248
|
+
createGrantedDispatchMcpEmbedSession({
|
|
249
|
+
app: "analytics",
|
|
250
|
+
path: "/../dispatch/extensions/ext-1",
|
|
251
|
+
}),
|
|
252
|
+
),
|
|
253
|
+
).rejects.toThrow(/safe app-relative route/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("routes same-origin mounted app embed URLs to the mounted app", async () => {
|
|
257
|
+
mocks.discoverAgents.mockResolvedValue([
|
|
258
|
+
{
|
|
259
|
+
...analyticsAgent,
|
|
260
|
+
url: "http://localhost:8092/analytics",
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const result = await runWithRequestContext(
|
|
265
|
+
{
|
|
266
|
+
userEmail: "owner@example.test",
|
|
267
|
+
requestOrigin: "http://localhost:8092",
|
|
268
|
+
},
|
|
269
|
+
() =>
|
|
270
|
+
createGrantedDispatchMcpEmbedSession({
|
|
271
|
+
url: "http://localhost:8092/analytics/dashboards?range=30d",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(mocks.createEmbedSessionTicket).not.toHaveBeenCalled();
|
|
276
|
+
expect(mocks.managerConstructor).toHaveBeenCalledWith({
|
|
277
|
+
servers: {
|
|
278
|
+
target: expect.objectContaining({
|
|
279
|
+
url: "http://localhost:8092/analytics/_agent-native/mcp",
|
|
280
|
+
}),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
expect(mocks.managerCallTool).toHaveBeenCalledWith(
|
|
284
|
+
"mcp__target__create_embed_session",
|
|
285
|
+
{
|
|
286
|
+
url: "http://localhost:8092/analytics/dashboards?range=30d",
|
|
287
|
+
chrome: "full",
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
expect(result).toEqual({
|
|
291
|
+
app: "analytics",
|
|
292
|
+
startUrl: "http://localhost:8086/_agent-native/embed/start?ticket=remote",
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -3,7 +3,12 @@ import {
|
|
|
3
3
|
buildMcpToolName,
|
|
4
4
|
McpClientManager,
|
|
5
5
|
} from "@agent-native/core/mcp-client";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildDeepLink,
|
|
8
|
+
buildEmbedStartPath,
|
|
9
|
+
createEmbedSessionTicket,
|
|
10
|
+
getRequestContext,
|
|
11
|
+
} from "@agent-native/core/server";
|
|
7
12
|
import {
|
|
8
13
|
discoverAgents,
|
|
9
14
|
type DiscoveredAgent,
|
|
@@ -19,6 +24,12 @@ import {
|
|
|
19
24
|
type DispatchMcpAppAccessSettings,
|
|
20
25
|
} from "./mcp-access-store.js";
|
|
21
26
|
|
|
27
|
+
const DISPATCH_APP_ID = "dispatch";
|
|
28
|
+
const DISPATCH_NAME = "Agent-Native Dispatch";
|
|
29
|
+
const DISPATCH_DESCRIPTION =
|
|
30
|
+
"Workspace control plane for extensions, agents, vault, integrations, approvals, and app routing.";
|
|
31
|
+
const DISPATCH_COLOR = "#14B8A6";
|
|
32
|
+
|
|
22
33
|
export interface DispatchMcpAccessibleApp {
|
|
23
34
|
id: string;
|
|
24
35
|
name: string;
|
|
@@ -32,6 +43,73 @@ function normalizeAppId(value: string): string {
|
|
|
32
43
|
return value.trim().toLowerCase();
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
function normalizeBaseUrl(raw: string | undefined | null): string | null {
|
|
47
|
+
const value = raw?.trim();
|
|
48
|
+
if (!value) return null;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(value);
|
|
51
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
52
|
+
return url.toString().replace(/\/+$/, "");
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeBasePath(value: string | undefined): string {
|
|
59
|
+
const trimmed = value?.trim();
|
|
60
|
+
if (!trimmed || trimmed === "/") return "";
|
|
61
|
+
const normalized = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
62
|
+
return normalized ? `/${normalized}` : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function withConfiguredBasePath(baseUrl: string): string {
|
|
66
|
+
const basePath = normalizeBasePath(
|
|
67
|
+
process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH,
|
|
68
|
+
);
|
|
69
|
+
if (!basePath) return baseUrl;
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(baseUrl);
|
|
72
|
+
const path = normalizeBasePath(url.pathname);
|
|
73
|
+
if (path === basePath || path.startsWith(`${basePath}/`)) {
|
|
74
|
+
return baseUrl;
|
|
75
|
+
}
|
|
76
|
+
url.pathname = path && path !== "/" ? `${basePath}${path}` : `${basePath}/`;
|
|
77
|
+
return url.toString().replace(/\/+$/, "");
|
|
78
|
+
} catch {
|
|
79
|
+
return baseUrl;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dispatchSelfBaseUrl(): string {
|
|
84
|
+
const requestOrigin = normalizeBaseUrl(getRequestContext()?.requestOrigin);
|
|
85
|
+
if (requestOrigin) return withConfiguredBasePath(requestOrigin);
|
|
86
|
+
|
|
87
|
+
const configured =
|
|
88
|
+
normalizeBaseUrl(process.env.WORKSPACE_GATEWAY_URL) ??
|
|
89
|
+
normalizeBaseUrl(process.env.APP_URL) ??
|
|
90
|
+
normalizeBaseUrl(process.env.URL) ??
|
|
91
|
+
normalizeBaseUrl(process.env.DEPLOY_URL) ??
|
|
92
|
+
normalizeBaseUrl(process.env.BETTER_AUTH_URL);
|
|
93
|
+
if (configured) return withConfiguredBasePath(configured);
|
|
94
|
+
|
|
95
|
+
return process.env.NODE_ENV === "production"
|
|
96
|
+
? "https://dispatch.agent-native.com"
|
|
97
|
+
: "http://localhost:8092";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function dispatchSelfApp(
|
|
101
|
+
settings: DispatchMcpAppAccessSettings,
|
|
102
|
+
): DispatchMcpAccessibleApp {
|
|
103
|
+
return {
|
|
104
|
+
id: DISPATCH_APP_ID,
|
|
105
|
+
name: DISPATCH_NAME,
|
|
106
|
+
description: DISPATCH_DESCRIPTION,
|
|
107
|
+
url: dispatchSelfBaseUrl(),
|
|
108
|
+
color: DISPATCH_COLOR,
|
|
109
|
+
granted: isAppAllowedByMcpAccess(DISPATCH_APP_ID, settings),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
35
113
|
const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]");
|
|
36
114
|
|
|
37
115
|
function safeAppPath(raw: unknown): string | null {
|
|
@@ -41,6 +119,15 @@ function safeAppPath(raw: unknown): string | null {
|
|
|
41
119
|
if (!value.startsWith("/")) return null;
|
|
42
120
|
if (value.startsWith("//") || value.startsWith("/\\")) return null;
|
|
43
121
|
if (/^\/[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
|
122
|
+
if (/%(?:2f|5c)/i.test(value)) return null;
|
|
123
|
+
const rawPath = value.split(/[?#]/, 1)[0] ?? value;
|
|
124
|
+
let parsed: URL;
|
|
125
|
+
try {
|
|
126
|
+
parsed = new URL(value, "http://agent-native.invalid");
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (parsed.pathname !== rawPath) return null;
|
|
44
131
|
return value;
|
|
45
132
|
}
|
|
46
133
|
|
|
@@ -64,6 +151,51 @@ function appBaseUrl(app: DispatchMcpAccessibleApp): string {
|
|
|
64
151
|
return app.url.replace(/\/+$/, "");
|
|
65
152
|
}
|
|
66
153
|
|
|
154
|
+
function appBasePath(app: DispatchMcpAccessibleApp): string {
|
|
155
|
+
const pathname = new URL(appBaseUrl(app)).pathname.replace(/\/+$/, "");
|
|
156
|
+
return pathname === "/" ? "" : pathname;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function appMatchesUrlPath(app: DispatchMcpAccessibleApp, url: URL): boolean {
|
|
160
|
+
if (url.origin !== appOrigin(app)) return false;
|
|
161
|
+
const basePath = appBasePath(app);
|
|
162
|
+
if (!basePath) return true;
|
|
163
|
+
return url.pathname === basePath || url.pathname.startsWith(`${basePath}/`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appPathSpecificity(app: DispatchMcpAccessibleApp): number {
|
|
167
|
+
return appBasePath(app).length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function appRelativePath(app: DispatchMcpAccessibleApp, url: URL): string {
|
|
171
|
+
const basePath = appBasePath(app);
|
|
172
|
+
const path = basePath
|
|
173
|
+
? url.pathname === basePath
|
|
174
|
+
? "/"
|
|
175
|
+
: url.pathname.slice(basePath.length)
|
|
176
|
+
: url.pathname;
|
|
177
|
+
return `${path || "/"}${url.search}${url.hash}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isDispatchControlPath(path: string | null): boolean {
|
|
181
|
+
if (!path) return false;
|
|
182
|
+
const route = path.split(/[?#]/, 1)[0] ?? path;
|
|
183
|
+
return (
|
|
184
|
+
route === "/extensions" ||
|
|
185
|
+
route.startsWith("/extensions/") ||
|
|
186
|
+
route === "/tools" ||
|
|
187
|
+
route.startsWith("/tools/")
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assertAppCanOpenPath(app: DispatchMcpAccessibleApp, path: string) {
|
|
192
|
+
if (app.id !== DISPATCH_APP_ID && isDispatchControlPath(path)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Path "${path}" belongs to Dispatch. Use app: "dispatch" for Dispatch extension and tool routes.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
67
199
|
function toAccessibleApp(
|
|
68
200
|
agent: DiscoveredAgent,
|
|
69
201
|
settings: DispatchMcpAppAccessSettings,
|
|
@@ -88,7 +220,12 @@ export async function listDispatchMcpApps(): Promise<{
|
|
|
88
220
|
]);
|
|
89
221
|
return {
|
|
90
222
|
settings,
|
|
91
|
-
apps:
|
|
223
|
+
apps: [
|
|
224
|
+
dispatchSelfApp(settings),
|
|
225
|
+
...agents
|
|
226
|
+
.filter((agent) => normalizeAppId(agent.id) !== DISPATCH_APP_ID)
|
|
227
|
+
.map((agent) => toAccessibleApp(agent, settings)),
|
|
228
|
+
],
|
|
92
229
|
};
|
|
93
230
|
}
|
|
94
231
|
|
|
@@ -165,9 +302,14 @@ export async function openGrantedDispatchMcpApp(input: {
|
|
|
165
302
|
chrome?: "full" | "minimal";
|
|
166
303
|
}> {
|
|
167
304
|
const view = input.view?.trim() ?? "";
|
|
305
|
+
const hasPathInput = input.path != null;
|
|
168
306
|
const path = safeAppPath(input.path);
|
|
307
|
+
if (hasPathInput && !path) {
|
|
308
|
+
throw new Error("path must be a safe app-relative route");
|
|
309
|
+
}
|
|
169
310
|
if (!view && !path) throw new Error("open_app requires view or path");
|
|
170
311
|
const target = await resolveGrantedDispatchMcpApp(input.app);
|
|
312
|
+
if (path) assertAppCanOpenPath(target, path);
|
|
171
313
|
const relUrl = path
|
|
172
314
|
? appendParamsToPath(path, input.params)
|
|
173
315
|
: buildDeepLink({
|
|
@@ -214,6 +356,7 @@ async function resolveDispatchEmbedTarget(input: {
|
|
|
214
356
|
if (explicitApp && input.path) {
|
|
215
357
|
const path = safeAppPath(input.path);
|
|
216
358
|
if (!path) throw new Error("path must be a safe app-relative route");
|
|
359
|
+
assertAppCanOpenPath(explicitApp, path);
|
|
217
360
|
return {
|
|
218
361
|
app: explicitApp,
|
|
219
362
|
path,
|
|
@@ -242,17 +385,47 @@ async function resolveDispatchEmbedTarget(input: {
|
|
|
242
385
|
}
|
|
243
386
|
|
|
244
387
|
const apps = explicitApp ? [explicitApp] : await listGrantedDispatchMcpApps();
|
|
245
|
-
const target = apps
|
|
388
|
+
const target = apps
|
|
389
|
+
.filter((app) => appMatchesUrlPath(app, parsed))
|
|
390
|
+
.sort((a, b) => appPathSpecificity(b) - appPathSpecificity(a))[0];
|
|
246
391
|
if (!target) {
|
|
247
392
|
throw new Error(
|
|
248
393
|
"Embed URL must belong to an app granted through Dispatch.",
|
|
249
394
|
);
|
|
250
395
|
}
|
|
251
|
-
const path = safeAppPath(
|
|
396
|
+
const path = safeAppPath(appRelativePath(target, parsed));
|
|
252
397
|
if (!path) throw new Error("Embed URL path is not safe.");
|
|
398
|
+
assertAppCanOpenPath(target, path);
|
|
253
399
|
return { app: target, path, url: `${appBaseUrl(target)}${path}` };
|
|
254
400
|
}
|
|
255
401
|
|
|
402
|
+
async function createDispatchSelfEmbedSession(input: {
|
|
403
|
+
ownerEmail: string;
|
|
404
|
+
orgId?: string;
|
|
405
|
+
path: string;
|
|
406
|
+
baseUrl: string;
|
|
407
|
+
chrome?: "full" | "minimal";
|
|
408
|
+
}): Promise<{
|
|
409
|
+
startUrl: string;
|
|
410
|
+
targetPath?: string;
|
|
411
|
+
expiresAt?: number;
|
|
412
|
+
app: string;
|
|
413
|
+
}> {
|
|
414
|
+
const ticket = await createEmbedSessionTicket({
|
|
415
|
+
ownerEmail: input.ownerEmail,
|
|
416
|
+
orgId: input.orgId,
|
|
417
|
+
targetPath: input.path,
|
|
418
|
+
scope: input.chrome ?? null,
|
|
419
|
+
});
|
|
420
|
+
const startPath = buildEmbedStartPath(ticket.ticket);
|
|
421
|
+
return {
|
|
422
|
+
startUrl: new URL(startPath, input.baseUrl).toString(),
|
|
423
|
+
targetPath: input.path,
|
|
424
|
+
expiresAt: ticket.expiresAt,
|
|
425
|
+
app: DISPATCH_APP_ID,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
256
429
|
export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
257
430
|
app?: string;
|
|
258
431
|
url?: string;
|
|
@@ -269,6 +442,16 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
|
269
442
|
const target = await resolveDispatchEmbedTarget(input);
|
|
270
443
|
|
|
271
444
|
const orgId = getRequestOrgId();
|
|
445
|
+
if (target.app.id === DISPATCH_APP_ID) {
|
|
446
|
+
return createDispatchSelfEmbedSession({
|
|
447
|
+
ownerEmail: userEmail,
|
|
448
|
+
orgId,
|
|
449
|
+
path: target.path,
|
|
450
|
+
baseUrl: appBaseUrl(target.app),
|
|
451
|
+
chrome: input.chrome,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
272
455
|
const [orgDomain, orgSecret] = orgId
|
|
273
456
|
? await Promise.all([
|
|
274
457
|
getOrgDomain(orgId).catch(() => null),
|
|
@@ -51,6 +51,7 @@ export interface AppAccessMetric {
|
|
|
51
51
|
name: string;
|
|
52
52
|
path: string;
|
|
53
53
|
status: WorkspaceAppSummary["status"];
|
|
54
|
+
statusLabel?: string;
|
|
54
55
|
isDispatch: boolean;
|
|
55
56
|
accessModel: "workspace" | "solo";
|
|
56
57
|
accessLabel: string;
|
|
@@ -587,6 +588,7 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
587
588
|
name: app.name,
|
|
588
589
|
path: app.path,
|
|
589
590
|
status: app.status,
|
|
591
|
+
statusLabel: app.statusLabel,
|
|
590
592
|
isDispatch: app.isDispatch,
|
|
591
593
|
accessModel,
|
|
592
594
|
accessLabel,
|