@agent-native/dispatch 0.29.3 → 0.29.4

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 (37) hide show
  1. package/dist/actions/provider-api-register.d.ts +14 -14
  2. package/dist/components/layout/Layout.d.ts.map +1 -1
  3. package/dist/components/layout/Layout.js +18 -3
  4. package/dist/components/layout/Layout.js.map +1 -1
  5. package/dist/components/workspace-app-card.d.ts.map +1 -1
  6. package/dist/components/workspace-app-card.js +9 -6
  7. package/dist/components/workspace-app-card.js.map +1 -1
  8. package/dist/components/workspace-app-host.d.ts.map +1 -1
  9. package/dist/components/workspace-app-host.js +48 -8
  10. package/dist/components/workspace-app-host.js.map +1 -1
  11. package/dist/lib/workspace-apps.d.ts +16 -0
  12. package/dist/lib/workspace-apps.d.ts.map +1 -1
  13. package/dist/lib/workspace-apps.js +71 -1
  14. package/dist/lib/workspace-apps.js.map +1 -1
  15. package/dist/server/lib/app-creation-store.d.ts +2 -0
  16. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  17. package/dist/server/lib/app-creation-store.js +9 -3
  18. package/dist/server/lib/app-creation-store.js.map +1 -1
  19. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  20. package/dist/server/lib/mcp-gateway.js +5 -42
  21. package/dist/server/lib/mcp-gateway.js.map +1 -1
  22. package/dist/shared/workspace-sso.d.ts +12 -0
  23. package/dist/shared/workspace-sso.d.ts.map +1 -1
  24. package/dist/shared/workspace-sso.js +43 -0
  25. package/dist/shared/workspace-sso.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/components/layout/Layout.tsx +26 -4
  28. package/src/components/workspace-app-card.spec.tsx +60 -0
  29. package/src/components/workspace-app-card.tsx +17 -4
  30. package/src/components/workspace-app-host.spec.tsx +104 -3
  31. package/src/components/workspace-app-host.tsx +50 -6
  32. package/src/lib/workspace-apps.spec.ts +80 -0
  33. package/src/lib/workspace-apps.ts +95 -1
  34. package/src/server/lib/app-creation-store.spec.ts +43 -0
  35. package/src/server/lib/app-creation-store.ts +11 -3
  36. package/src/server/lib/mcp-gateway.ts +5 -48
  37. package/src/shared/workspace-sso.ts +55 -0
@@ -106,9 +106,13 @@ import { toast } from "sonner";
106
106
  import { cn } from "../../lib/utils";
107
107
  import {
108
108
  isDispatchWorkspaceAppId,
109
+ isPathMountedWorkspaceApp,
109
110
  isWorkspaceAppVisibleInDefaultLaunchers,
111
+ isWorkspaceSsoApp,
110
112
  mergeChatFirstWorkspaceApps,
113
+ navigateToWorkspaceApp,
111
114
  workspaceAppIdFromRoute,
115
+ workspaceAppDirectHref,
112
116
  workspaceAppRoute,
113
117
  type WorkspaceAppSummary,
114
118
  } from "../../lib/workspace-apps";
@@ -1450,6 +1454,26 @@ export function Layout({
1450
1454
  })),
1451
1455
  [chatFirstAppRegistrations],
1452
1456
  );
1457
+ const openChatFirstApp = useCallback(
1458
+ (app: ChatFirstAppItem) => {
1459
+ if (isDispatchWorkspaceAppId(app.id)) return;
1460
+ const registration = chatFirstAppRegistrations.find(
1461
+ (candidate) => candidate.id.toLowerCase() === app.id.toLowerCase(),
1462
+ );
1463
+ const directHref =
1464
+ registration &&
1465
+ !isWorkspaceSsoApp(registration) &&
1466
+ isPathMountedWorkspaceApp(registration)
1467
+ ? workspaceAppDirectHref(registration, "/")
1468
+ : null;
1469
+ if (directHref) {
1470
+ navigateToWorkspaceApp(directHref);
1471
+ return;
1472
+ }
1473
+ navigate(dispatchNavLinkTarget(workspaceAppRoute(app.id)));
1474
+ },
1475
+ [chatFirstAppRegistrations, navigate],
1476
+ );
1453
1477
  const chatFirstCopy = useMemo(() => createDispatchChatFirstCopy(t), [t]);
