@agent-native/dispatch 0.40.40 → 0.40.41-beta-dispatch-embed-01a15dc9-20261027050558

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 (45) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/actions/create_embed_session.js +1 -0
  3. package/dist/actions/create_embed_session.js.map +1 -1
  4. package/dist/actions/delete-destination.d.ts +12 -12
  5. package/dist/actions/provider-api-register.d.ts +13 -13
  6. package/dist/components/layout/Layout.d.ts.map +1 -1
  7. package/dist/components/layout/Layout.js +8 -11
  8. package/dist/components/layout/Layout.js.map +1 -1
  9. package/dist/components/workspace-app-host.d.ts.map +1 -1
  10. package/dist/components/workspace-app-host.js +23 -10
  11. package/dist/components/workspace-app-host.js.map +1 -1
  12. package/dist/lib/workspace-apps.d.ts +2 -2
  13. package/dist/lib/workspace-apps.d.ts.map +1 -1
  14. package/dist/lib/workspace-apps.js +35 -5
  15. package/dist/lib/workspace-apps.js.map +1 -1
  16. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  17. package/dist/server/lib/app-creation-store.js +173 -48
  18. package/dist/server/lib/app-creation-store.js.map +1 -1
  19. package/dist/server/lib/community-apps.js +1 -1
  20. package/dist/server/lib/community-apps.js.map +1 -1
  21. package/dist/server/lib/mcp-gateway.d.ts +1 -0
  22. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  23. package/dist/server/lib/mcp-gateway.js +170 -38
  24. package/dist/server/lib/mcp-gateway.js.map +1 -1
  25. package/dist/server/lib/provider-api.d.ts +2 -2
  26. package/dist/shared/workspace-app-route.d.ts +2 -0
  27. package/dist/shared/workspace-app-route.d.ts.map +1 -0
  28. package/dist/shared/workspace-app-route.js +4 -0
  29. package/dist/shared/workspace-app-route.js.map +1 -0
  30. package/package.json +4 -4
  31. package/src/actions/create_embed_session.ts +1 -0
  32. package/src/actions/index.spec.ts +3 -0
  33. package/src/components/layout/Layout.spec.tsx +39 -9
  34. package/src/components/layout/Layout.tsx +19 -40
  35. package/src/components/workspace-app-host.spec.tsx +88 -1
  36. package/src/components/workspace-app-host.tsx +30 -9
  37. package/src/lib/workspace-apps.spec.ts +64 -5
  38. package/src/lib/workspace-apps.ts +36 -6
  39. package/src/routes/pages/apps.$appId.spec.tsx +21 -3
  40. package/src/server/lib/app-creation-store.spec.ts +502 -11
  41. package/src/server/lib/app-creation-store.ts +198 -50
  42. package/src/server/lib/community-apps.ts +1 -1
  43. package/src/server/lib/mcp-gateway.spec.ts +396 -9
  44. package/src/server/lib/mcp-gateway.ts +209 -45
  45. package/src/shared/workspace-app-route.ts +3 -0
@@ -3,12 +3,14 @@ import {
3
3
  canonicalA2AAudience,
4
4
  extractA2APersistedMutationReceipts,
5
5
  getGlobalA2ASecret,
6
+ signA2AOrgMemberDelegationToken,
6
7
  signA2AOrganizationToken,
7
8
  signA2AToken,
8
9
  stripA2APersistedArtifactMarkers,
9
10
  type A2APersistedMutationReceipt,
10
11
  type Task,
11
12
  } from "@agent-native/core/a2a";
13
+ import { getAppConfig } from "@agent-native/core/app-config";
12
14
  import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";
