@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.
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +18 -3
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +9 -6
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +48 -8
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +16 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +71 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts +2 -0
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +9 -3
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +5 -42
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/shared/workspace-sso.d.ts +12 -0
- package/dist/shared/workspace-sso.d.ts.map +1 -1
- package/dist/shared/workspace-sso.js +43 -0
- package/dist/shared/workspace-sso.js.map +1 -1
- package/package.json +2 -2
- package/src/components/layout/Layout.tsx +26 -4
- package/src/components/workspace-app-card.spec.tsx +60 -0
- package/src/components/workspace-app-card.tsx +17 -4
- package/src/components/workspace-app-host.spec.tsx +104 -3
- package/src/components/workspace-app-host.tsx +50 -6
- package/src/lib/workspace-apps.spec.ts +80 -0
- package/src/lib/workspace-apps.ts +95 -1
- package/src/server/lib/app-creation-store.spec.ts +43 -0
- package/src/server/lib/app-creation-store.ts +11 -3
- package/src/server/lib/mcp-gateway.ts +5 -48
- 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
|
-
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}, [
|
|
360
|
+
}, [
|
|
361
|
+
embedInput,
|
|
362
|
+
navigateToTopWindow,
|
|
363
|
+
openInTopWindow,
|
|
364
|
+
topWindowHref,
|
|
365
|
+
useWorkspaceSso,
|
|
366
|
+
]);
|
|
353
367
|
|
|
354
368
|
useEffect(() => {
|
|
355
|
-
|
|
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 =
|
|
389
|
+
const createSession = useWorkspaceSso
|
|
361
390
|
? createWorkspaceSsoEmbedSession
|
|
362
391
|
: createEmbedSession;
|
|
363
392
|
void createSession
|
|
364
393
|
.mutateAsync(embedInput)
|
|
365
394
|
.then((result) => {
|
|
366
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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);
|