1454
1478
  const [chatFirstPane, setChatFirstPane] =
1455
1479
  useState<DispatchChatFirstPane | null>(null);
@@ -2448,7 +2472,7 @@ export function Layout({
2448
2472
  onChatFirstAppOpen={(app) => {
2449
2473
  if (isDispatchWorkspaceAppId(app.id)) return;
2450
2474
  setSidebarCollapsed(true);
2451
- navigate(dispatchNavLinkTarget(workspaceAppRoute(app.id)));
2475
+ openChatFirstApp(app);
2452
2476
  }}
2453
2477
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2454
2478
  collapsible
@@ -2489,9 +2513,7 @@ export function Layout({
2489
2513
  chatFirstSurfaceTabsStore.closeAll();
2490
2514
  setChatFirstSurfacePanelOpen(false);
2491
2515
  }}
2492
- onChatFirstAppOpen={(app) =>
2493
- navigate(dispatchNavLinkTarget(workspaceAppRoute(app.id)))
2494
- }
2516
+ onChatFirstAppOpen={openChatFirstApp}
2495
2517
  onChatFirstAppsRetry={() => void chatFirstAppsQuery.refetch()}
2496
2518
  onNavigate={() => setMobileOpen(false)}
2497
2519
  />
@@ -53,6 +53,7 @@ describe("WorkspaceAppCard", () => {
53
53
  id: "analytics",
54
54
  name: "Analytics",
55
55
  path: "/analytics",
56
+ url: "https://analytics.agent-native.com",
56
57
  description: "Explore product and growth performance.",
57
58
  status: "ready",
58
59
  }}
@@ -116,6 +117,65 @@ describe("WorkspaceAppCard", () => {
116
117
  ).toBeNull();
117
118
  });
118
119
 
120
+ it("opens mounted workspace apps at their published URL", async () => {
121
+ const originalParent = window.parent;
122
+ const originalTop = window.top;
123
+ const topWindow = { location: { href: "" } } as unknown as Window;
124
+ Object.defineProperty(window, "parent", {
125
+ configurable: true,
126
+ value: {},
127
+ });
128
+ Object.defineProperty(window, "top", {
129
+ configurable: true,
130
+ value: topWindow,
131
+ });
132
+
133
+ try {
134
+ await act(async () => {
135
+ root.render(
136
+ <MemoryRouter>
137
+ <TooltipProvider>
138
+ <WorkspaceAppCard
139
+ app={{
140
+ id: "feedback-leaderboard",
141
+ name: "Feedback leaderboard",
142
+ path: "/feedback-leaderboard",
143
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
144
+ status: "ready",
145
+ }}
146
+ />
147
+ </TooltipProvider>
148
+ </MemoryRouter>,
149
+ );
150
+ });
151
+
152
+ const openButton = container.querySelector<HTMLButtonElement>(
153
+ ".app-open-actions__primary",
154
+ );
155
+ expect(openButton).not.toBeNull();
156
+ expect(openButton?.textContent).toContain("Open app");
157
+ expect(
158
+ container.querySelector(
159
+ 'a[href="https://agent-workspace.builder.io/feedback-leaderboard/leaderboard"]',
160
+ ),
161
+ ).toBeNull();
162
+
163
+ await act(async () => openButton?.click());
164
+ expect(topWindow.location.href).toBe(
165
+ "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
166
+ );
167
+ } finally {
168
+ Object.defineProperty(window, "parent", {
169
+ configurable: true,
170
+ value: originalParent,
171
+ });
172
+ Object.defineProperty(window, "top", {
173
+ configurable: true,
174
+ value: originalTop,
175
+ });
176
+ }
177
+ });
178
+
119
179
  it("keeps pinning in the app open menu", async () => {
120
180
  const onTogglePinned = vi.fn();
121
181
  await act(async () => {
@@ -26,6 +26,10 @@ import { toast } from "sonner";
26
26
 
27
27
  import { cn } from "../lib/utils";
28
28
  import {
29
+ isPathMountedWorkspaceApp,
30
+ isWorkspaceSsoApp,
31
+ navigateToWorkspaceApp,
32
+ workspaceAppDirectHref,
29
33
  workspaceAppHref,
30
34
  workspaceAppRoute,
31
35
  type WorkspaceAppSummary,
@@ -75,6 +79,12 @@ export function WorkspaceAppCard({
75
79
  }) {
76
80
  const t = useT();
77
81
  const href = workspaceAppHref(app);
82
+ const directHref =
83
+ app.status !== "pending" &&
84
+ !isWorkspaceSsoApp(app) &&
85
+ isPathMountedWorkspaceApp(app)
86
+ ? workspaceAppDirectHref(app, "/")
87
+ : null;
78
88
  const isPending = app.status === "pending";
79
89
  const pendingLabel = app.statusLabel || "Builder branch";
80
90
  const pendingOpenLabel = t("dispatch.pages.openBuilderBranch", {
@@ -193,7 +203,8 @@ export function WorkspaceAppCard({
193
203
  <div className="flex shrink-0 items-center gap-2">
194
204
  <WorkspaceAppOpenActions
195
205
  app={app}
196
- href={href}
206
+ href={directHref ?? href}
207
+ openDirectly={Boolean(directHref)}
197
208
  isPinned={isPinned}
198
209
  pinLabel={pinLabel}
199
210
  pendingOpenLabel={pendingOpenLabel}
@@ -285,6 +296,7 @@ export function WorkspaceAppCard({
285
296
  function WorkspaceAppOpenActions({
286
297
  app,
287
298
  href,
299
+ openDirectly,
288
300
  isPinned,
289
301
  pinLabel,
290
302
  pendingOpenLabel,
@@ -292,6 +304,7 @@ function WorkspaceAppOpenActions({
292
304
  }: {
293
305
  app: WorkspaceAppSummary;
294
306
  href: string | null;
307
+ openDirectly: boolean;
295
308
  isPinned: boolean;
296
309
  pinLabel: string;
297
310
  pendingOpenLabel: string;
@@ -328,9 +341,9 @@ function WorkspaceAppOpenActions({
328
341
  name={app.name}
329
342
  href={href}
330
343
  showNewTabOption
331
- onOpen={() => {
332
- navigate(appRoute);
333
- }}
344
+ onOpen={() =>
345
+ openDirectly ? navigateToWorkspaceApp(href) : navigate(appRoute)
346
+ }
334
347
  menuItems={
335
348
  onTogglePinned
336
349
  ? [
@@ -309,7 +309,14 @@ describe("WorkspaceAppKeepAlive", () => {
309
309
 
310
310
  await act(async () => {
311
311
  root.render(
312
- <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
312
+ <WorkspaceAppFrame
313
+ app={{
314
+ id: "mail",
315
+ name: "Mail",
316
+ path: "/mail",
317
+ url: "https://mail.agent-native.com",
318
+ }}
319
+ />,
313
320
  );
314
321
  await Promise.resolve();
315
322
  await Promise.resolve();
@@ -317,12 +324,99 @@ describe("WorkspaceAppKeepAlive", () => {
317
324
 
318
325
  expect(clientState.workspaceSsoMutateAsync).toHaveBeenCalledWith({
319
326
  app: "mail",
320
- path: "/mail",
327
+ url: "https://mail.agent-native.com",
321
328
  chrome: "minimal",
322
329
  });
323
330
  expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
324
331
  });
325
332
 
333
+ it("uses the granted-app session when a mounted app reuses a canonical id", async () => {
334
+ clientState.workspaceSsoEnabled = true;
335
+
336
+ await act(async () => {
337
+ root.render(
338
+ <WorkspaceAppFrame
339
+ app={{
340
+ id: "mail",
341
+ name: "Internal Mail",
342
+ path: "/mail",
343
+ url: "https://agent-workspace.builder.io/mail",
344
+ }}
345
+ />,
346
+ );
347
+ await Promise.resolve();
348
+ await Promise.resolve();
349
+ });
350
+
351
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
352
+ app: "mail",
353
+ url: "https://agent-workspace.builder.io/mail",
354
+ chrome: "minimal",
355
+ });
356
+ expect(clientState.workspaceSsoMutateAsync).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it("uses the granted-app session action for mounted apps outside the SSO registry", async () => {
360
+ clientState.workspaceSsoEnabled = true;
361
+
362
+ await act(async () => {
363
+ root.render(
364
+ <WorkspaceAppFrame
365
+ app={{
366
+ id: "feedback-leaderboard",
367
+ name: "Feedback leaderboard",
368
+ path: "/feedback-leaderboard",
369
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
370
+ }}
371
+ />,
372
+ );
373
+ await Promise.resolve();
374
+ await Promise.resolve();
375
+ });
376
+
377
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
378
+ app: "feedback-leaderboard",
379
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
380
+ chrome: "minimal",
381
+ });
382
+ expect(clientState.workspaceSsoMutateAsync).not.toHaveBeenCalled();
383
+ });
384
+
385
+ it("mints custom SSO before navigating an embedded Dispatch surface", async () => {
386
+ clientState.workspaceSsoEnabled = true;
387
+ const navigateToTopWindow = vi.fn(() => true);
388
+ Object.defineProperty(window, "parent", {
389
+ configurable: true,
390
+ value: {},
391
+ });
392
+
393
+ await act(async () => {
394
+ root.render(
395
+ <WorkspaceAppFrame
396
+ app={{
397
+ id: "custom-sso",
398
+ name: "Custom SSO",
399
+ path: "/custom-sso",
400
+ url: "https://custom.example/custom-sso",
401
+ workspaceSso: true,
402
+ }}
403
+ navigateToTopWindow={navigateToTopWindow}
404
+ />,
405
+ );
406
+ await Promise.resolve();
407
+ await Promise.resolve();
408
+ });
409
+
410
+ expect(clientState.workspaceSsoMutateAsync).toHaveBeenCalledWith({
411
+ app: "custom-sso",
412
+ url: "https://custom.example/custom-sso",
413
+ chrome: "minimal",
414
+ });
415
+ expect(navigateToTopWindow).toHaveBeenCalledWith("about:blank");
416
+ expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
417
+ expect(container.querySelector("iframe")).toBeNull();
418
+ });
419
+
326
420
  it("sends the parent theme on iframe load and when the parent changes", async () => {
327
421
  await act(async () => {
328
422
  root.render(
@@ -420,7 +514,14 @@ describe("WorkspaceAppKeepAlive", () => {
420
514
 
421
515
  await act(async () => {
422
516
  root.render(
423
- <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
517
+ <WorkspaceAppFrame
518
+ app={{
519
+ id: "mail",
520
+ name: "Mail",
521
+ path: "/mail",
522
+ url: "https://mail.agent-native.com",
523
+ }}
524
+ />,
424
525
  );
425
526
  await Promise.resolve();
426
527
  await Promise.resolve();
@@ -31,6 +31,7 @@ import { Link } from "react-router";
31
31
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
32
32
  import {
33
33
  mergeChatFirstWorkspaceApps,
34
+ isWorkspaceSsoApp,
34
35
  navigateToWorkspaceApp,
35
36
  shouldOpenWorkspaceAppInTopWindow,
36
37
  workspaceAppDirectHref,
@@ -287,6 +288,7 @@ export function WorkspaceAppFrame({
287
288
  if (isDirectFallback) setEmbedError(null);
288
289
  }, [isDirectFallback, postThemeToFrame]);
289
290
  const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key);
291
+ const useWorkspaceSso = workspaceSsoEnabled && isWorkspaceSsoApp(app);
290
292
  const createEmbedSession = useActionMutation<
291
293
  EmbedSessionResult,
292
294
  EmbedSessionInput
@@ -320,6 +322,8 @@ export function WorkspaceAppFrame({
320
322
  return target.url ?? target.path ?? null;
321
323
  }, [app.path, app.url, embedPath]);
322
324
  const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
325
+ const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${embedAttempt}`;
326
+ const topWindowSsoAttemptedRef = useRef<string | null>(null);
323
327
  const embedInput = useMemo<EmbedSessionInput | null>(() => {
324
328
  if (embedPath !== undefined) {
325
329
  return buildChatFirstEmbedSessionInput(app.id, embedPath);
@@ -333,6 +337,10 @@ export function WorkspaceAppFrame({
333
337
  }, [app.id, app.path, app.url, appHref, embedPath]);
334
338
 
335
339
  useEffect(() => {
340
+ if (openInTopWindow && useWorkspaceSso && embedInput) {
341
+ setTopWindowNavigationFailed(false);
342
+ return;
343
+ }
336
344
  if (!openInTopWindow) {
337
345
  setTopWindowNavigationFailed(false);
338
346
  return;
@@ -349,26 +357,59 @@ export function WorkspaceAppFrame({
349
357
  didNavigate = false;
350
358
  }
351
359
  setTopWindowNavigationFailed(!didNavigate);
352
- }, [navigateToTopWindow, openInTopWindow, topWindowHref]);
360
+ }, [
361
+ embedInput,
362
+ navigateToTopWindow,
363
+ openInTopWindow,
364
+ topWindowHref,
365
+ useWorkspaceSso,
366
+ ]);
353
367
 
354
368
  useEffect(() => {
355
- if (!embedInput || (openInTopWindow && !topWindowNavigationFailed)) return;
369
+ const useTopWindowSso = openInTopWindow && useWorkspaceSso && !!embedInput;
370
+ if (
371
+ !embedInput ||
372
+ (openInTopWindow && !useWorkspaceSso && !topWindowNavigationFailed)
373
+ ) {
374
+ return;
375
+ }
376
+ if (
377
+ useTopWindowSso &&
378
+ topWindowSsoAttemptedRef.current === topWindowSsoAttemptKey
379
+ ) {
380
+ return;
381
+ }
382
+ if (useTopWindowSso) {
383
+ topWindowSsoAttemptedRef.current = topWindowSsoAttemptKey;
384
+ }
356
385
  let cancelled = false;
357
386
  setEmbedUrl(null);
358
387
  setEmbedError(null);
359
388
  setIsDirectFallback(false);
360
- const createSession = workspaceSsoEnabled
389
+ const createSession = useWorkspaceSso
361
390
  ? createWorkspaceSsoEmbedSession
362
391
  : createEmbedSession;
363
392
  void createSession
364
393
  .mutateAsync(embedInput)
365
394
  .then((result) => {
366
- if (!cancelled) setEmbedUrl(result.startUrl);
395
+ if (cancelled) return;
396
+ if (useTopWindowSso) {
397
+ let didNavigate = false;
398
+ try {
399
+ didNavigate = navigateToTopWindow(result.startUrl) !== false;
400
+ } catch {
401
+ didNavigate = false;
402
+ }
403
+ setTopWindowNavigationFailed(!didNavigate);
404
+ setEmbedUrl(didNavigate ? null : result.startUrl);
405
+ return;
406
+ }
407
+ setEmbedUrl(result.startUrl);
367
408
  })
368
409
  .catch((cause: unknown) => {
369
410
  if (cancelled) return;
370
411
  const error = cause instanceof Error ? cause : new Error(String(cause));
371
- if (workspaceSsoEnabled) {
412
+ if (useWorkspaceSso) {
372
413
  // An SSO-enabled pane must never fall back to the child app's
373
414
  // unauthenticated shell. Keep the parent-owned retry surface in
374
415
  // place so a transient exchange failure cannot expose another
@@ -376,6 +417,7 @@ export function WorkspaceAppFrame({
376
417
  setIsDirectFallback(false);
377
418
  setEmbedUrl(null);
378
419
  setEmbedError(error);
420
+ if (useTopWindowSso) setTopWindowNavigationFailed(true);
379
421
  return;
380
422
  }
381
423
  setIsDirectFallback(true);
@@ -400,8 +442,10 @@ export function WorkspaceAppFrame({
400
442
  embedPath,
401
443
  embedAttempt,
402
444
  openInTopWindow,
445
+ navigateToTopWindow,
446
+ topWindowSsoAttemptKey,
403
447
  topWindowNavigationFailed,
404
- workspaceSsoEnabled,
448
+ useWorkspaceSso,
405
449
  ]);
406
450
 
407
451
  useEffect(() => {
@@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  isDefaultWorkspaceAppHiddenId,
5
5
  isDispatchWorkspaceAppId,
6
+ isPathMountedWorkspaceApp,
6
7
  isWorkspaceAppVisibleInDefaultLaunchers,
8
+ isWorkspaceSsoApp,
7
9
  mergeChatFirstWorkspaceApps,
8
10
  workspaceAppIdFromRoute,
11
+ workspaceAppDirectHref,
9
12
  workspaceAppRoute,
10
13
  } from "./workspace-apps";
11
14
 
@@ -31,6 +34,83 @@ describe("workspace app routes", () => {
31
34
  expect(isDispatchWorkspaceAppId("dispatch-tools")).toBe(false);
32
35
  });
33
36
 
37
+ it("requires canonical metadata before enabling workspace SSO", () => {
38
+ expect(
39
+ isWorkspaceSsoApp({
40
+ id: " Mail ",
41
+ path: "/",
42
+ url: "https://mail.agent-native.com",
43
+ }),
44
+ ).toBe(true);
45
+ expect(
46
+ isWorkspaceSsoApp({
47
+ id: "mail",
48
+ path: "/mail",
49
+ url: "https://agent-workspace.builder.io/mail",
50
+ }),
51
+ ).toBe(false);
52
+ expect(
53
+ isWorkspaceSsoApp({
54
+ id: "feedback-leaderboard",
55
+ path: "/feedback-leaderboard",
56
+ url: "https://agent-workspace.builder.io/feedback-leaderboard",
57
+ }),
58
+ ).toBe(false);
59
+ });
60
+
61
+ it("accepts the server eligibility projection for registered custom apps", () => {
62
+ expect(
63
+ isWorkspaceSsoApp({
64
+ id: "workspace-reports",
65
+ path: "/workspace-reports",
66
+ url: "https://reports.example.com/workspace-reports",
67
+ workspaceSso: true,
68
+ }),
69
+ ).toBe(true);
70
+ expect(
71
+ isWorkspaceSsoApp({
72
+ id: "workspace-reports",
73
+ path: "/workspace-reports",
74
+ url: "javascript:alert(1)",
75
+ workspaceSso: true,
76
+ }),
77
+ ).toBe(false);
78
+ });
79
+
80
+ it("keeps canonical loopback apps eligible outside production", () => {
81
+ expect(
82
+ isWorkspaceSsoApp({
83
+ id: "mail",
84
+ path: "/mail",
85
+ url: "http://localhost:8084",
86
+ }),
87
+ ).toBe(true);
88
+ });
89
+
90
+ it("identifies mounted apps and resolves their direct workspace href", () => {
91
+ const app = {
92
+ path: "/feedback-leaderboard",
93
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
94
+ };
95
+
96
+ expect(isPathMountedWorkspaceApp(app)).toBe(true);
97
+ expect(workspaceAppDirectHref(app, "/")).toBe(
98
+ "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
99
+ );
100
+ expect(
101
+ isPathMountedWorkspaceApp({
102
+ path: "/",
103
+ url: "https://mail.agent-native.com",
104
+ }),
105
+ ).toBe(false);
106
+ expect(
107
+ isPathMountedWorkspaceApp({
108
+ path: "/",
109
+ url: "https://feedback.example.com",
110
+ }),
111
+ ).toBe(true);
112
+ });
113
+
34
114
  it("hides the generic chat starter and Dispatch from default launchers", () => {
35
115
  expect(isDefaultWorkspaceAppHiddenId(" Chat ")).toBe(true);
36
116
  expect(isWorkspaceAppVisibleInDefaultLaunchers({ id: "chat" })).toBe(false);
@@ -2,7 +2,10 @@ import { CHAT_FIRST_DEFAULT_APP_IDS } from "@agent-native/core/client/chat-first
2
2
  import { isInBuilderFrame } from "@agent-native/core/client/host";
3
3
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
4
4
 
5
- import { CANONICAL_WORKSPACE_SSO_APP_ORIGINS } from "../shared/workspace-sso";
5
+ import {
6
+ CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
7
+ isWorkspaceSsoAppUrl,
8
+ } from "../shared/workspace-sso";
6
9
 
7
10
  export interface WorkspaceAppSummary {
8
11
  id: string;
@@ -28,17 +31,108 @@ export interface WorkspaceAppSummary {
28
31
  agentName?: string | null;
29
32
  agentSkillsCount?: number | null;
30
33
  archived?: boolean;
34
+ workspaceSso?: boolean;
31
35
  }
32
36
 
33
37
  interface WorkspaceAppHrefSource {
34
38
  path?: string | null;
35
39
  url?: string | null;
40
+ workspaceSso?: boolean;
36
41
  }
37
42
 
38
43
  export function isDispatchWorkspaceAppId(appId: string): boolean {
39
44
  return appId.trim().toLowerCase() === "dispatch";
40
45
  }
41
46
 
47
+ function clientRuntimeEnvironment(): "production" | "development" {
48
+ const importMetaEnv = (
49
+ import.meta as unknown as {
50
+ env?: Record<string, string | boolean | undefined>;
51
+ }
52
+ ).env;
53
+ const processEnv = (
54
+ globalThis as typeof globalThis & {
55
+ process?: { env?: Record<string, string | boolean | undefined> };
56
+ }
57
+ ).process?.env;
58
+ const env = { ...processEnv, ...importMetaEnv };
59
+ if (
60
+ env.PROD === true ||
61
+ env.MODE === "production" ||
62
+ env.NODE_ENV === "production"
63
+ ) {
64
+ return "production";
65
+ }
66
+ if (
67
+ env.DEV === true ||
68
+ env.MODE === "development" ||
69
+ env.MODE === "test" ||
70
+ env.NODE_ENV === "development" ||
71
+ env.NODE_ENV === "test"
72
+ ) {
73
+ return "development";
74
+ }
75
+ return "production";
76
+ }
77
+
78
+ /**
79
+ * The workspace SSO action only accepts an exact registered app identity and
80
+ * origin. The catalog's boolean is a server-derived projection for custom
81
+ * registrations; the URL check keeps malformed metadata out of the action.
82
+ */
83
+ export function isWorkspaceSsoApp(
84
+ app: WorkspaceAppHrefSource & { id: string },
85
+ ): boolean {
86
+ const rawUrl = app.url?.trim();
87
+ if (!rawUrl) return false;
88
+ try {
89
+ const url = new URL(rawUrl);
90
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
91
+ if (app.workspaceSso === true) return true;
92
+ return isWorkspaceSsoAppUrl(
93
+ { id: app.id, url: rawUrl },
94
+ { nodeEnv: clientRuntimeEnvironment() },
95
+ );
96
+ // coercion-ok: malformed app metadata is not eligible for workspace SSO.
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function isCanonicalWorkspaceSsoOrigin(rawUrl: string): boolean {
103
+ try {
104
+ const origin = new URL(rawUrl).origin;
105
+ return Object.values(CANONICAL_WORKSPACE_SSO_APP_ORIGINS).some(
106
+ (canonicalOrigin) => canonicalOrigin === origin,
107
+ );
108
+ } catch {
109
+ // coercion-ok: malformed app metadata is not a canonical first-party app.
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * A mounted app URL leaves Dispatch's `/apps/:id` host route. Canonical
116
+ * first-party origins can stay inline even at `/`, while external published
117
+ * apps must open at their own origin regardless of their path.
118
+ */
119
+ export function isPathMountedWorkspaceApp(
120
+ app: WorkspaceAppHrefSource,
121
+ ): boolean {
122
+ const rawUrl = app.url?.trim();
123
+ if (rawUrl) {
124
+ try {
125
+ const pathname = new URL(rawUrl).pathname.replace(/\/+$/, "") || "/";
126
+ return pathname !== "/" || !isCanonicalWorkspaceSsoOrigin(rawUrl);
127
+ // coercion-ok: invalid absolute URLs use the mounted path fallback.
128
+ } catch {
129
+ // Fall through to the mounted path for relative manifest values.
130
+ }
131
+ }
132
+ const path = app.path?.trim().replace(/\/+$/, "") || "/";
133
+ return path !== "/";
134
+ }
135
+
42
136
  export function isDefaultWorkspaceAppHiddenId(appId: string): boolean {
43
137
  const normalized = appId.trim().toLowerCase();
44
138
  return normalized === "chat" || isDispatchWorkspaceAppId(normalized);