13
15
  import {
14
16
  buildMcpToolName,
@@ -19,6 +21,7 @@ import {
19
21
  buildDeepLink,
20
22
  buildEmbedStartPath,
21
23
  createEmbedSessionTicket,
24
+ getConfiguredAppBasePath,
22
25
  getRequestContext,
23
26
  getRequestOrgId,
24
27
  getRequestUserEmail,
@@ -28,6 +31,7 @@ import {
28
31
  getBuiltinAgents,
29
32
  type DiscoveredAgent,
30
33
  } from "@agent-native/core/server/agent-discovery";
34
+ import { EMBED_START_PATH } from "@agent-native/core/shared";
31
35
 
32
36
  import { DESKTOP_WORKSPACE_SSO_FLAG } from "../../shared/feature-flags.js";
33
37
  import {
@@ -79,6 +83,7 @@ export interface DispatchMcpAccessibleApp {
79
83
  description: string;
80
84
  url: string;
81
85
  homeUrl?: string;
86
+ mcpUrl?: string;
82
87
  color: string;
83
88
  granted: boolean;
84
89
  }
@@ -581,14 +586,73 @@ function appHomeBaseUrl(app: DispatchMcpAccessibleApp): string {
581
586
  return appBaseUrl(app);
582
587
  }
583
588
 
589
+ function workspaceGatewayMcpUrl(appUrl: string): string | undefined {
590
+ const configuredGateway = getAppConfig().workspace.gatewayUrl?.trim();
591
+ if (!configuredGateway) return undefined;
592
+
593
+ let configuredUrl: URL;
594
+ try {
595
+ configuredUrl = new URL(configuredGateway);
596
+ } catch {
597
+ throw new Error(
598
+ "WORKSPACE_GATEWAY_URL must be a valid absolute HTTP(S) URL.",
599
+ );
600
+ }
601
+ if (
602
+ configuredUrl.protocol !== "http:" &&
603
+ configuredUrl.protocol !== "https:"
604
+ ) {
605
+ throw new Error("WORKSPACE_GATEWAY_URL must use http or https.");
606
+ }
607
+
608
+ const gateway = new URL(projectEnvironmentUrl(configuredUrl.toString()));
609
+ if (gateway.protocol !== "http:" && gateway.protocol !== "https:") {
610
+ throw new Error("WORKSPACE_GATEWAY_URL must use http or https.");
611
+ }
612
+ const target = new URL(appUrl);
613
+ if (gateway.origin !== target.origin) return undefined;
614
+ const configuredBasePath = getConfiguredAppBasePath();
615
+ const gatewayPath = gateway.pathname.replace(/\/+$/, "");
616
+ const basePath =
617
+ configuredBasePath &&
618
+ gatewayPath !== configuredBasePath &&
619
+ !gatewayPath.startsWith(`${configuredBasePath}/`)
620
+ ? `${configuredBasePath}${gatewayPath}`
621
+ : gatewayPath;
622
+ gateway.pathname = `${basePath}/mcp`;
623
+ gateway.search = "";
624
+ gateway.hash = "";
625
+ return gateway.toString().replace(/\/$/, "");
626
+ }
627
+
628
+ function appMcpUrl(app: DispatchMcpAccessibleApp): string {
629
+ const explicit = app.mcpUrl?.trim();
630
+ return (
631
+ explicit?.replace(/\/+$/, "") ??
632
+ workspaceGatewayMcpUrl(app.url) ??
633
+ `${appHomeBaseUrl(app)}/mcp`
634
+ );
635
+ }
636
+
637
+ function appMcpBaseUrl(app: DispatchMcpAccessibleApp): string {
638
+ const url = new URL(appMcpUrl(app));
639
+ url.pathname = url.pathname.replace(/\/mcp\/?$/, "") || "/";
640
+ url.search = "";
641
+ url.hash = "";
642
+ return url.toString().replace(/\/$/, "");
643
+ }
644
+
584
645
  function resolveGrantedAppEmbedStartUrl(
585
646
  app: DispatchMcpAccessibleApp,
586
647
  startUrl: string,
587
648
  ): string {
588
649
  try {
589
- const baseUrl = appHomeBaseUrl(app);
650
+ const baseUrl = appMcpBaseUrl(app);
590
651
  const resolved = new URL(startUrl, `${baseUrl}/`);
591
- if (!appMatchesUrlPath(app, resolved)) {
652
+ const base = new URL(baseUrl);
653
+ const basePath = base.pathname.replace(/\/+$/, "");
654
+ const expectedPath = `${basePath}${EMBED_START_PATH}`;
655
+ if (resolved.origin !== base.origin || resolved.pathname !== expectedPath) {
592
656
  throw new Error(
593
657
  "Target app returned an embed start URL outside the granted app.",
594
658
  );
@@ -763,11 +827,13 @@ async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
763
827
  }
764
828
  for (const app of mountedApps) {
765
829
  if (app.isDispatch || !app.url) continue;
830
+ const mcpUrl = workspaceGatewayMcpUrl(app.url);
766
831
  candidatesById.set(app.id, {
767
832
  id: app.id,
768
833
  name: app.name,
769
834
  description: app.description,
770
835
  url: app.url,
836
+ ...(mcpUrl ? { mcpUrl } : {}),
771
837
  color: DISPATCH_COLOR,
772
838
  granted: true,
773
839
  });
@@ -1104,14 +1170,16 @@ function isRetryableTargetMcpError(error: unknown): boolean {
1104
1170
  }
1105
1171
 
1106
1172
  function targetMcpErrorStatus(error: unknown): number | undefined {
1173
+ const status = httpStatusFromError(error);
1174
+ if (status !== undefined) return status;
1107
1175
  const message =
1108
1176
  error instanceof Error
1109
1177
  ? error.message
1110
1178
  : typeof error === "string"
1111
1179
  ? error
1112
1180
  : safeJson(error);
1113
- const status = message.match(/\b([45]\d{2})\b/)?.[1];
1114
- return status ? Number(status) : undefined;
1181
+ const matchedStatus = message.match(/\b([45]\d{2})\b/)?.[1];
1182
+ return matchedStatus ? Number(matchedStatus) : undefined;
1115
1183
  }
1116
1184
 
1117
1185
  function targetMcpRequestDetails(input: {
@@ -1132,6 +1200,22 @@ function targetMcpConnectTimeout(deadline: number): number {
1132
1200
  return Math.max(1000, budget);
1133
1201
  }
1134
1202
 
1203
+ class TargetMcpAuthenticationRejectedError extends Error {
1204
+ readonly status?: number;
1205
+
1206
+ constructor(cause: unknown) {
1207
+ super("Target MCP authentication was rejected", { cause });
1208
+ this.name = "TargetMcpAuthenticationRejectedError";
1209
+ const status = httpStatusFromError(cause) ?? targetMcpErrorStatus(cause);
1210
+ if (status !== undefined) this.status = status;
1211
+ }
1212
+ }
1213
+
1214
+ function isTargetMcpAuthenticationRejected(error: unknown): boolean {
1215
+ const status = httpStatusFromError(error);
1216
+ return status === 401 || status === 403;
1217
+ }
1218
+
1135
1219
  function targetMcpRetryDelay(attempt: number): number {
1136
1220
  const base =
1137
1221
  TARGET_EMBED_SESSION_RETRY_BASE_MS * Math.pow(2, Math.max(0, attempt - 1));
@@ -1152,7 +1236,7 @@ async function callTargetCreateEmbedSession(input: {
1152
1236
  servers: {
1153
1237
  [serverId]: {
1154
1238
  type: "http",
1155
- url: `${appHomeBaseUrl(input.app)}/mcp`,
1239
+ url: appMcpUrl(input.app),
1156
1240
  headers: {
1157
1241
  Authorization: `Bearer ${input.token}`,
1158
1242
  },
@@ -1161,8 +1245,17 @@ async function callTargetCreateEmbedSession(input: {
1161
1245
  },
1162
1246
  { connectTimeoutMs: targetMcpConnectTimeout(deadline) },
1163
1247
  );
1248
+ let connected = false;
1164
1249
  try {
1165
1250
  await manager.start();
1251
+ const connectErrorStatus = manager.getConnectErrorStatus(serverId);
1252
+ if (connectErrorStatus === 401 || connectErrorStatus === 403) {
1253
+ const error = Object.assign(new Error(`HTTP ${connectErrorStatus}`), {
1254
+ status: connectErrorStatus,
1255
+ });
1256
+ throw new TargetMcpAuthenticationRejectedError(error);
1257
+ }
1258
+ connected = true;
1166
1259
  return await manager.callTool(
1167
1260
  buildMcpToolName(serverId, "create_embed_session"),
1168
1261
  {
@@ -1171,6 +1264,13 @@ async function callTargetCreateEmbedSession(input: {
1171
1264
  },
1172
1265
  );
1173
1266
  } catch (error) {
1267
+ if (
1268
+ !connected &&
1269
+ !(error instanceof TargetMcpAuthenticationRejectedError) &&
1270
+ isTargetMcpAuthenticationRejected(error)
1271
+ ) {
1272
+ throw new TargetMcpAuthenticationRejectedError(error);
1273
+ }
1174
1274
  if (
1175
1275
  attempt >= TARGET_EMBED_SESSION_ATTEMPTS ||
1176
1276
  !isRetryableTargetMcpError(error) ||
@@ -1187,23 +1287,60 @@ async function callTargetCreateEmbedSession(input: {
1187
1287
  }
1188
1288
  }
1189
1289
 
1190
- async function createTargetMcpUserToken(input: {
1290
+ type TargetMcpCredential = {
1291
+ strategy: "deployment-user" | "org-member-delegation";
1292
+ token: string;
1293
+ };
1294
+
1295
+ async function createTargetMcpUserCredentials(input: {
1191
1296
  ownerEmail: string;
1192
1297
  orgId?: string;
1193
1298
  target: DispatchMcpAccessibleApp;
1194
- }): Promise<string> {
1195
- if (!getGlobalA2ASecret()) {
1299
+ }): Promise<{
1300
+ credentials: TargetMcpCredential[];
1301
+ loadOrgMemberCredential: () => Promise<TargetMcpCredential | undefined>;
1302
+ }> {
1303
+ const audience = canonicalA2AAudience(appMcpUrl(input.target));
1304
+ const orgDomain = input.orgId
1305
+ ? (await getOrgDomain(input.orgId))?.trim().toLowerCase() || undefined
1306
+ : undefined;
1307
+ const credentials: TargetMcpCredential[] = [];
1308
+ if (getGlobalA2ASecret()) {
1309
+ credentials.push({
1310
+ strategy: "deployment-user",
1311
+ token: await signA2AToken(input.ownerEmail, undefined, undefined, {
1312
+ expiresIn: "5m",
1313
+ audience,
1314
+ preferGlobalSecret: true,
1315
+ }),
1316
+ });
1317
+ }
1318
+
1319
+ const loadOrgMemberCredential = async () => {
1320
+ if (!input.orgId || !orgDomain) return undefined;
1321
+ const orgSecret = await getOrgA2ASecret(input.orgId);
1322
+ if (!orgSecret) return undefined;
1323
+ return {
1324
+ strategy: "org-member-delegation" as const,
1325
+ token: await signA2AOrgMemberDelegationToken(
1326
+ input.ownerEmail,
1327
+ orgDomain,
1328
+ orgSecret,
1329
+ audience,
1330
+ ),
1331
+ };
1332
+ };
1333
+
1334
+ if (credentials.length === 0) {
1335
+ const orgMemberCredential = await loadOrgMemberCredential();
1336
+ if (orgMemberCredential) credentials.push(orgMemberCredential);
1337
+ }
1338
+ if (credentials.length === 0) {
1196
1339
  throw new Error(
1197
- "Cross-app embed sessions require A2A_SECRET to preserve the authenticated user identity; an organization-only A2A credential cannot create a user-owned session.",
1340
+ "Cross-app embed sessions require a deployment A2A secret or an organization secret with member delegation enabled on the target.",
1198
1341
  );
1199
1342
  }
1200
- const audience = canonicalA2AAudience(`${appHomeBaseUrl(input.target)}/mcp`);
1201
- return signA2AToken(input.ownerEmail, undefined, undefined, {
1202
- expiresIn: "5m",
1203
- audience,
1204
- preferGlobalSecret: true,
1205
- ...(input.orgId ? { extraClaims: { org_id: input.orgId } } : {}),
1206
- });
1343
+ return { credentials, loadOrgMemberCredential };
1207
1344
  }
1208
1345
 
1209
1346
  async function resolveEmbedTarget(
@@ -1363,45 +1500,72 @@ async function createEmbedSessionForResolvedApp(input: {
1363
1500
  });
1364
1501
  }
1365
1502
 
1366
- const targetToken = await createTargetMcpUserToken({
1503
+ const targetCredentials = await createTargetMcpUserCredentials({
1367
1504
  ownerEmail,
1368
- orgId,
1505
+ ...(orgId !== undefined ? { orgId } : {}),
1369
1506
  target: target.app,
1370
1507
  });
1371
1508
  const targetDetails = targetMcpRequestDetails({
1372
1509
  app: target.app,
1373
1510
  url: target.url,
1374
1511
  });
1375
- let parsed: {
1376
- startUrl?: string;
1377
- targetPath?: string;
1378
- expiresAt?: number;
1379
- };
1380
- console.info("[dispatch] workspace embed target request", {
1381
- ...targetDetails,
1382
- authStrategy: "user-identity",
1383
- });
1384
- try {
1385
- const result = await callTargetCreateEmbedSession({
1386
- app: target.app,
1387
- token: targetToken,
1388
- url: target.url,
1389
- chrome,
1390
- });
1391
- parsed = parseMcpToolTextResult(result) as {
1392
- startUrl?: string;
1393
- targetPath?: string;
1394
- expiresAt?: number;
1395
- };
1396
- } catch (error) {
1397
- console.warn("[dispatch] workspace embed target response", {
1512
+ let parsed:
1513
+ | {
1514
+ startUrl?: string;
1515
+ targetPath?: string;
1516
+ expiresAt?: number;
1517
+ }
1518
+ | undefined;
1519
+ const credentials = [...targetCredentials.credentials];
1520
+ for (const [index, credential] of credentials.entries()) {
1521
+ console.info("[dispatch] workspace embed target request", {
1398
1522
  ...targetDetails,
1399
- authStrategy: "user-identity",
1400
- status: targetMcpErrorStatus(error),
1401
- category: isRetryableTargetMcpError(error) ? "transient" : "permanent",
1523
+ authStrategy: credential.strategy,
1402
1524
  });
1403
- throw error;
1525
+ try {
1526
+ const result = await callTargetCreateEmbedSession({
1527
+ app: target.app,
1528
+ token: credential.token,
1529
+ url: target.url,
1530
+ ...(chrome !== undefined ? { chrome } : {}),
1531
+ });
1532
+ parsed = parseMcpToolTextResult(result) as {
1533
+ startUrl?: string;
1534
+ targetPath?: string;
1535
+ expiresAt?: number;
1536
+ };
1537
+ break;
1538
+ } catch (error) {
1539
+ if (
1540
+ error instanceof TargetMcpAuthenticationRejectedError &&
1541
+ credential.strategy === "deployment-user" &&
1542
+ index === credentials.length - 1
1543
+ ) {
1544
+ const orgMemberCredential =
1545
+ await targetCredentials.loadOrgMemberCredential();
1546
+ if (orgMemberCredential) credentials.push(orgMemberCredential);
1547
+ }
1548
+ if (
1549
+ error instanceof TargetMcpAuthenticationRejectedError &&
1550
+ index < credentials.length - 1
1551
+ ) {
1552
+ console.warn("[dispatch] workspace embed auth candidate rejected", {
1553
+ ...targetDetails,
1554
+ authStrategy: credential.strategy,
1555
+ status: targetMcpErrorStatus(error),
1556
+ });
1557
+ continue;
1558
+ }
1559
+ console.warn("[dispatch] workspace embed target response", {
1560
+ ...targetDetails,
1561
+ authStrategy: credential.strategy,
1562
+ status: targetMcpErrorStatus(error),
1563
+ category: isRetryableTargetMcpError(error) ? "transient" : "permanent",
1564
+ });
1565
+ throw error;
1566
+ }
1404
1567
  }
1568
+ if (!parsed) throw new Error("Target app did not return an embed session.");
1405
1569
  if (!parsed.startUrl) {
1406
1570
  throw new Error("Target app did not return an embed start URL.");
1407
1571
  }
@@ -0,0 +1,3 @@
1
+ export function workspaceAppRoute(appId: string): string {
2
+ return `/apps/${encodeURIComponent(appId)}`;
3
+ }