@agent-native/dispatch 0.27.9 → 0.27.10

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.
@@ -626,17 +626,6 @@ function workspaceSsoOriginForApp(appId: string): string | null {
626
626
  return custom?.origin ?? null;
627
627
  }
628
628
 
629
- function workspaceSsoGatewayOrigin(): string | null {
630
- const configured = process.env.WORKSPACE_GATEWAY_URL?.trim();
631
- if (!configured) return null;
632
- try {
633
- return new URL(configured).origin;
634
- } catch {
635
- // coercion-ok: malformed deployment configuration is not a trusted origin.
636
- return null;
637
- }
638
- }
639
-
640
629
  function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
641
630
  const origin = safeAppOrigin(app);
642
631
  if (!origin) return false;
@@ -653,17 +642,8 @@ function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
653
642
  : false;
654
643
  }
655
644
 
656
- function sameWorkspaceApp(
657
- candidate: DispatchMcpAccessibleApp,
658
- mounted: WorkspaceAppSummary,
659
- ): boolean {
660
- if (candidate.id !== mounted.id || !mounted.url) return false;
661
- return candidate.url.replace(/\/+$/, "") === mounted.url.replace(/\/+$/, "");
662
- }
663
-
664
645
  async function isEligibleWorkspaceSsoApp(
665
646
  candidate: DispatchMcpAccessibleApp,
666
- mountedApps: WorkspaceAppSummary[],
667
647
  ): Promise<boolean> {
668
648
  const origin = safeAppOrigin(candidate);
669
649
  if (!origin) return false;
@@ -671,18 +651,7 @@ async function isEligibleWorkspaceSsoApp(
671
651
 
672
652
  const customOrigin = workspaceSsoOriginForApp(candidate.id);
673
653
  if (customOrigin === origin) return true;
674
-
675
- const dispatchOrigin = safeAppOrigin({
676
- id: DISPATCH_APP_ID,
677
- name: DISPATCH_NAME,
678
- description: DISPATCH_DESCRIPTION,
679
- url: dispatchSelfBaseUrl(),
680
- color: DISPATCH_COLOR,
681
- granted: true,
682
- });
683
- const trustedMountedOrigin = workspaceSsoGatewayOrigin() ?? dispatchOrigin;
684
- if (origin !== trustedMountedOrigin) return false;
685
- return mountedApps.some((mounted) => sameWorkspaceApp(candidate, mounted));
654
+ return false;
686
655
  }
687
656
 
688
657
  async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
@@ -702,8 +671,8 @@ async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
702
671
  });
703
672
  }
704
673
  // The hosted Dispatch database is separate from the shared Workspace
