@agent-native/dispatch 0.29.2 → 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/list-workspace-apps.d.ts +1 -0
- package/dist/actions/list-workspace-apps.js +3 -0
- package/dist/actions/list-workspace-apps.js.map +1 -1
- 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 +19 -4
- 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 +65 -13
- 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/actions/list-workspace-apps.ts +3 -0
- package/src/components/layout/Layout.tsx +27 -5
- 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 +208 -41
- package/src/components/workspace-app-host.tsx +85 -10
- 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
|
@@ -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(() => {
|
|
@@ -476,21 +520,52 @@ export function WorkspaceAppHost({
|
|
|
476
520
|
const t = useT();
|
|
477
521
|
const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
|
|
478
522
|
"list-workspace-apps",
|
|
479
|
-
{ includeAgentCards: false },
|
|
523
|
+
{ includeAgentCards: false, includeArchived: true },
|
|
524
|
+
);
|
|
525
|
+
const workspaceApps = useMemo(
|
|
526
|
+
() => mergeChatFirstWorkspaceApps(workspaceAppsQuery.data),
|
|
527
|
+
[workspaceAppsQuery.data],
|
|
528
|
+
);
|
|
529
|
+
const visibleWorkspaceApps = useMemo(
|
|
530
|
+
() => workspaceApps.filter((item) => !item.archived),
|
|
531
|
+
[workspaceApps],
|
|
532
|
+
);
|
|
533
|
+
const workspaceAppIds = useMemo(
|
|
534
|
+
() => new Set(workspaceApps.map((item) => item.id.trim().toLowerCase())),
|
|
535
|
+
[workspaceApps],
|
|
536
|
+
);
|
|
537
|
+
const workspaceApp = useMemo(
|
|
538
|
+
() =>
|
|
539
|
+
visibleWorkspaceApps.find(
|
|
540
|
+
(item) => item.id.trim().toLowerCase() === appId?.trim().toLowerCase(),
|
|
541
|
+
) ?? null,
|
|
542
|
+
[appId, visibleWorkspaceApps],
|
|
480
543
|
);
|
|
481
544
|
const grantedAppsQuery = useActionQuery<GrantedWorkspaceAppsResult>(
|
|
482
545
|
"list_apps",
|
|
483
546
|
{},
|
|
547
|
+
{
|
|
548
|
+
// Mounted workspace apps are already fully described by the workspace
|
|
549
|
+
// registry. Defer the broader MCP grant/discovery scan until that
|
|
550
|
+
// lookup misses; it is only needed for externally granted apps.
|
|
551
|
+
enabled: !workspaceAppsQuery.isLoading && !workspaceApp,
|
|
552
|
+
},
|
|
484
553
|
);
|
|
485
554
|
const apps = useMemo(() => {
|
|
486
555
|
const merged = new Map<string, WorkspaceAppSummary>();
|
|
487
556
|
|
|
488
|
-
for (const app of
|
|
557
|
+
for (const app of visibleWorkspaceApps) {
|
|
489
558
|
merged.set(app.id.trim().toLowerCase(), app);
|
|
490
559
|
}
|
|
491
560
|
for (const app of grantedAppsQuery.data?.apps ?? []) {
|
|
492
561
|
const id = app.id.trim();
|
|
493
|
-
if (
|
|
562
|
+
if (
|
|
563
|
+
!id ||
|
|
564
|
+
workspaceAppIds.has(id.toLowerCase()) ||
|
|
565
|
+
merged.has(id.toLowerCase())
|
|
566
|
+
) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
494
569
|
merged.set(id.toLowerCase(), {
|
|
495
570
|
id,
|
|
496
571
|
name: app.name.trim() || id,
|
|
@@ -501,7 +576,7 @@ export function WorkspaceAppHost({
|
|
|
501
576
|
}
|
|
502
577
|
|
|
503
578
|
return [...merged.values()];
|
|
504
|
-
}, [grantedAppsQuery.data?.apps,
|
|
579
|
+
}, [grantedAppsQuery.data?.apps, visibleWorkspaceApps, workspaceAppIds]);
|
|
505
580
|
const app = useMemo(
|
|
506
581
|
() =>
|
|
507
582
|
apps.find(
|
|
@@ -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);
|
|
@@ -345,6 +345,49 @@ describe("listWorkspaceApps", () => {
|
|
|
345
345
|
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
it("projects exact custom SSO eligibility without exposing registry details", async () => {
|
|
349
|
+
stubNoPendingContext();
|
|
350
|
+
stubManifest([
|
|
351
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
352
|
+
{
|
|
353
|
+
id: "workspace-reports",
|
|
354
|
+
name: "Workspace Reports",
|
|
355
|
+
path: "/workspace-reports",
|
|
356
|
+
url: "https://reports.example.com/workspace-reports",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "unregistered",
|
|
360
|
+
name: "Unregistered",
|
|
361
|
+
path: "/unregistered",
|
|
362
|
+
url: "https://unregistered.example.com",
|
|
363
|
+
},
|
|
364
|
+
]);
|
|
365
|
+
vi.stubEnv(
|
|
366
|
+
"IDENTITY_SSO_APP_REGISTRY_JSON",
|
|
367
|
+
JSON.stringify([
|
|
368
|
+
{
|
|
369
|
+
appId: "workspace-reports",
|
|
370
|
+
clientId: "workspace-reports-client",
|
|
371
|
+
origin: "https://reports.example.com",
|
|
372
|
+
callbackPath: "/_agent-native/identity/callback",
|
|
373
|
+
capabilities: ["identity-sso"],
|
|
374
|
+
},
|
|
375
|
+
]),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const apps = await runWithRequestContext(
|
|
379
|
+
{ userEmail: "dev@example.test" },
|
|
380
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect(apps.find((app) => app.id === "workspace-reports")).toMatchObject({
|
|
384
|
+
workspaceSso: true,
|
|
385
|
+
});
|
|
386
|
+
expect(apps.find((app) => app.id === "unregistered")).toMatchObject({
|
|
387
|
+
workspaceSso: false,
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
348
391
|
it("filters workspace apps by audience", async () => {
|
|
349
392
|
stubNoPendingContext();
|
|
350
393
|
vi.stubEnv(
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { getSetting, putSetting } from "@agent-native/core/settings";
|
|
18
18
|
import { assertValidWorkspaceAppId } from "@agent-native/core/shared";
|
|
19
19
|
|
|
20
|
+
import { isWorkspaceSsoAppUrl } from "../../shared/workspace-sso.js";
|
|
20
21
|
import { identityKeyForIncoming } from "./dispatch-integrations.js";
|
|
21
22
|
import {
|
|
22
23
|
currentOrgId,
|
|
@@ -103,6 +104,8 @@ export interface WorkspaceAppSummary {
|
|
|
103
104
|
agentName?: string | null;
|
|
104
105
|
agentSkillsCount?: number | null;
|
|
105
106
|
archived?: boolean;
|
|
107
|
+
/** Safe server-side eligibility projection for the workspace SSO action. */
|
|
108
|
+
workspaceSso?: boolean;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
export interface ListWorkspaceAppsOptions {
|
|
@@ -1326,9 +1329,14 @@ async function applyArchivedAndPending(
|
|
|
1326
1329
|
app,
|
|
1327
1330
|
metadataSettings,
|
|
1328
1331
|
);
|
|
1329
|
-
return
|
|
1330
|
-
|
|
1331
|
-
: withMetadata
|
|
1332
|
+
return {
|
|
1333
|
+
...withMetadata,
|
|
1334
|
+
workspaceSso: isWorkspaceSsoAppUrl(withMetadata, {
|
|
1335
|
+
nodeEnv: process.env.NODE_ENV,
|
|
1336
|
+
registryRaw: process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
|
|
1337
|
+
}),
|
|
1338
|
+
...(archivedSet.has(app.id) ? { archived: true } : {}),
|
|
1339
|
+
};
|
|
1332
1340
|
});
|
|
1333
1341
|
return options.includeArchived
|
|
1334
1342
|
? filterAppsByAudience(annotated, options.audience)
|
|
@@ -25,9 +25,8 @@ import {
|
|
|
25
25
|
} from "@agent-native/core/server/agent-discovery";
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
|
-
CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
|
|
29
28
|
DISPATCH_WORKSPACE_SSO_FLAG,
|
|
30
|
-
|
|
29
|
+
isWorkspaceSsoAppUrl,
|
|
31
30
|
} from "../../shared/workspace-sso.js";
|
|
32
31
|
import {
|
|
33
32
|
listWorkspaceApps,
|
|
@@ -465,17 +464,6 @@ function safeAppOrigin(app: DispatchMcpAccessibleApp): string | null {
|
|
|
465
464
|
}
|
|
466
465
|
}
|
|
467
466
|
|
|
468
|
-
function isLoopbackOrigin(origin: string): boolean {
|
|
469
|
-
try {
|
|
470
|
-
const hostname = new URL(origin).hostname;
|
|
471
|
-
return ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
472
|
-
} catch {
|
|
473
|
-
// coercion-ok: only a previously validated app origin reaches this helper;
|
|
474
|
-
// malformed values are ineligible for the loopback development exception.
|
|
475
|
-
return false;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
467
|
function appBaseUrl(app: DispatchMcpAccessibleApp): string {
|
|
480
468
|
return app.url.replace(/\/+$/, "");
|
|
481
469
|
}
|
|
@@ -643,44 +631,13 @@ export async function resolveGrantedDispatchMcpApp(
|
|
|
643
631
|
return match;
|
|
644
632
|
}
|
|
645
633
|
|
|
646
|
-
function workspaceSsoOriginForApp(appId: string): string | null {
|
|
647
|
-
const canonical =
|
|
648
|
-
CANONICAL_WORKSPACE_SSO_APP_ORIGINS[
|
|
649
|
-
appId as keyof typeof CANONICAL_WORKSPACE_SSO_APP_ORIGINS
|
|
650
|
-
];
|
|
651
|
-
if (canonical) return canonical;
|
|
652
|
-
const custom = parseWorkspaceSsoAppRegistrations(
|
|
653
|
-
process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
|
|
654
|
-
).find((registration) => registration.appId === appId);
|
|
655
|
-
return custom?.origin ?? null;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
|
|
659
|
-
const origin = safeAppOrigin(app);
|
|
660
|
-
if (!origin) return false;
|
|
661
|
-
const registeredOrigin = workspaceSsoOriginForApp(app.id);
|
|
662
|
-
if (origin === registeredOrigin) return true;
|
|
663
|
-
// The built-in discovery table intentionally points at local dev ports when
|
|
664
|
-
// running outside production. Keep local development usable without ever
|
|
665
|
-
// weakening the production exact-origin check.
|
|
666
|
-
return process.env.NODE_ENV !== "production" && isLoopbackOrigin(origin)
|
|
667
|
-
? Object.prototype.hasOwnProperty.call(
|
|
668
|
-
CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
|
|
669
|
-
app.id,
|
|
670
|
-
)
|
|
671
|
-
: false;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
634
|
async function isEligibleWorkspaceSsoApp(
|
|
675
635
|
candidate: DispatchMcpAccessibleApp,
|
|
676
636
|
): Promise<boolean> {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const customOrigin = workspaceSsoOriginForApp(candidate.id);
|
|
682
|
-
if (customOrigin === origin) return true;
|
|
683
|
-
return false;
|
|
637
|
+
return isWorkspaceSsoAppUrl(candidate, {
|
|
638
|
+
nodeEnv: process.env.NODE_ENV,
|
|
639
|
+
registryRaw: process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
|
|
640
|
+
});
|
|
684
641
|
}
|
|
685
642
|
|
|
686
643
|
async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
|
|
@@ -116,3 +116,58 @@ export function parseWorkspaceSsoAppRegistrations(
|
|
|
116
116
|
}
|
|
117
117
|
return registrations;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
function appOrigin(rawUrl: unknown): string | null {
|
|
121
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) return null;
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(rawUrl.trim());
|
|
124
|
+
return url.protocol === "http:" || url.protocol === "https:"
|
|
125
|
+
? url.origin
|
|
126
|
+
: null;
|
|
127
|
+
} catch {
|
|
128
|
+
// coercion-ok: malformed app metadata is not eligible for workspace SSO.
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isLoopbackOrigin(origin: string): boolean {
|
|
134
|
+
try {
|
|
135
|
+
return LOCALHOST_HOSTS.has(new URL(origin).hostname);
|
|
136
|
+
} catch {
|
|
137
|
+
// coercion-ok: only a previously parsed origin reaches this helper.
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Keep the server-side app catalog and the browser's action choice on the
|
|
144
|
+
* same exact-origin rules. `registryRaw` is deliberately supplied by the
|
|
145
|
+
* caller so this shared module never reads process or browser environment.
|
|
146
|
+
*/
|
|
147
|
+
export function isWorkspaceSsoAppUrl(
|
|
148
|
+
app: { id: string; url?: unknown },
|
|
149
|
+
options: { nodeEnv?: string; registryRaw?: unknown } = {},
|
|
150
|
+
): boolean {
|
|
151
|
+
const origin = appOrigin(app.url);
|
|
152
|
+
if (!origin) return false;
|
|
153
|
+
|
|
154
|
+
const appId = app.id.trim().toLowerCase();
|
|
155
|
+
const canonicalOrigin =
|
|
156
|
+
CANONICAL_WORKSPACE_SSO_APP_ORIGINS[
|
|
157
|
+
appId as keyof typeof CANONICAL_WORKSPACE_SSO_APP_ORIGINS
|
|
158
|
+
];
|
|
159
|
+
if (canonicalOrigin === origin) return true;
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
options.nodeEnv !== "production" &&
|
|
163
|
+
canonicalOrigin &&
|
|
164
|
+
isLoopbackOrigin(origin)
|
|
165
|
+
) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parseWorkspaceSsoAppRegistrations(options.registryRaw).some(
|
|
170
|
+
(registration) =>
|
|
171
|
+
registration.appId === appId && registration.origin === origin,
|
|
172
|
+
);
|
|
173
|
+
}
|