@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +28 -10
  2. package/dist/actions/provider-api-register.d.ts +11 -11
  3. package/dist/actions/upsert-destination.d.ts +12 -12
  4. package/dist/components/action-query-error.d.ts.map +1 -1
  5. package/dist/components/action-query-error.js +8 -0
  6. package/dist/components/action-query-error.js.map +1 -1
  7. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  8. package/dist/server/lib/app-creation-store.js +48 -3
  9. package/dist/server/lib/app-creation-store.js.map +1 -1
  10. package/dist/server/lib/mcp-gateway.d.ts +8 -0
  11. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  12. package/dist/server/lib/mcp-gateway.js +71 -11
  13. package/dist/server/lib/mcp-gateway.js.map +1 -1
  14. package/dist/server/lib/workspace-app-chat-proxy.d.ts +6 -5
  15. package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -1
  16. package/dist/server/lib/workspace-app-chat-proxy.js +78 -11
  17. package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -1
  18. package/dist/server/plugins/agent-chat.js +1 -0
  19. package/dist/server/plugins/agent-chat.js.map +1 -1
  20. package/package.json +3 -3
  21. package/src/components/action-query-error.spec.tsx +37 -0
  22. package/src/components/action-query-error.tsx +11 -0
  23. package/src/server/lib/app-creation-store.spec.ts +101 -1
  24. package/src/server/lib/app-creation-store.ts +65 -4
  25. package/src/server/lib/mcp-gateway.spec.ts +135 -1
  26. package/src/server/lib/mcp-gateway.ts +122 -22
  27. package/src/server/lib/workspace-app-chat-proxy.spec.ts +309 -5
  28. package/src/server/lib/workspace-app-chat-proxy.ts +123 -25
  29. package/src/server/plugins/agent-chat.spec.ts +1 -0
  30. 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 { ...actual, getSession: mocks.getSession };
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: { token?: string; startStatus?: number } = {},
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: `/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
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 { getSession, runWithRequestContext } from "@agent-native/core/server";
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
- createGrantedDispatchMcpEmbedSession,
11
- createWorkspaceSsoEmbedSession,
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 [input.userEmail, input.orgId ?? "", input.appId].join("\u0000");
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(appId: string): Promise<string> {
103
- const input = { app: appId, path: "/", chrome: "minimal" } as const;
171
+ async function mintEmbedStartUrl(
172
+ appId: string,
173
+ ): Promise<{ startUrl: string; appBaseUrl: string }> {
104
174
  try {
105
- return (await createWorkspaceSsoEmbedSession(input)).startUrl;
175
+ return await createWorkspaceSsoEmbedSessionForWorkspaceAppChat(appId);
106
176
  } catch (ssoError) {
107
177
  try {
108
- return (await createGrantedDispatchMcpEmbedSession(input)).startUrl;
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
- exchangeEmbedStartUrl(
182
- await mintEmbedStartUrl(input.appId),
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 mountedPathname = (
240
- event as WorkspaceAppChatProxyEvent & {
241
- context?: { _mountedPathname?: string };
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
  }
@@ -30,6 +30,7 @@ describe("Dispatch agent chat plugin", () => {
30
30
  "list-dispatch-usage-metrics",
31
31
  ]),
32
32
  mcp: {
33
+ appApprovalPage: true,
33
34
  connectorCatalog: expect.arrayContaining([
34
35
  "start-workspace-app-creation",
35
36
  "list-dispatch-usage-metrics",
@@ -44,6 +44,7 @@ export default createAgentChatPlugin({
44
44
  durableBackgroundRuns: true,
45
45
  initialToolNames: INITIAL_TOOL_NAMES,
46
46
  mcp: {
47
+ appApprovalPage: true,
47
48
  connectorCatalog: [
48
49
  "resolve-integration-source-context",
49
50
  "start-workspace-app-creation",