705
- // database. Overlay the live mounted registry so custom apps are eligible
706
- // without copying a stale app list into Dispatch configuration.
674
+ // database. Overlay the live mounted registry so custom apps remain
675
+ // discoverable without copying a stale app list into Dispatch configuration.
707
676
  for (const app of mountedApps) {
708
677
  if (app.isDispatch || !app.url) continue;
709
678
  candidatesById.set(app.id, {
@@ -718,7 +687,7 @@ async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
718
687
  const candidates = [...candidatesById.values()];
719
688
  const eligible = [] as DispatchMcpAccessibleApp[];
720
689
  for (const candidate of candidates) {
721
- if (await isEligibleWorkspaceSsoApp(candidate, mountedApps)) {
690
+ if (await isEligibleWorkspaceSsoApp(candidate)) {
722
691
  eligible.push(candidate);
723
692
  }
724
693
  }
@@ -975,6 +944,46 @@ function isRetryableTargetMcpError(error: unknown): boolean {
975
944
  );
976
945
  }
977
946
 
947
+ function isTargetMcpAuthError(error: unknown): boolean {
948
+ const message =
949
+ error instanceof Error
950
+ ? error.message
951
+ : typeof error === "string"
952
+ ? error
953
+ : String(error ?? "");
954
+ return /\b401\b|\b403\b|unauthorized|forbidden|invalid(?: or expired)? (?:a2a )?token|authentication required/i.test(
955
+ message,
956
+ );
957
+ }
958
+
959
+ function targetMcpErrorStatus(error: unknown): number | undefined {
960
+ const message =
961
+ error instanceof Error
962
+ ? error.message
963
+ : typeof error === "string"
964
+ ? error
965
+ : String(error ?? "");
966
+ const status = message.match(/\b([45]\d{2})\b/)?.[1];
967
+ return status ? Number(status) : undefined;
968
+ }
969
+
970
+ type TargetMcpTokenAttempt = {
971
+ token: string;
972
+ strategy: "org" | "global";
973
+ };
974
+
975
+ function targetMcpRequestDetails(input: {
976
+ app: DispatchMcpAccessibleApp;
977
+ url: string;
978
+ }): { app: string; targetOrigin: string; targetPath: string } {
979
+ const targetUrl = new URL(input.url);
980
+ return {
981
+ app: input.app.id,
982
+ targetOrigin: targetUrl.origin,
983
+ targetPath: targetUrl.pathname,
984
+ };
985
+ }
986
+
978
987
  function targetMcpRetryDelay(attempt: number): number {
979
988
  const base =
980
989
  TARGET_EMBED_SESSION_RETRY_BASE_MS * Math.pow(2, Math.max(0, attempt - 1));
@@ -1025,6 +1034,58 @@ async function callTargetCreateEmbedSession(input: {
1025
1034
  }
1026
1035
  }
1027
1036
 
1037
+ async function createTargetMcpTokenAttempts(input: {
1038
+ ownerEmail: string;
1039
+ orgDomain?: string;
1040
+ orgSecret?: string;
1041
+ target: DispatchMcpAccessibleApp;
1042
+ }): Promise<TargetMcpTokenAttempt[]> {
1043
+ const attempts: TargetMcpTokenAttempt[] = [];
1044
+ const addAttempt = async (tokenInput: {
1045
+ strategy: TargetMcpTokenAttempt["strategy"];
1046
+ secret?: string;
1047
+ preferGlobalSecret: boolean;
1048
+ }) => {
1049
+ const token = await signA2AToken(
1050
+ input.ownerEmail,
1051
+ input.orgDomain,
1052
+ tokenInput.secret,
1053
+ {
1054
+ expiresIn: "5m",
1055
+ audience: canonicalA2AAudience(appBaseUrl(input.target)),
1056
+ preferGlobalSecret: tokenInput.preferGlobalSecret,
1057
+ },
1058
+ );
1059
+ if (!attempts.some((attempt) => attempt.token === token)) {
1060
+ attempts.push({ token, strategy: tokenInput.strategy });
1061
+ }
1062
+ };
1063
+
1064
+ if (input.orgDomain && input.orgSecret) {
1065
+ await addAttempt({
1066
+ strategy: "org",
1067
+ secret: input.orgSecret,
1068
+ preferGlobalSecret: false,
1069
+ });
1070
+ // A target app may not have the org secret synced yet. The shared secret
1071
+ // is a bounded compatibility fallback, used only after the target rejects
1072
+ // the org-signed request and never after a non-authentication failure.
1073
+ if (process.env.A2A_SECRET?.trim()) {
1074
+ await addAttempt({
1075
+ strategy: "global",
1076
+ preferGlobalSecret: true,
1077
+ });
1078
+ }
1079
+ } else {
1080
+ await addAttempt({
1081
+ strategy: "global",
1082
+ preferGlobalSecret: true,
1083
+ });
1084
+ }
1085
+
1086
+ return attempts;
1087
+ }
1088
+
1028
1089
  async function resolveEmbedTarget(
1029
1090
  input: {
1030
1091
  app?: string;
@@ -1192,43 +1253,93 @@ async function createEmbedSessionForResolvedApp(input: {
1192
1253
  typeof orgSecret === "string" && orgSecret.trim().length > 0;
1193
1254
  const usableOrgDomain =
1194
1255
  typeof orgDomain === "string" && orgDomain.trim().length > 0;
1195
- const useOrgSigning = usableOrgDomain && usableOrgSecret;
1196
1256
  const signedOrgDomain = usableOrgDomain ? orgDomain.trim() : undefined;
1197
- const token = await signA2AToken(
1257
+ const tokenAttempts = await createTargetMcpTokenAttempts({
1198
1258
  ownerEmail,
1199
- signedOrgDomain,
1200
- useOrgSigning ? orgSecret.trim() : undefined,
1201
- {
1202
- expiresIn: "5m",
1203
- audience: canonicalA2AAudience(appBaseUrl(target.app)),
1204
- // Prefer the synced org A2A secret when present because first-party
1205
- // production apps do not have to share the same deployment env secret.
1206
- // Fall back to the global A2A_SECRET for orgs that have not synced yet.
1207
- preferGlobalSecret: !useOrgSigning,
1208
- },
1209
- );
1210
-
1211
- const result = await callTargetCreateEmbedSession({
1259
+ orgDomain: signedOrgDomain,
1260
+ orgSecret: usableOrgSecret ? orgSecret.trim() : undefined,
1261
+ target: target.app,
1262
+ });
1263
+ const targetDetails = targetMcpRequestDetails({
1212
1264
  app: target.app,
1213
- token,
1214
1265
  url: target.url,
1215
- chrome,
1216
1266
  });
1217
- const parsed = parseMcpToolTextResult(result) as {
1267
+ let parsed: {
1218
1268
  startUrl?: string;
1219
1269
  targetPath?: string;
1220
1270
  expiresAt?: number;
1221
- };
1271
+ } | null = null;
1272
+ let lastError: unknown;
1273
+ for (
1274
+ let attemptIndex = 0;
1275
+ attemptIndex < tokenAttempts.length;
1276
+ attemptIndex++
1277
+ ) {
1278
+ const tokenAttempt = tokenAttempts[attemptIndex];
1279
+ console.info("[dispatch] workspace embed target request", {
1280
+ ...targetDetails,
1281
+ authStrategy: tokenAttempt.strategy,
1282
+ attempt: attemptIndex + 1,
1283
+ attempts: tokenAttempts.length,
1284
+ });
1285
+ try {
1286
+ const result = await callTargetCreateEmbedSession({
1287
+ app: target.app,
1288
+ token: tokenAttempt.token,
1289
+ url: target.url,
1290
+ chrome,
1291
+ });
1292
+ parsed = parseMcpToolTextResult(result) as {
1293
+ startUrl?: string;
1294
+ targetPath?: string;
1295
+ expiresAt?: number;
1296
+ };
1297
+ break;
1298
+ } catch (error) {
1299
+ lastError = error;
1300
+ console.warn("[dispatch] workspace embed target response", {
1301
+ ...targetDetails,
1302
+ authStrategy: tokenAttempt.strategy,
1303
+ attempt: attemptIndex + 1,
1304
+ status: targetMcpErrorStatus(error),
1305
+ category: isTargetMcpAuthError(error)
1306
+ ? "authentication"
1307
+ : isRetryableTargetMcpError(error)
1308
+ ? "transient"
1309
+ : "permanent",
1310
+ });
1311
+ if (
1312
+ !isTargetMcpAuthError(error) ||
1313
+ attemptIndex >= tokenAttempts.length - 1
1314
+ ) {
1315
+ throw error;
1316
+ }
1317
+ }
1318
+ }
1319
+ if (!parsed) {
1320
+ throw lastError instanceof Error
1321
+ ? lastError
1322
+ : new Error("Target app did not return an embed session.");
1323
+ }
1222
1324
  if (!parsed.startUrl) {
1223
1325
  throw new Error("Target app did not return an embed start URL.");
1224
1326
  }
1327
+ const startUrl = resolveGrantedAppEmbedStartUrl(target.app, parsed.startUrl);
1328
+ const startUrlDetails = new URL(startUrl);
1329
+ console.info("[dispatch] workspace embed target minted session", {
1330
+ ...targetDetails,
1331
+ startPath: startUrlDetails.pathname,
1332
+ returnedTicket: startUrlDetails.searchParams.has("ticket"),
1333
+ returnedTargetPath: typeof parsed.targetPath === "string",
1334
+ returnedExpiry: typeof parsed.expiresAt === "number",
1335
+ });
1225
1336
  const output: {
1226
1337
  startUrl: string;
1227
1338
  targetPath?: string;
1228
1339
  expiresAt?: number;
1229
1340
  app: string;
1230
1341
  } = {
1231
- startUrl: resolveGrantedAppEmbedStartUrl(target.app, parsed.startUrl),
1342
+ startUrl,
1232
1343
  app: target.app.id,
1233
1344
  };
1234
1345
  if (parsed.targetPath) output.targetPath = parsed.targetPath;
@@ -1248,13 +1359,31 @@ export async function createWorkspaceSsoEmbedSession(input: {
1248
1359
  app: string;
1249
1360
  }> {
1250
1361
  const ownerEmail = getRequestUserEmail();
1251
- if (!ownerEmail) throw new Error("no authenticated user");
1362
+ if (!ownerEmail) {
1363
+ console.warn("[dispatch] workspace embed mint rejected", {
1364
+ phase: "dispatch-auth",
1365
+ requestedApp: input.app ?? null,
1366
+ hasPath: typeof input.path === "string",
1367
+ hasUrl: typeof input.url === "string",
1368
+ ownerResolved: false,
1369
+ orgResolved: Boolean(getRequestOrgId()),
1370
+ });
1371
+ throw new Error("no authenticated user");
1372
+ }
1252
1373
  const enabled = await isFeatureFlagEnabled(DISPATCH_WORKSPACE_SSO_FLAG, {
1253
1374
  userEmail: ownerEmail,
1254
1375
  userKey: ownerEmail,
1255
1376
  orgId: getRequestOrgId(),
1256
1377
  });
1257
1378
  if (!enabled) {
1379
+ console.warn("[dispatch] workspace embed mint rejected", {
1380
+ phase: "feature-flag",
1381
+ requestedApp: input.app ?? null,
1382
+ hasPath: typeof input.path === "string",
1383
+ hasUrl: typeof input.url === "string",
1384
+ ownerResolved: true,
1385
+ orgResolved: Boolean(getRequestOrgId()),
1386
+ });
1258
1387
  throw new Error("Dispatch workspace sign-in is not enabled.");
1259
1388
  }
1260
1389