@agent-native/dispatch 0.40.41-same-host-mcp-20261027-20261027081850 → 0.40.41
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/CHANGELOG.md +28 -10
- package/dist/actions/provider-api-register.d.ts +11 -11
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/components/action-query-error.d.ts.map +1 -1
- package/dist/components/action-query-error.js +8 -0
- package/dist/components/action-query-error.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +48 -3
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts +8 -0
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +71 -11
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/workspace-app-chat-proxy.d.ts +6 -5
- package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -1
- package/dist/server/lib/workspace-app-chat-proxy.js +78 -11
- package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +1 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/components/action-query-error.spec.tsx +37 -0
- package/src/components/action-query-error.tsx +11 -0
- package/src/server/lib/app-creation-store.spec.ts +101 -1
- package/src/server/lib/app-creation-store.ts +65 -4
- package/src/server/lib/mcp-gateway.spec.ts +135 -1
- package/src/server/lib/mcp-gateway.ts +122 -22
- package/src/server/lib/workspace-app-chat-proxy.spec.ts +309 -5
- package/src/server/lib/workspace-app-chat-proxy.ts +123 -25
- package/src/server/plugins/agent-chat.spec.ts +1 -0
- package/src/server/plugins/agent-chat.ts +1 -0
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import { getRequestContext } from "@agent-native/core/server";
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
|
|
3
4
|
const mocks = vi.hoisted(() => ({
|
|
4
5
|
getSession: vi.fn(),
|
|
5
6
|
getOrgContext: vi.fn(),
|
|
7
|
+
getAppProductionUrl: vi.fn(),
|
|
8
|
+
getForwardedRequestOrigin: vi.fn(),
|
|
9
|
+
resolveAppRuntimeUrl: vi.fn(),
|
|
6
10
|
createWorkspaceSsoEmbedSession: vi.fn(),
|
|
7
11
|
createGrantedDispatchMcpEmbedSession: vi.fn(),
|
|
12
|
+
createWorkspaceSsoEmbedSessionForWorkspaceAppChat: vi.fn(),
|
|
13
|
+
createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat: vi.fn(),
|
|
8
14
|
}));
|
|
9
15
|
|
|
10
16
|
vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
11
17
|
const actual =
|
|
12
18
|
await importOriginal<typeof import("@agent-native/core/server")>();
|
|
13
|
-
return {
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
getAppProductionUrl: mocks.getAppProductionUrl,
|
|
22
|
+
getForwardedRequestOrigin: mocks.getForwardedRequestOrigin,
|
|
23
|
+
getSession: mocks.getSession,
|
|
24
|
+
resolveAppRuntimeUrl: mocks.resolveAppRuntimeUrl,
|
|
25
|
+
};
|
|
14
26
|
});
|
|
15
27
|
|
|
16
28
|
vi.mock("@agent-native/core/org", () => ({
|
|
@@ -21,6 +33,10 @@ vi.mock("./mcp-gateway.js", () => ({
|
|
|
21
33
|
createWorkspaceSsoEmbedSession: mocks.createWorkspaceSsoEmbedSession,
|
|
22
34
|
createGrantedDispatchMcpEmbedSession:
|
|
23
35
|
mocks.createGrantedDispatchMcpEmbedSession,
|
|
36
|
+
createWorkspaceSsoEmbedSessionForWorkspaceAppChat:
|
|
37
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat,
|
|
38
|
+
createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat:
|
|
39
|
+
mocks.createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat,
|
|
24
40
|
}));
|
|
25
41
|
|
|
26
42
|
import {
|
|
@@ -28,7 +44,12 @@ import {
|
|
|
28
44
|
createWorkspaceAppChatProxyHandler,
|
|
29
45
|
} from "./workspace-app-chat-proxy.js";
|
|
30
46
|
|
|
47
|
+
type WorkspaceAppChatProxyEvent = Parameters<
|
|
48
|
+
ReturnType<typeof createWorkspaceAppChatProxyHandler>
|
|
49
|
+
>[0];
|
|
50
|
+
|
|
31
51
|
const APP_ORIGIN = "https://analytics.agent-native.com";
|
|
52
|
+
const WORKSPACE_ORIGIN = "https://beta.agent-workspace.builder.io";
|
|
32
53
|
const START_URL = `${APP_ORIGIN}/_agent-native/embed/start?ticket=ticket-1`;
|
|
33
54
|
|
|
34
55
|
function embedToken(expiresInSeconds = 3600): string {
|
|
@@ -45,7 +66,7 @@ function embedToken(expiresInSeconds = 3600): string {
|
|
|
45
66
|
|
|
46
67
|
function proxyEvent(path: string, init: RequestInit = {}) {
|
|
47
68
|
const url = new URL(`https://dispatch.agent-native.com${path}`);
|
|
48
|
-
return { url, req: new Request(url, init) };
|
|
69
|
+
return { url, req: new Request(url, init) } as WorkspaceAppChatProxyEvent;
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
function mountedProxyEvent(
|
|
@@ -58,7 +79,7 @@ function mountedProxyEvent(
|
|
|
58
79
|
url,
|
|
59
80
|
req: new Request(url, init),
|
|
60
81
|
context: { _mountedPathname: mountedPath },
|
|
61
|
-
};
|
|
82
|
+
} as WorkspaceAppChatProxyEvent;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
function stubFetch(
|
|
@@ -66,7 +87,11 @@ function stubFetch(
|
|
|
66
87
|
url: string,
|
|
67
88
|
init: RequestInit,
|
|
68
89
|
) => Response | Promise<Response>,
|
|
69
|
-
options: {
|
|
90
|
+
options: {
|
|
91
|
+
token?: string;
|
|
92
|
+
startStatus?: number;
|
|
93
|
+
redirectLocation?: (token: string) => string;
|
|
94
|
+
} = {},
|
|
70
95
|
) {
|
|
71
96
|
return vi.fn(async (input: string | URL, init: RequestInit = {}) => {
|
|
72
97
|
const url = String(input);
|
|
@@ -78,7 +103,9 @@ function stubFetch(
|
|
|
78
103
|
return new Response("", {
|
|
79
104
|
status: 302,
|
|
80
105
|
headers: {
|
|
81
|
-
location:
|
|
106
|
+
location:
|
|
107
|
+
options.redirectLocation?.(token) ??
|
|
108
|
+
`/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
|
|
82
109
|
},
|
|
83
110
|
});
|
|
84
111
|
}
|
|
@@ -91,11 +118,46 @@ describe("workspace app chat proxy", () => {
|
|
|
91
118
|
clearWorkspaceAppChatSessions();
|
|
92
119
|
mocks.getSession.mockResolvedValue({ email: "owner@example.com" });
|
|
93
120
|
mocks.getOrgContext.mockResolvedValue({ orgId: "org-1" });
|
|
121
|
+
mocks.getAppProductionUrl.mockReturnValue(
|
|
122
|
+
"https://dispatch.agent-native.com",
|
|
123
|
+
);
|
|
124
|
+
mocks.getForwardedRequestOrigin.mockReturnValue(
|
|
125
|
+
"https://dispatch.agent-native.com",
|
|
126
|
+
);
|
|
127
|
+
mocks.resolveAppRuntimeUrl.mockReturnValue(
|
|
128
|
+
"https://workspace.agent-native.com/dispatch",
|
|
129
|
+
);
|
|
94
130
|
mocks.createWorkspaceSsoEmbedSession.mockResolvedValue({
|
|
95
131
|
startUrl: START_URL,
|
|
96
132
|
app: "analytics",
|
|
97
133
|
});
|
|
98
134
|
mocks.createGrantedDispatchMcpEmbedSession.mockReset();
|
|
135
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat.mockImplementation(
|
|
136
|
+
async (appId: string) => {
|
|
137
|
+
const result = await mocks.createWorkspaceSsoEmbedSession({
|
|
138
|
+
app: appId,
|
|
139
|
+
path: "/",
|
|
140
|
+
chrome: "minimal",
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
startUrl: result.startUrl,
|
|
144
|
+
appBaseUrl: new URL(result.startUrl).origin,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
mocks.createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat.mockImplementation(
|
|
149
|
+
async (appId: string) => {
|
|
150
|
+
const result = await mocks.createGrantedDispatchMcpEmbedSession({
|
|
151
|
+
app: appId,
|
|
152
|
+
path: "/",
|
|
153
|
+
chrome: "minimal",
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
startUrl: result.startUrl,
|
|
157
|
+
appBaseUrl: new URL(result.startUrl).origin,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
);
|
|
99
161
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
100
162
|
});
|
|
101
163
|
|
|
@@ -127,6 +189,248 @@ describe("workspace app chat proxy", () => {
|
|
|
127
189
|
expect(headers.get("cookie")).toBeNull();
|
|
128
190
|
});
|
|
129
191
|
|
|
192
|
+
it("forwards chat through the Workspace app mount returned by embed exchange", async () => {
|
|
193
|
+
const appId = "customer-ticket-triage";
|
|
194
|
+
const appBaseUrl = `${WORKSPACE_ORIGIN}/dispatch/apps/${appId}`;
|
|
195
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat.mockResolvedValue({
|
|
196
|
+
startUrl: `${WORKSPACE_ORIGIN}/dispatch/_agent-native/embed/start?ticket=ticket-1`,
|
|
197
|
+
appBaseUrl,
|
|
198
|
+
});
|
|
199
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }), {
|
|
200
|
+
redirectLocation: (token) =>
|
|
201
|
+
`/dispatch/apps/${appId}/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
|
|
202
|
+
});
|
|
203
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
204
|
+
|
|
205
|
+
const response = await handler(
|
|
206
|
+
proxyEvent(`/_agent-native/workspace-app-chat/${appId}`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
body: JSON.stringify({ messages: [] }),
|
|
209
|
+
headers: { "content-type": "application/json" },
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(response.status).toBe(200);
|
|
214
|
+
const chatCall = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
|
|
215
|
+
.calls[1];
|
|
216
|
+
expect(chatCall[0]).toBe(`${appBaseUrl}/_agent-native/agent-chat`);
|
|
217
|
+
expect(
|
|
218
|
+
(chatCall[1].headers as Headers).get("x-agent-native-embed-target"),
|
|
219
|
+
).toBe(`/dispatch/apps/${appId}/`);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("does not send an embed token to a sibling app on the same origin", async () => {
|
|
223
|
+
const appId = "customer-ticket-triage";
|
|
224
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat.mockResolvedValue({
|
|
225
|
+
startUrl: `${WORKSPACE_ORIGIN}/dispatch/_agent-native/embed/start?ticket=ticket-1`,
|
|
226
|
+
appBaseUrl: `${WORKSPACE_ORIGIN}/dispatch/apps/${appId}`,
|
|
227
|
+
});
|
|
228
|
+
const fetchImpl = stubFetch(
|
|
229
|
+
() => new Response("unexpected", { status: 200 }),
|
|
230
|
+
{
|
|
231
|
+
redirectLocation: (token) =>
|
|
232
|
+
`/dispatch/apps/other-app/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
236
|
+
|
|
237
|
+
const response = await handler(
|
|
238
|
+
proxyEvent(`/_agent-native/workspace-app-chat/${appId}`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(response.status).toBe(502);
|
|
244
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
246
|
+
"outside its app mount",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("rejects encoded path segments before forwarding an embed token", async () => {
|
|
251
|
+
const appId = "customer-ticket-triage";
|
|
252
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat.mockResolvedValue({
|
|
253
|
+
startUrl: `${WORKSPACE_ORIGIN}/dispatch/_agent-native/embed/start?ticket=ticket-1`,
|
|
254
|
+
appBaseUrl: `${WORKSPACE_ORIGIN}/dispatch/apps/${appId}`,
|
|
255
|
+
});
|
|
256
|
+
const fetchImpl = stubFetch(
|
|
257
|
+
() => new Response("unexpected", { status: 200 }),
|
|
258
|
+
{
|
|
259
|
+
redirectLocation: () =>
|
|
260
|
+
`/dispatch/apps/${appId}%2F..%2Fother-app/?embedded=1&__an_embed_token=encoded`,
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
264
|
+
|
|
265
|
+
const response = await handler(
|
|
266
|
+
proxyEvent(`/_agent-native/workspace-app-chat/${appId}`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(response.status).toBe(502);
|
|
272
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
274
|
+
"outside its app mount",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("allows an encoded app mount when the redirect preserves that mount", async () => {
|
|
279
|
+
const appId = "customer-ticket-triage";
|
|
280
|
+
const appBaseUrl = `${WORKSPACE_ORIGIN}/dispatch/apps/customer%20ticket-triage`;
|
|
281
|
+
mocks.createWorkspaceSsoEmbedSessionForWorkspaceAppChat.mockResolvedValue({
|
|
282
|
+
startUrl: `${WORKSPACE_ORIGIN}/dispatch/_agent-native/embed/start?ticket=ticket-1`,
|
|
283
|
+
appBaseUrl,
|
|
284
|
+
});
|
|
285
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }), {
|
|
286
|
+
redirectLocation: (token) =>
|
|
287
|
+
`/dispatch/apps/customer%20ticket-triage/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
|
|
288
|
+
});
|
|
289
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
290
|
+
|
|
291
|
+
const response = await handler(
|
|
292
|
+
proxyEvent(`/_agent-native/workspace-app-chat/${appId}`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(response.status).toBe(200);
|
|
298
|
+
const chatCall = fetchImpl.mock.calls[1];
|
|
299
|
+
expect(chatCall[0]).toBe(`${appBaseUrl}/_agent-native/agent-chat`);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("does not send an embed token to a cross-origin exchange redirect", async () => {
|
|
303
|
+
const fetchImpl = stubFetch(
|
|
304
|
+
() => new Response("unexpected", { status: 200 }),
|
|
305
|
+
{
|
|
306
|
+
redirectLocation: (token) =>
|
|
307
|
+
`https://attacker.example/?__an_embed_token=${encodeURIComponent(token)}`,
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
311
|
+
|
|
312
|
+
const response = await handler(
|
|
313
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
314
|
+
method: "POST",
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(response.status).toBe(502);
|
|
319
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
321
|
+
"untrusted origin",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("preserves a configured request origin for both embed-session signing paths", async () => {
|
|
326
|
+
const requestOrigin = "https://dispatch.agent-native.com";
|
|
327
|
+
mocks.createWorkspaceSsoEmbedSession.mockImplementation(async () => {
|
|
328
|
+
expect(getRequestContext()?.requestOrigin).toBe(requestOrigin);
|
|
329
|
+
throw new Error("Workspace sign-in is not configured for this app.");
|
|
330
|
+
});
|
|
331
|
+
mocks.createGrantedDispatchMcpEmbedSession.mockImplementation(async () => {
|
|
332
|
+
expect(getRequestContext()?.requestOrigin).toBe(requestOrigin);
|
|
333
|
+
return { startUrl: START_URL, app: "analytics" };
|
|
334
|
+
});
|
|
335
|
+
const handler = createWorkspaceAppChatProxyHandler({
|
|
336
|
+
fetchImpl: stubFetch(() => new Response("ok", { status: 200 })),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const response = await handler(
|
|
340
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics"),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(response.status).toBe(200);
|
|
344
|
+
expect(mocks.getForwardedRequestOrigin).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(mocks.createGrantedDispatchMcpEmbedSession).toHaveBeenCalledTimes(1);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("accepts a request origin from the configured runtime URL", async () => {
|
|
349
|
+
const runtimeOrigin = "https://workspace.agent-native.com";
|
|
350
|
+
mocks.getForwardedRequestOrigin.mockReturnValue(runtimeOrigin);
|
|
351
|
+
const observedOrigins: string[] = [];
|
|
352
|
+
mocks.createWorkspaceSsoEmbedSession.mockImplementation(async () => {
|
|
353
|
+
observedOrigins.push(getRequestContext()?.requestOrigin ?? "");
|
|
354
|
+
return { startUrl: START_URL, app: "analytics" };
|
|
355
|
+
});
|
|
356
|
+
const handler = createWorkspaceAppChatProxyHandler({
|
|
357
|
+
fetchImpl: stubFetch(() => new Response("ok", { status: 200 })),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const response = await handler(
|
|
361
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics"),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
expect(response.status).toBe(200);
|
|
365
|
+
expect(observedOrigins).toEqual([runtimeOrigin]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("ignores untrusted forwarded origins for context and session caching", async () => {
|
|
369
|
+
const requestOrigins = [
|
|
370
|
+
"https://attacker-one.example",
|
|
371
|
+
"https://attacker-two.example",
|
|
372
|
+
];
|
|
373
|
+
const observedOrigins: string[] = [];
|
|
374
|
+
mocks.getForwardedRequestOrigin
|
|
375
|
+
.mockReturnValueOnce(requestOrigins[0])
|
|
376
|
+
.mockReturnValueOnce(requestOrigins[1]);
|
|
377
|
+
mocks.createWorkspaceSsoEmbedSession.mockImplementation(async () => {
|
|
378
|
+
observedOrigins.push(getRequestContext()?.requestOrigin ?? "");
|
|
379
|
+
return { startUrl: START_URL, app: "analytics" };
|
|
380
|
+
});
|
|
381
|
+
const handler = createWorkspaceAppChatProxyHandler({
|
|
382
|
+
fetchImpl: stubFetch(() => new Response("ok", { status: 200 })),
|
|
383
|
+
});
|
|
384
|
+
const send = () =>
|
|
385
|
+
handler(proxyEvent("/_agent-native/workspace-app-chat/analytics"));
|
|
386
|
+
|
|
387
|
+
await send();
|
|
388
|
+
await send();
|
|
389
|
+
|
|
390
|
+
expect(observedOrigins).toEqual(["https://dispatch.agent-native.com"]);
|
|
391
|
+
expect(mocks.createWorkspaceSsoEmbedSession).toHaveBeenCalledTimes(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("uses a valid runtime origin when the configured production URL is invalid", async () => {
|
|
395
|
+
mocks.getAppProductionUrl.mockReturnValue("not-a-url");
|
|
396
|
+
mocks.getForwardedRequestOrigin.mockReturnValue("https://attacker.example");
|
|
397
|
+
const observedOrigins: string[] = [];
|
|
398
|
+
mocks.createWorkspaceSsoEmbedSession.mockImplementation(async () => {
|
|
399
|
+
observedOrigins.push(getRequestContext()?.requestOrigin ?? "");
|
|
400
|
+
return { startUrl: START_URL, app: "analytics" };
|
|
401
|
+
});
|
|
402
|
+
const handler = createWorkspaceAppChatProxyHandler({
|
|
403
|
+
fetchImpl: stubFetch(() => new Response("ok", { status: 200 })),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const response = await handler(
|
|
407
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics"),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(response.status).toBe(200);
|
|
411
|
+
expect(observedOrigins).toEqual(["https://workspace.agent-native.com"]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("returns a JSON error when no trusted origin is configured", async () => {
|
|
415
|
+
mocks.getAppProductionUrl.mockReturnValue("not-a-url");
|
|
416
|
+
mocks.resolveAppRuntimeUrl.mockReturnValue(undefined);
|
|
417
|
+
mocks.getForwardedRequestOrigin.mockReturnValue("https://attacker.example");
|
|
418
|
+
const handler = createWorkspaceAppChatProxyHandler({
|
|
419
|
+
fetchImpl: stubFetch(() => new Response("ok", { status: 200 })),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const response = await handler(
|
|
423
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics"),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
expect(response.status).toBe(502);
|
|
427
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
428
|
+
error: expect.stringContaining(
|
|
429
|
+
"Configured application origin is invalid.",
|
|
430
|
+
),
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
130
434
|
it("forwards agent-chat sub-paths and query strings unchanged", async () => {
|
|
131
435
|
const fetchImpl = stubFetch(() => new Response("[]", { status: 200 }));
|
|
132
436
|
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { getOrgContext } from "@agent-native/core/org";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getAppProductionUrl,
|
|
4
|
+
getForwardedRequestOrigin,
|
|
5
|
+
getSession,
|
|
6
|
+
resolveAppRuntimeUrl,
|
|
7
|
+
runWithRequestContext,
|
|
8
|
+
} from "@agent-native/core/server";
|
|
3
9
|
import {
|
|
4
10
|
EMBED_START_PATH,
|
|
5
11
|
EMBED_TOKEN_QUERY_PARAM,
|
|
@@ -7,8 +13,8 @@ import {
|
|
|
7
13
|
|
|
8
14
|
import { parseWorkspaceAppChatProxyPath } from "../../shared/workspace-app-chat.js";
|
|
9
15
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat,
|
|
17
|
+
createWorkspaceSsoEmbedSessionForWorkspaceAppChat,
|
|
12
18
|
} from "./mcp-gateway.js";
|
|
13
19
|
|
|
14
20
|
const TARGET_CHAT_PATH = "/_agent-native/agent-chat";
|
|
@@ -62,12 +68,75 @@ export interface WorkspaceAppChatSession {
|
|
|
62
68
|
|
|
63
69
|
const sessionCache = new Map<string, Promise<WorkspaceAppChatSession>>();
|
|
64
70
|
|
|
71
|
+
interface WorkspaceAppChatProxyEvent {
|
|
72
|
+
url: URL;
|
|
73
|
+
req: Request;
|
|
74
|
+
context?: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
function sessionCacheKey(input: {
|
|
66
78
|
appId: string;
|
|
67
79
|
userEmail: string;
|
|
68
80
|
orgId: string | undefined;
|
|
81
|
+
requestOrigin: string;
|
|
69
82
|
}): string {
|
|
70
|
-
return [
|
|
83
|
+
return [
|
|
84
|
+
input.userEmail,
|
|
85
|
+
input.orgId ?? "",
|
|
86
|
+
input.requestOrigin,
|
|
87
|
+
input.appId,
|
|
88
|
+
].join("\u0000");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type HttpOriginResult =
|
|
92
|
+
| { kind: "absent" }
|
|
93
|
+
| { kind: "invalid"; cause: unknown }
|
|
94
|
+
| { kind: "valid"; origin: string };
|
|
95
|
+
|
|
96
|
+
function httpOrigin(value: string | undefined): HttpOriginResult {
|
|
97
|
+
if (value === undefined) return { kind: "absent" };
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(value);
|
|
100
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
101
|
+
return {
|
|
102
|
+
kind: "invalid",
|
|
103
|
+
cause: new Error("Application origin must use HTTP or HTTPS."),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { kind: "valid", origin: url.origin };
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
return { kind: "invalid", cause };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trustedWorkspaceRequestOrigin(
|
|
113
|
+
event: WorkspaceAppChatProxyEvent,
|
|
114
|
+
): string {
|
|
115
|
+
const configured = httpOrigin(getAppProductionUrl());
|
|
116
|
+
const runtime = httpOrigin(resolveAppRuntimeUrl());
|
|
117
|
+
const request = httpOrigin(
|
|
118
|
+
getForwardedRequestOrigin(
|
|
119
|
+
event as unknown as Parameters<typeof getForwardedRequestOrigin>[0],
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
const allowedOrigins = new Set(
|
|
123
|
+
[configured, runtime]
|
|
124
|
+
.filter(
|
|
125
|
+
(result): result is Extract<HttpOriginResult, { kind: "valid" }> =>
|
|
126
|
+
result.kind === "valid",
|
|
127
|
+
)
|
|
128
|
+
.map((result) => result.origin),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (request.kind === "valid" && allowedOrigins.has(request.origin)) {
|
|
132
|
+
return request.origin;
|
|
133
|
+
}
|
|
134
|
+
if (configured.kind === "valid") return configured.origin;
|
|
135
|
+
if (runtime.kind === "valid") return runtime.origin;
|
|
136
|
+
if (configured.kind === "invalid" || runtime.kind === "invalid") {
|
|
137
|
+
throw new Error("Configured application origin is invalid.");
|
|
138
|
+
}
|
|
139
|
+
throw new Error("No trusted application origin is configured for app chat.");
|
|
71
140
|
}
|
|
72
141
|
|
|
73
142
|
function errorMessage(error: unknown): string {
|
|
@@ -99,13 +168,16 @@ export function embedSessionTokenExpiry(token: string): number {
|
|
|
99
168
|
return exp * 1000;
|
|
100
169
|
}
|
|
101
170
|
|
|
102
|
-
async function mintEmbedStartUrl(
|
|
103
|
-
|
|
171
|
+
async function mintEmbedStartUrl(
|
|
172
|
+
appId: string,
|
|
173
|
+
): Promise<{ startUrl: string; appBaseUrl: string }> {
|
|
104
174
|
try {
|
|
105
|
-
return
|
|
175
|
+
return await createWorkspaceSsoEmbedSessionForWorkspaceAppChat(appId);
|
|
106
176
|
} catch (ssoError) {
|
|
107
177
|
try {
|
|
108
|
-
return
|
|
178
|
+
return await createGrantedDispatchMcpEmbedSessionForWorkspaceAppChat(
|
|
179
|
+
appId,
|
|
180
|
+
);
|
|
109
181
|
} catch (grantError) {
|
|
110
182
|
throw new Error(
|
|
111
183
|
`Could not create an app session for "${appId}". ` +
|
|
@@ -118,6 +190,7 @@ async function mintEmbedStartUrl(appId: string): Promise<string> {
|
|
|
118
190
|
|
|
119
191
|
export async function exchangeEmbedStartUrl(
|
|
120
192
|
startUrl: string,
|
|
193
|
+
expectedAppBaseUrl: string,
|
|
121
194
|
fetchImpl: typeof fetch = fetch,
|
|
122
195
|
): Promise<WorkspaceAppChatSession> {
|
|
123
196
|
const start = new URL(startUrl);
|
|
@@ -125,8 +198,6 @@ export async function exchangeEmbedStartUrl(
|
|
|
125
198
|
if (startIndex < 0) {
|
|
126
199
|
throw new Error("App session start URL is not an embed start route.");
|
|
127
200
|
}
|
|
128
|
-
const appBaseUrl = `${start.origin}${start.pathname.slice(0, startIndex)}`;
|
|
129
|
-
|
|
130
201
|
const response = await fetchImpl(start.toString(), { redirect: "manual" });
|
|
131
202
|
await response.body?.cancel().catch(() => undefined);
|
|
132
203
|
const location = response.headers.get("location");
|
|
@@ -137,22 +208,48 @@ export async function exchangeEmbedStartUrl(
|
|
|
137
208
|
}
|
|
138
209
|
|
|
139
210
|
const resolved = new URL(location, start);
|
|
211
|
+
if (resolved.origin !== start.origin) {
|
|
212
|
+
throw new Error("App session exchange redirected to an untrusted origin.");
|
|
213
|
+
}
|
|
214
|
+
const expectedApp = new URL(expectedAppBaseUrl);
|
|
215
|
+
if (
|
|
216
|
+
expectedApp.origin !== start.origin ||
|
|
217
|
+
!isWithinAppMount(expectedApp.pathname, resolved.pathname)
|
|
218
|
+
) {
|
|
219
|
+
throw new Error("App session exchange redirected outside its app mount.");
|
|
220
|
+
}
|
|
140
221
|
const token = resolved.searchParams.get(EMBED_TOKEN_QUERY_PARAM);
|
|
141
222
|
if (!token) {
|
|
142
223
|
throw new Error("App session exchange returned no embed token.");
|
|
143
224
|
}
|
|
144
225
|
return {
|
|
145
|
-
appBaseUrl
|
|
226
|
+
appBaseUrl: `${expectedApp.origin}${expectedApp.pathname.replace(/\/+$/, "")}`,
|
|
146
227
|
token,
|
|
147
228
|
embedTarget: resolved.pathname,
|
|
148
229
|
expiresAt: embedSessionTokenExpiry(token),
|
|
149
230
|
};
|
|
150
231
|
}
|
|
151
232
|
|
|
233
|
+
function appPathSegments(pathname: string): string[] | null {
|
|
234
|
+
return pathname.split("/").filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isWithinAppMount(mountPath: string, targetPath: string): boolean {
|
|
238
|
+
const mount = appPathSegments(mountPath);
|
|
239
|
+
const target = appPathSegments(targetPath);
|
|
240
|
+
return (
|
|
241
|
+
mount !== null &&
|
|
242
|
+
target !== null &&
|
|
243
|
+
mount.length <= target.length &&
|
|
244
|
+
mount.every((segment, index) => target[index] === segment)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
152
248
|
async function resolveWorkspaceAppChatSession(input: {
|
|
153
249
|
appId: string;
|
|
154
250
|
userEmail: string;
|
|
155
251
|
orgId: string | undefined;
|
|
252
|
+
requestOrigin: string;
|
|
156
253
|
fetchImpl: typeof fetch;
|
|
157
254
|
}): Promise<WorkspaceAppChatSession> {
|
|
158
255
|
const key = sessionCacheKey(input);
|
|
@@ -176,12 +273,16 @@ async function resolveWorkspaceAppChatSession(input: {
|
|
|
176
273
|
{
|
|
177
274
|
userEmail: input.userEmail,
|
|
178
275
|
...(input.orgId ? { orgId: input.orgId } : {}),
|
|
276
|
+
requestOrigin: input.requestOrigin,
|
|
179
277
|
},
|
|
180
|
-
async () =>
|
|
181
|
-
|
|
182
|
-
|
|
278
|
+
async () => {
|
|
279
|
+
const embedSession = await mintEmbedStartUrl(input.appId);
|
|
280
|
+
return exchangeEmbedStartUrl(
|
|
281
|
+
embedSession.startUrl,
|
|
282
|
+
embedSession.appBaseUrl,
|
|
183
283
|
input.fetchImpl,
|
|
184
|
-
)
|
|
284
|
+
);
|
|
285
|
+
},
|
|
185
286
|
),
|
|
186
287
|
);
|
|
187
288
|
sessionCache.set(key, pending);
|
|
@@ -225,22 +326,15 @@ function proxyErrorResponse(status: number, message: string): Response {
|
|
|
225
326
|
});
|
|
226
327
|
}
|
|
227
328
|
|
|
228
|
-
interface WorkspaceAppChatProxyEvent {
|
|
229
|
-
url: URL;
|
|
230
|
-
req: Request;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
329
|
export function createWorkspaceAppChatProxyHandler(
|
|
234
330
|
options: { fetchImpl?: typeof fetch } = {},
|
|
235
331
|
) {
|
|
236
332
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
237
333
|
return async (event: WorkspaceAppChatProxyEvent): Promise<Response> => {
|
|
238
334
|
const url = event.url;
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
).context?._mountedPathname;
|
|
335
|
+
const rawMountedPathname = event.context?._mountedPathname;
|
|
336
|
+
const mountedPathname =
|
|
337
|
+
typeof rawMountedPathname === "string" ? rawMountedPathname : undefined;
|
|
244
338
|
const parsed = parseWorkspaceAppChatProxyPath(
|
|
245
339
|
mountedPathname ?? url.pathname,
|
|
246
340
|
);
|
|
@@ -263,12 +357,15 @@ export function createWorkspaceAppChatProxyHandler(
|
|
|
263
357
|
return proxyErrorResponse(401, "Sign in to Dispatch to use app chat.");
|
|
264
358
|
}
|
|
265
359
|
|
|
360
|
+
let requestOrigin: string;
|
|
266
361
|
let appSession: WorkspaceAppChatSession;
|
|
267
362
|
try {
|
|
363
|
+
requestOrigin = trustedWorkspaceRequestOrigin(event);
|
|
268
364
|
appSession = await resolveWorkspaceAppChatSession({
|
|
269
365
|
appId: parsed.appId,
|
|
270
366
|
userEmail: session.email,
|
|
271
367
|
orgId,
|
|
368
|
+
requestOrigin,
|
|
272
369
|
fetchImpl,
|
|
273
370
|
});
|
|
274
371
|
} catch (error) {
|
|
@@ -320,6 +417,7 @@ export function createWorkspaceAppChatProxyHandler(
|
|
|
320
417
|
appId: parsed.appId,
|
|
321
418
|
userEmail: session.email,
|
|
322
419
|
orgId,
|
|
420
|
+
requestOrigin,
|
|
323
421
|
}),
|
|
324
422
|
);
|
|
325
423
|
}
|