@agent-native/dispatch 0.24.6 → 0.25.1
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/README.md +12 -1
- package/dist/actions/connect-external-agent.d.ts +18 -0
- package/dist/actions/connect-external-agent.d.ts.map +1 -0
- package/dist/actions/connect-external-agent.js +68 -0
- package/dist/actions/connect-external-agent.js.map +1 -0
- package/dist/actions/create-workspace-resource.d.ts +14 -14
- package/dist/actions/create-workspace-resource.js +9 -2
- package/dist/actions/create-workspace-resource.js.map +1 -1
- package/dist/actions/create_workspace_app_embed_session.d.ts +13 -0
- package/dist/actions/create_workspace_app_embed_session.d.ts.map +1 -0
- package/dist/actions/create_workspace_app_embed_session.js +30 -0
- package/dist/actions/create_workspace_app_embed_session.js.map +1 -0
- package/dist/actions/import-agent-pack.d.ts +96 -0
- package/dist/actions/import-agent-pack.d.ts.map +1 -0
- package/dist/actions/import-agent-pack.js +116 -0
- package/dist/actions/import-agent-pack.js.map +1 -0
- package/dist/actions/import-agent.d.ts +60 -0
- package/dist/actions/import-agent.d.ts.map +1 -0
- package/dist/actions/import-agent.js +82 -0
- package/dist/actions/import-agent.js.map +1 -0
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +10 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-agent-pack.d.ts +36 -0
- package/dist/actions/list-agent-pack.d.ts.map +1 -0
- package/dist/actions/list-agent-pack.js +32 -0
- package/dist/actions/list-agent-pack.js.map +1 -0
- package/dist/actions/list-workspace-resource-options.d.ts +1 -1
- package/dist/actions/list-workspace-resource-options.js +8 -1
- package/dist/actions/list-workspace-resource-options.js.map +1 -1
- package/dist/actions/list-workspace-resources.d.ts +1 -1
- package/dist/actions/list-workspace-resources.js +8 -1
- package/dist/actions/list-workspace-resources.js.map +1 -1
- package/dist/actions/navigate.js +1 -1
- package/dist/actions/navigate.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +10 -10
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/actions/view-screen.js +18 -1
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -8
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +33 -106
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/simple-agents-panel.d.ts +9 -0
- package/dist/components/simple-agents-panel.d.ts.map +1 -0
- package/dist/components/simple-agents-panel.js +405 -0
- package/dist/components/simple-agents-panel.js.map +1 -0
- package/dist/components/workspace-app-host.d.ts +29 -0
- package/dist/components/workspace-app-host.d.ts.map +1 -0
- package/dist/components/workspace-app-host.js +145 -0
- package/dist/components/workspace-app-host.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -4
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +25 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/schema.js +1 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/hooks/use-navigation-state.d.ts +1 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +10 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/lib/agent-pack.d.ts +31 -0
- package/dist/lib/agent-pack.d.ts.map +1 -0
- package/dist/lib/agent-pack.js +149 -0
- package/dist/lib/agent-pack.js.map +1 -0
- package/dist/lib/simple-agent-profile.d.ts +36 -0
- package/dist/lib/simple-agent-profile.d.ts.map +1 -0
- package/dist/lib/simple-agent-profile.js +183 -0
- package/dist/lib/simple-agent-profile.js.map +1 -0
- package/dist/routes/pages/agents.d.ts.map +1 -1
- package/dist/routes/pages/agents.js +11 -1
- package/dist/routes/pages/agents.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +4 -95
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +62 -10
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/server/lib/agent-pack-store.d.ts +24 -0
- package/dist/server/lib/agent-pack-store.d.ts.map +1 -0
- package/dist/server/lib/agent-pack-store.js +22 -0
- package/dist/server/lib/agent-pack-store.js.map +1 -0
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +4 -0
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts +11 -0
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +155 -9
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/workspace-resources-store.d.ts +15 -1
- package/dist/server/lib/workspace-resources-store.d.ts.map +1 -1
- package/dist/server/lib/workspace-resources-store.js +11 -2
- package/dist/server/lib/workspace-resources-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +13 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/dist/shared/feature-flags.d.ts +7 -0
- package/dist/shared/feature-flags.d.ts.map +1 -0
- package/dist/shared/feature-flags.js +12 -0
- package/dist/shared/feature-flags.js.map +1 -0
- package/dist/shared/workspace-sso.d.ts +36 -0
- package/dist/shared/workspace-sso.d.ts.map +1 -0
- package/dist/shared/workspace-sso.js +99 -0
- package/dist/shared/workspace-sso.js.map +1 -0
- package/package.json +5 -3
- package/src/actions/connect-external-agent.ts +92 -0
- package/src/actions/create-workspace-resource.ts +9 -2
- package/src/actions/create_workspace_app_embed_session.ts +32 -0
- package/src/actions/import-agent-pack.ts +146 -0
- package/src/actions/import-agent.ts +102 -0
- package/src/actions/index.spec.ts +13 -0
- package/src/actions/index.ts +10 -0
- package/src/actions/list-agent-pack.ts +43 -0
- package/src/actions/list-workspace-resource-options.ts +8 -1
- package/src/actions/list-workspace-resources.ts +8 -1
- package/src/actions/navigate.ts +1 -1
- package/src/actions/view-screen.spec.ts +29 -1
- package/src/actions/view-screen.ts +19 -1
- package/src/components/app-icon.spec.tsx +10 -0
- package/src/components/index.ts +1 -0
- package/src/components/layout/Layout.spec.tsx +2 -0
- package/src/components/layout/Layout.tsx +59 -171
- package/src/components/simple-agents-panel.spec.tsx +73 -0
- package/src/components/simple-agents-panel.tsx +1226 -0
- package/src/components/workspace-app-host.spec.tsx +204 -0
- package/src/components/workspace-app-host.tsx +359 -0
- package/src/db/migrations.spec.ts +5 -1
- package/src/db/migrations.ts +28 -1
- package/src/db/schema.ts +1 -1
- package/src/hooks/use-navigation-state.spec.ts +25 -0
- package/src/hooks/use-navigation-state.ts +11 -0
- package/src/lib/agent-pack.spec.ts +47 -0
- package/src/lib/agent-pack.ts +226 -0
- package/src/lib/simple-agent-profile.spec.ts +77 -0
- package/src/lib/simple-agent-profile.ts +247 -0
- package/src/routes/index.spec.ts +1 -0
- package/src/routes/pages/agents.tsx +21 -1
- package/src/routes/pages/apps.$appId.tsx +3 -240
- package/src/routes/pages/chat.spec.tsx +62 -0
- package/src/routes/pages/chat.tsx +117 -9
- package/src/server/lib/agent-pack-store.ts +35 -0
- package/src/server/lib/dispatch-store.ts +8 -0
- package/src/server/lib/mcp-gateway.spec.ts +141 -0
- package/src/server/lib/mcp-gateway.ts +227 -15
- package/src/server/lib/workspace-resources-store.ts +9 -2
- package/src/server/plugins/agent-chat.ts +13 -0
- package/src/shared/feature-flags.ts +13 -0
- package/src/shared/workspace-sso.ts +118 -0
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
discoverAgents: vi.fn(),
|
|
5
|
+
listWorkspaceApps: vi.fn(),
|
|
5
6
|
getUserSetting: vi.fn(),
|
|
6
7
|
getOrgSetting: vi.fn(),
|
|
7
8
|
createEmbedSessionTicket: vi.fn(),
|
|
@@ -18,12 +19,26 @@ const mocks = vi.hoisted(() => ({
|
|
|
18
19
|
signA2AToken: vi.fn(),
|
|
19
20
|
getOrgA2ASecret: vi.fn(),
|
|
20
21
|
getOrgDomain: vi.fn(),
|
|
22
|
+
isFeatureFlagEnabled: vi.fn(),
|
|
21
23
|
}));
|
|
22
24
|
|
|
23
25
|
vi.mock("@agent-native/core/server/agent-discovery", () => ({
|
|
24
26
|
discoverAgents: mocks.discoverAgents,
|
|
25
27
|
}));
|
|
26
28
|
|
|
29
|
+
vi.mock("./app-creation-store.js", () => ({
|
|
30
|
+
listWorkspaceApps: mocks.listWorkspaceApps,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("@agent-native/core/feature-flags", async (importOriginal) => {
|
|
34
|
+
const actual =
|
|
35
|
+
await importOriginal<typeof import("@agent-native/core/feature-flags")>();
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
isFeatureFlagEnabled: mocks.isFeatureFlagEnabled,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
27
42
|
vi.mock("@agent-native/core/settings", () => ({
|
|
28
43
|
getUserSetting: mocks.getUserSetting,
|
|
29
44
|
getOrgSetting: mocks.getOrgSetting,
|
|
@@ -89,6 +104,7 @@ import { runWithRequestContext } from "@agent-native/core/server";
|
|
|
89
104
|
|
|
90
105
|
import {
|
|
91
106
|
createGrantedDispatchMcpEmbedSession,
|
|
107
|
+
createWorkspaceSsoEmbedSession,
|
|
92
108
|
askGrantedDispatchMcpApp,
|
|
93
109
|
getGrantedDispatchMcpAppTask,
|
|
94
110
|
listGrantedDispatchMcpApps,
|
|
@@ -111,6 +127,7 @@ beforeEach(() => {
|
|
|
111
127
|
mocks.a2aGetTask.mockReset();
|
|
112
128
|
mocks.signA2AToken.mockReset();
|
|
113
129
|
mocks.discoverAgents.mockResolvedValue([analyticsAgent]);
|
|
130
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
114
131
|
mocks.getUserSetting.mockResolvedValue({ mode: "all-apps" });
|
|
115
132
|
mocks.getOrgSetting.mockResolvedValue({ mode: "all-apps" });
|
|
116
133
|
mocks.createEmbedSessionTicket.mockResolvedValue({
|
|
@@ -138,6 +155,7 @@ beforeEach(() => {
|
|
|
138
155
|
mocks.signA2AToken.mockResolvedValue("signed-token");
|
|
139
156
|
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
140
157
|
mocks.getOrgDomain.mockResolvedValue(null);
|
|
158
|
+
mocks.isFeatureFlagEnabled.mockResolvedValue(true);
|
|
141
159
|
});
|
|
142
160
|
|
|
143
161
|
afterEach(() => {
|
|
@@ -864,6 +882,129 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
|
864
882
|
});
|
|
865
883
|
});
|
|
866
884
|
|
|
885
|
+
it("keeps workspace sign-in behind the rollout flag", async () => {
|
|
886
|
+
mocks.isFeatureFlagEnabled.mockResolvedValueOnce(false);
|
|
887
|
+
|
|
888
|
+
await expect(
|
|
889
|
+
runWithRequestContext(
|
|
890
|
+
{
|
|
891
|
+
userEmail: "owner@example.test",
|
|
892
|
+
requestOrigin: "http://localhost:8092",
|
|
893
|
+
},
|
|
894
|
+
() =>
|
|
895
|
+
createWorkspaceSsoEmbedSession({
|
|
896
|
+
app: "analytics",
|
|
897
|
+
path: "/overview",
|
|
898
|
+
}),
|
|
899
|
+
),
|
|
900
|
+
).rejects.toThrow(/not enabled/);
|
|
901
|
+
expect(mocks.managerConstructor).not.toHaveBeenCalled();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("allows an exact canonical app without requiring an MCP app grant", async () => {
|
|
905
|
+
mocks.getUserSetting.mockResolvedValue({
|
|
906
|
+
mode: "selected-apps",
|
|
907
|
+
selectedAppIds: [],
|
|
908
|
+
});
|
|
909
|
+
mocks.discoverAgents.mockResolvedValue([
|
|
910
|
+
{
|
|
911
|
+
...analyticsAgent,
|
|
912
|
+
url: "https://analytics.agent-native.com",
|
|
913
|
+
},
|
|
914
|
+
]);
|
|
915
|
+
mocks.managerCallTool.mockResolvedValueOnce({
|
|
916
|
+
structuredContent: {
|
|
917
|
+
startUrl:
|
|
918
|
+
"https://analytics.agent-native.com/_agent-native/embed/start?ticket=remote",
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const result = await runWithRequestContext(
|
|
923
|
+
{
|
|
924
|
+
userEmail: "owner@example.test",
|
|
925
|
+
requestOrigin: "https://dispatch.agent-native.com",
|
|
926
|
+
},
|
|
927
|
+
() =>
|
|
928
|
+
createWorkspaceSsoEmbedSession({
|
|
929
|
+
app: "analytics",
|
|
930
|
+
path: "/overview",
|
|
931
|
+
chrome: "minimal",
|
|
932
|
+
}),
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
expect(result).toMatchObject({
|
|
936
|
+
app: "analytics",
|
|
937
|
+
startUrl:
|
|
938
|
+
"https://analytics.agent-native.com/_agent-native/embed/start?ticket=remote",
|
|
939
|
+
});
|
|
940
|
+
expect(mocks.managerConstructor).toHaveBeenCalled();
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("allows an exact custom registry entry and rejects an unregistered external app", async () => {
|
|
944
|
+
vi.stubEnv(
|
|
945
|
+
"IDENTITY_SSO_APP_REGISTRY_JSON",
|
|
946
|
+
JSON.stringify([
|
|
947
|
+
{
|
|
948
|
+
appId: "workspace",
|
|
949
|
+
clientId: "workspace-client",
|
|
950
|
+
origin: "https://workspace.example.com",
|
|
951
|
+
callbackPath: "/_agent-native/identity/callback",
|
|
952
|
+
capabilities: ["identity-sso"],
|
|
953
|
+
},
|
|
954
|
+
]),
|
|
955
|
+
);
|
|
956
|
+
mocks.discoverAgents.mockResolvedValue([
|
|
957
|
+
{
|
|
958
|
+
id: "workspace",
|
|
959
|
+
name: "Workspace",
|
|
960
|
+
description: "Custom workspace app",
|
|
961
|
+
url: "https://workspace.example.com",
|
|
962
|
+
color: "#111827",
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
id: "unregistered",
|
|
966
|
+
name: "Unregistered",
|
|
967
|
+
description: "External app without registration",
|
|
968
|
+
url: "https://unregistered.example.com",
|
|
969
|
+
color: "#111827",
|
|
970
|
+
},
|
|
971
|
+
]);
|
|
972
|
+
mocks.managerCallTool.mockResolvedValueOnce({
|
|
973
|
+
structuredContent: {
|
|
974
|
+
startUrl:
|
|
975
|
+
"https://workspace.example.com/_agent-native/embed/start?ticket=remote",
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
await expect(
|
|
980
|
+
runWithRequestContext(
|
|
981
|
+
{
|
|
982
|
+
userEmail: "owner@example.test",
|
|
983
|
+
requestOrigin: "https://dispatch.agent-native.com",
|
|
984
|
+
},
|
|
985
|
+
() =>
|
|
986
|
+
createWorkspaceSsoEmbedSession({
|
|
987
|
+
app: "workspace",
|
|
988
|
+
path: "/home",
|
|
989
|
+
}),
|
|
990
|
+
),
|
|
991
|
+
).resolves.toMatchObject({ app: "workspace" });
|
|
992
|
+
|
|
993
|
+
await expect(
|
|
994
|
+
runWithRequestContext(
|
|
995
|
+
{
|
|
996
|
+
userEmail: "owner@example.test",
|
|
997
|
+
requestOrigin: "https://dispatch.agent-native.com",
|
|
998
|
+
},
|
|
999
|
+
() =>
|
|
1000
|
+
createWorkspaceSsoEmbedSession({
|
|
1001
|
+
app: "unregistered",
|
|
1002
|
+
path: "/home",
|
|
1003
|
+
}),
|
|
1004
|
+
),
|
|
1005
|
+
).rejects.toThrow(/not registered/);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
867
1008
|
it("rejects traversal into Dispatch-owned embed routes on sibling apps", async () => {
|
|
868
1009
|
await expect(
|
|
869
1010
|
runWithRequestContext(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { A2AClient, signA2AToken, type Task } from "@agent-native/core/a2a";
|
|
2
|
+
import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";
|
|
2
3
|
import {
|
|
3
4
|
buildMcpToolName,
|
|
4
5
|
McpClientManager,
|
|
@@ -17,6 +18,15 @@ import {
|
|
|
17
18
|
type DiscoveredAgent,
|
|
18
19
|
} from "@agent-native/core/server/agent-discovery";
|
|
19
20
|
|
|
21
|
+
import {
|
|
22
|
+
CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
|
|
23
|
+
DISPATCH_WORKSPACE_SSO_FLAG,
|
|
24
|
+
parseWorkspaceSsoAppRegistrations,
|
|
25
|
+
} from "../../shared/workspace-sso.js";
|
|
26
|
+
import {
|
|
27
|
+
listWorkspaceApps,
|
|
28
|
+
type WorkspaceAppSummary,
|
|
29
|
+
} from "./app-creation-store.js";
|
|
20
30
|
import {
|
|
21
31
|
getDispatchMcpAppAccessSettings,
|
|
22
32
|
isAppAllowedByMcpAccess,
|
|
@@ -447,6 +457,17 @@ function safeAppOrigin(app: DispatchMcpAccessibleApp): string | null {
|
|
|
447
457
|
}
|
|
448
458
|
}
|
|
449
459
|
|
|
460
|
+
function isLoopbackOrigin(origin: string): boolean {
|
|
461
|
+
try {
|
|
462
|
+
const hostname = new URL(origin).hostname;
|
|
463
|
+
return ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
464
|
+
} catch {
|
|
465
|
+
// coercion-ok: only a previously validated app origin reaches this helper;
|
|
466
|
+
// malformed values are ineligible for the loopback development exception.
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
450
471
|
function appBaseUrl(app: DispatchMcpAccessibleApp): string {
|
|
451
472
|
return app.url.replace(/\/+$/, "");
|
|
452
473
|
}
|
|
@@ -588,6 +609,116 @@ export async function resolveGrantedDispatchMcpApp(
|
|
|
588
609
|
return match;
|
|
589
610
|
}
|
|
590
611
|
|
|
612
|
+
function workspaceSsoOriginForApp(appId: string): string | null {
|
|
613
|
+
const canonical =
|
|
614
|
+
CANONICAL_WORKSPACE_SSO_APP_ORIGINS[
|
|
615
|
+
appId as keyof typeof CANONICAL_WORKSPACE_SSO_APP_ORIGINS
|
|
616
|
+
];
|
|
617
|
+
if (canonical) return canonical;
|
|
618
|
+
const custom = parseWorkspaceSsoAppRegistrations(
|
|
619
|
+
process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
|
|
620
|
+
).find((registration) => registration.appId === appId);
|
|
621
|
+
return custom?.origin ?? null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
|
|
625
|
+
const origin = safeAppOrigin(app);
|
|
626
|
+
if (!origin) return false;
|
|
627
|
+
const registeredOrigin = workspaceSsoOriginForApp(app.id);
|
|
628
|
+
if (origin === registeredOrigin) return true;
|
|
629
|
+
// The built-in discovery table intentionally points at local dev ports when
|
|
630
|
+
// running outside production. Keep local development usable without ever
|
|
631
|
+
// weakening the production exact-origin check.
|
|
632
|
+
return process.env.NODE_ENV !== "production" && isLoopbackOrigin(origin)
|
|
633
|
+
? Object.prototype.hasOwnProperty.call(
|
|
634
|
+
CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
|
|
635
|
+
app.id,
|
|
636
|
+
)
|
|
637
|
+
: false;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function sameWorkspaceApp(
|
|
641
|
+
candidate: DispatchMcpAccessibleApp,
|
|
642
|
+
mounted: WorkspaceAppSummary,
|
|
643
|
+
): boolean {
|
|
644
|
+
if (candidate.id !== mounted.id || !mounted.url) return false;
|
|
645
|
+
return candidate.url.replace(/\/+$/, "") === mounted.url.replace(/\/+$/, "");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function isEligibleWorkspaceSsoApp(
|
|
649
|
+
candidate: DispatchMcpAccessibleApp,
|
|
650
|
+
mountedApps: WorkspaceAppSummary[],
|
|
651
|
+
): Promise<boolean> {
|
|
652
|
+
const origin = safeAppOrigin(candidate);
|
|
653
|
+
if (!origin) return false;
|
|
654
|
+
if (isWorkspaceSsoCanonicalApp(candidate)) return true;
|
|
655
|
+
|
|
656
|
+
const customOrigin = workspaceSsoOriginForApp(candidate.id);
|
|
657
|
+
if (customOrigin === origin) return true;
|
|
658
|
+
|
|
659
|
+
const dispatchOrigin = safeAppOrigin({
|
|
660
|
+
id: DISPATCH_APP_ID,
|
|
661
|
+
name: DISPATCH_NAME,
|
|
662
|
+
description: DISPATCH_DESCRIPTION,
|
|
663
|
+
url: dispatchSelfBaseUrl(),
|
|
664
|
+
color: DISPATCH_COLOR,
|
|
665
|
+
granted: true,
|
|
666
|
+
});
|
|
667
|
+
if (origin !== dispatchOrigin) return false;
|
|
668
|
+
return mountedApps.some((mounted) => sameWorkspaceApp(candidate, mounted));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
|
|
672
|
+
const [agents, mountedApps] = await Promise.all([
|
|
673
|
+
discoverAgents("dispatch"),
|
|
674
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
675
|
+
]);
|
|
676
|
+
const candidates = agents
|
|
677
|
+
.filter((agent) => normalizeAppId(agent.id) !== DISPATCH_APP_ID)
|
|
678
|
+
.map((agent) => ({
|
|
679
|
+
...toAccessibleApp(agent, {
|
|
680
|
+
mode: "all-apps",
|
|
681
|
+
selectedAppIds: [],
|
|
682
|
+
}),
|
|
683
|
+
granted: true,
|
|
684
|
+
}));
|
|
685
|
+
const eligible = [] as DispatchMcpAccessibleApp[];
|
|
686
|
+
for (const candidate of candidates) {
|
|
687
|
+
if (await isEligibleWorkspaceSsoApp(candidate, mountedApps)) {
|
|
688
|
+
eligible.push(candidate);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return [
|
|
692
|
+
{
|
|
693
|
+
id: DISPATCH_APP_ID,
|
|
694
|
+
name: DISPATCH_NAME,
|
|
695
|
+
description: DISPATCH_DESCRIPTION,
|
|
696
|
+
url: dispatchSelfBaseUrl(),
|
|
697
|
+
color: DISPATCH_COLOR,
|
|
698
|
+
granted: true,
|
|
699
|
+
},
|
|
700
|
+
...eligible,
|
|
701
|
+
];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function resolveWorkspaceSsoApp(
|
|
705
|
+
app: string,
|
|
706
|
+
): Promise<DispatchMcpAccessibleApp> {
|
|
707
|
+
const target = normalizeAppId(app);
|
|
708
|
+
if (!target) throw new Error("app is required");
|
|
709
|
+
const apps = await listWorkspaceSsoApps();
|
|
710
|
+
const match = apps.find(
|
|
711
|
+
(candidate) =>
|
|
712
|
+
candidate.id === target || candidate.name.toLowerCase() === target,
|
|
713
|
+
);
|
|
714
|
+
if (!match) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`Workspace app "${app}" is not registered for workspace sign-in.`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
return match;
|
|
720
|
+
}
|
|
721
|
+
|
|
591
722
|
export async function askGrantedDispatchMcpApp(
|
|
592
723
|
app: string,
|
|
593
724
|
message: string,
|
|
@@ -860,13 +991,20 @@ async function callTargetCreateEmbedSession(input: {
|
|
|
860
991
|
}
|
|
861
992
|
}
|
|
862
993
|
|
|
863
|
-
async function
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
994
|
+
async function resolveEmbedTarget(
|
|
995
|
+
input: {
|
|
996
|
+
app?: string;
|
|
997
|
+
url?: string;
|
|
998
|
+
path?: string;
|
|
999
|
+
},
|
|
1000
|
+
options: {
|
|
1001
|
+
resolveApp: (app: string) => Promise<DispatchMcpAccessibleApp>;
|
|
1002
|
+
listApps: () => Promise<DispatchMcpAccessibleApp[]>;
|
|
1003
|
+
urlError: string;
|
|
1004
|
+
},
|
|
1005
|
+
): Promise<{ app: DispatchMcpAccessibleApp; path: string; url: string }> {
|
|
868
1006
|
const explicitApp = input.app?.trim()
|
|
869
|
-
? await
|
|
1007
|
+
? await options.resolveApp(input.app)
|
|
870
1008
|
: null;
|
|
871
1009
|
if (explicitApp && input.path) {
|
|
872
1010
|
const path = safeAppPath(input.path);
|
|
@@ -899,14 +1037,12 @@ async function resolveDispatchEmbedTarget(input: {
|
|
|
899
1037
|
};
|
|
900
1038
|
}
|
|
901
1039
|
|
|
902
|
-
const apps = explicitApp ? [explicitApp] : await
|
|
1040
|
+
const apps = explicitApp ? [explicitApp] : await options.listApps();
|
|
903
1041
|
const target = apps
|
|
904
1042
|
.filter((app) => appMatchesUrlPath(app, parsed))
|
|
905
1043
|
.sort((a, b) => appPathSpecificity(b) - appPathSpecificity(a))[0];
|
|
906
1044
|
if (!target) {
|
|
907
|
-
throw new Error(
|
|
908
|
-
"Embed URL must belong to an app granted through Dispatch.",
|
|
909
|
-
);
|
|
1045
|
+
throw new Error(options.urlError);
|
|
910
1046
|
}
|
|
911
1047
|
const path = safeAppPath(appRelativePath(target, parsed));
|
|
912
1048
|
if (!path) throw new Error("Embed URL path is not safe.");
|
|
@@ -914,6 +1050,31 @@ async function resolveDispatchEmbedTarget(input: {
|
|
|
914
1050
|
return { app: target, path, url: `${appBaseUrl(target)}${path}` };
|
|
915
1051
|
}
|
|
916
1052
|
|
|
1053
|
+
async function resolveDispatchEmbedTarget(input: {
|
|
1054
|
+
app?: string;
|
|
1055
|
+
url?: string;
|
|
1056
|
+
path?: string;
|
|
1057
|
+
}): Promise<{ app: DispatchMcpAccessibleApp; path: string; url: string }> {
|
|
1058
|
+
return resolveEmbedTarget(input, {
|
|
1059
|
+
resolveApp: resolveGrantedDispatchMcpApp,
|
|
1060
|
+
listApps: listGrantedDispatchMcpApps,
|
|
1061
|
+
urlError: "Embed URL must belong to an app granted through Dispatch.",
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function resolveWorkspaceSsoEmbedTarget(input: {
|
|
1066
|
+
app?: string;
|
|
1067
|
+
url?: string;
|
|
1068
|
+
path?: string;
|
|
1069
|
+
}): Promise<{ app: DispatchMcpAccessibleApp; path: string; url: string }> {
|
|
1070
|
+
return resolveEmbedTarget(input, {
|
|
1071
|
+
resolveApp: resolveWorkspaceSsoApp,
|
|
1072
|
+
listApps: listWorkspaceSsoApps,
|
|
1073
|
+
urlError:
|
|
1074
|
+
"Embed URL must belong to an app registered for Dispatch workspace sign-in.",
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
917
1078
|
async function createDispatchSelfEmbedSession(input: {
|
|
918
1079
|
ownerEmail: string;
|
|
919
1080
|
orgId?: string;
|
|
@@ -956,14 +1117,34 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
|
956
1117
|
if (!userEmail) throw new Error("no authenticated user");
|
|
957
1118
|
const target = await resolveDispatchEmbedTarget(input);
|
|
958
1119
|
|
|
959
|
-
|
|
1120
|
+
return createEmbedSessionForResolvedApp({
|
|
1121
|
+
ownerEmail: userEmail,
|
|
1122
|
+
orgId: getRequestOrgId(),
|
|
1123
|
+
target,
|
|
1124
|
+
chrome: input.chrome,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function createEmbedSessionForResolvedApp(input: {
|
|
1129
|
+
ownerEmail: string;
|
|
1130
|
+
orgId?: string;
|
|
1131
|
+
target: { app: DispatchMcpAccessibleApp; path: string; url: string };
|
|
1132
|
+
chrome?: "full" | "minimal";
|
|
1133
|
+
}): Promise<{
|
|
1134
|
+
startUrl: string;
|
|
1135
|
+
targetPath?: string;
|
|
1136
|
+
expiresAt?: number;
|
|
1137
|
+
app: string;
|
|
1138
|
+
}> {
|
|
1139
|
+
const { ownerEmail, orgId, target, chrome } = input;
|
|
1140
|
+
|
|
960
1141
|
if (target.app.id === DISPATCH_APP_ID) {
|
|
961
1142
|
return createDispatchSelfEmbedSession({
|
|
962
|
-
ownerEmail
|
|
1143
|
+
ownerEmail,
|
|
963
1144
|
orgId,
|
|
964
1145
|
path: target.path,
|
|
965
1146
|
baseUrl: appBaseUrl(target.app),
|
|
966
|
-
chrome
|
|
1147
|
+
chrome,
|
|
967
1148
|
});
|
|
968
1149
|
}
|
|
969
1150
|
|
|
@@ -980,7 +1161,7 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
|
980
1161
|
const useOrgSigning = usableOrgDomain && usableOrgSecret;
|
|
981
1162
|
const signedOrgDomain = usableOrgDomain ? orgDomain.trim() : undefined;
|
|
982
1163
|
const token = await signA2AToken(
|
|
983
|
-
|
|
1164
|
+
ownerEmail,
|
|
984
1165
|
signedOrgDomain,
|
|
985
1166
|
useOrgSigning ? orgSecret.trim() : undefined,
|
|
986
1167
|
{
|
|
@@ -996,7 +1177,7 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
|
996
1177
|
app: target.app,
|
|
997
1178
|
token,
|
|
998
1179
|
url: target.url,
|
|
999
|
-
chrome
|
|
1180
|
+
chrome,
|
|
1000
1181
|
});
|
|
1001
1182
|
const parsed = parseMcpToolTextResult(result) as {
|
|
1002
1183
|
startUrl?: string;
|
|
@@ -1019,3 +1200,34 @@ export async function createGrantedDispatchMcpEmbedSession(input: {
|
|
|
1019
1200
|
if (typeof parsed.expiresAt === "number") output.expiresAt = parsed.expiresAt;
|
|
1020
1201
|
return output;
|
|
1021
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
export async function createWorkspaceSsoEmbedSession(input: {
|
|
1205
|
+
app?: string;
|
|
1206
|
+
url?: string;
|
|
1207
|
+
path?: string;
|
|
1208
|
+
chrome?: "full" | "minimal";
|
|
1209
|
+
}): Promise<{
|
|
1210
|
+
startUrl: string;
|
|
1211
|
+
targetPath?: string;
|
|
1212
|
+
expiresAt?: number;
|
|
1213
|
+
app: string;
|
|
1214
|
+
}> {
|
|
1215
|
+
const ownerEmail = getRequestUserEmail();
|
|
1216
|
+
if (!ownerEmail) throw new Error("no authenticated user");
|
|
1217
|
+
const enabled = await isFeatureFlagEnabled(DISPATCH_WORKSPACE_SSO_FLAG, {
|
|
1218
|
+
userEmail: ownerEmail,
|
|
1219
|
+
userKey: ownerEmail,
|
|
1220
|
+
orgId: getRequestOrgId(),
|
|
1221
|
+
});
|
|
1222
|
+
if (!enabled) {
|
|
1223
|
+
throw new Error("Dispatch workspace sign-in is not enabled.");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const target = await resolveWorkspaceSsoEmbedTarget(input);
|
|
1227
|
+
return createEmbedSessionForResolvedApp({
|
|
1228
|
+
ownerEmail,
|
|
1229
|
+
orgId: getRequestOrgId(),
|
|
1230
|
+
target,
|
|
1231
|
+
chrome: input.chrome,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
@@ -83,7 +83,13 @@ interface MaterializableWorkspaceResource {
|
|
|
83
83
|
function mimeTypeForWorkspaceResource(
|
|
84
84
|
resource: MaterializableWorkspaceResource,
|
|
85
85
|
) {
|
|
86
|
-
|
|
86
|
+
if (resource.path.endsWith(".json")) return "application/json";
|
|
87
|
+
if (resource.path.endsWith(".txt")) return "text/plain";
|
|
88
|
+
if (resource.path.endsWith(".csv")) return "text/csv";
|
|
89
|
+
if (resource.path.endsWith(".yaml") || resource.path.endsWith(".yml")) {
|
|
90
|
+
return "text/yaml";
|
|
91
|
+
}
|
|
92
|
+
return "text/markdown";
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
function parseResourceMetadata(metadata: string | null): Record<string, any> {
|
|
@@ -188,6 +194,7 @@ export type WorkspaceResourceKind =
|
|
|
188
194
|
| "skill"
|
|
189
195
|
| "instruction"
|
|
190
196
|
| "agent"
|
|
197
|
+
| "agent-file"
|
|
191
198
|
| "knowledge"
|
|
192
199
|
| "mcp-server";
|
|
193
200
|
export type WorkspaceResourceScope = "all" | "selected";
|
|
@@ -526,7 +533,7 @@ async function writeStarterSeedMarker(ctx: WorkspaceResourceCtx) {
|
|
|
526
533
|
}
|
|
527
534
|
}
|
|
528
535
|
|
|
529
|
-
async function getWorkspaceResourceByPath(
|
|
536
|
+
export async function getWorkspaceResourceByPath(
|
|
530
537
|
resourcePath: string,
|
|
531
538
|
ctx: WorkspaceResourceCtx,
|
|
532
539
|
) {
|
|
@@ -13,6 +13,10 @@ const INITIAL_TOOL_NAMES = [
|
|
|
13
13
|
"list-workspace-resources",
|
|
14
14
|
"create-workspace-resource",
|
|
15
15
|
"update-workspace-resource",
|
|
16
|
+
"import-agent",
|
|
17
|
+
"import-agent-pack",
|
|
18
|
+
"list-agent-pack",
|
|
19
|
+
"connect-external-agent",
|
|
16
20
|
"list-vault-secrets",
|
|
17
21
|
"request-vault-secret",
|
|
18
22
|
"create-vault-secret",
|
|
@@ -62,6 +66,15 @@ Use the standard workspace primitives:
|
|
|
62
66
|
- Read and update resources like AGENTS.md, LEARNINGS.md, jobs/*.md, agents/*.md, and remote-agents/*.json when appropriate.
|
|
63
67
|
- Use recurring jobs for scheduled behavior.
|
|
64
68
|
- Use custom agent profiles in agents/*.md for local spawned work and remote-agents/*.json for remote A2A apps.
|
|
69
|
+
- For a Claude-style Markdown or JSON agent setup, use import-agent so the
|
|
70
|
+
profile is normalized into agents/<slug>.md. Never import credentials, hooks,
|
|
71
|
+
shell commands, or local environment settings.
|
|
72
|
+
- For a folder of agent instructions, references, or skills, use
|
|
73
|
+
import-agent-pack. It preserves the text files under agents/<slug>/ and
|
|
74
|
+
creates the normalized profile alongside them. Use list-agent-pack to inspect
|
|
75
|
+
the pack before editing or handing it off to an app.
|
|
76
|
+
- For an existing HTTP/A2A endpoint, use connect-external-agent and let the
|
|
77
|
+
normal A2A/MCP connection flow handle authentication.
|
|
65
78
|
- You receive a compact available-apps block with sibling workspace app names and descriptions. Use it to pick the right A2A target, and call list-connected-agents or tool-search only when you need fresh details.
|
|
66
79
|
- Hosted/connected A2A neighbors such as Analytics and Content come from the available-apps context or list-connected-agents. list-workspace-apps only inventories apps mounted inside this workspace deployment; never use a missing row there to conclude that a connected agent is unavailable.
|
|
67
80
|
- When answering whether a mounted workspace app exposes an agent card or A2A endpoint, call list-workspace-apps with includeAgentCards=true. If you have not requested that probe, absence of agent-card fields means unchecked, not unavailable.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineFeatureFlag } from "@agent-native/core/feature-flags";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser-only rollout for app-scoped sessions in Dispatch panes. The server
|
|
5
|
+
* action checks this flag too; the client hook only selects the presentation
|
|
6
|
+
* path and keeps the legacy embed route available while the flag is off.
|
|
7
|
+
*/
|
|
8
|
+
export const DISPATCH_WORKSPACE_SSO_FLAG = defineFeatureFlag({
|
|
9
|
+
key: "dispatch.workspace-sso",
|
|
10
|
+
displayName: "Dispatch workspace sign-in",
|
|
11
|
+
description:
|
|
12
|
+
"Let Dispatch use the signed-in workspace identity for exact registered app panes.",
|
|
13
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { DISPATCH_WORKSPACE_SSO_FLAG } from "./feature-flags.js";
|
|
2
|
+
|
|
3
|
+
export { DISPATCH_WORKSPACE_SSO_FLAG };
|
|
4
|
+
|
|
5
|
+
/** Exact first-party origins. Never replace this with a suffix or wildcard. */
|
|
6
|
+
export const CANONICAL_WORKSPACE_SSO_APP_ORIGINS = {
|
|
7
|
+
analytics: "https://analytics.agent-native.com",
|
|
8
|
+
assets: "https://assets.agent-native.com",
|
|
9
|
+
brain: "https://brain.agent-native.com",
|
|
10
|
+
calendar: "https://calendar.agent-native.com",
|
|
11
|
+
chat: "https://chat.agent-native.com",
|
|
12
|
+
clips: "https://clips.agent-native.com",
|
|
13
|
+
content: "https://content.agent-native.com",
|
|
14
|
+
crm: "https://crm.agent-native.com",
|
|
15
|
+
design: "https://design.agent-native.com",
|
|
16
|
+
dispatch: "https://dispatch.agent-native.com",
|
|
17
|
+
forms: "https://forms.agent-native.com",
|
|
18
|
+
macros: "https://macros.agent-native.com",
|
|
19
|
+
mail: "https://mail.agent-native.com",
|
|
20
|
+
plan: "https://plan.agent-native.com",
|
|
21
|
+
slides: "https://slides.agent-native.com",
|
|
22
|
+
tasks: "https://tasks.agent-native.com",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const WORKSPACE_SSO_CALLBACK_PATH =
|
|
26
|
+
"/_agent-native/identity/callback" as const;
|
|
27
|
+
|
|
28
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
29
|
+
const APP_ID = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
30
|
+
const CLIENT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
31
|
+
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
32
|
+
|
|
33
|
+
export interface WorkspaceSsoAppRegistration {
|
|
34
|
+
appId: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
origin: string;
|
|
37
|
+
callbackPath: typeof WORKSPACE_SSO_CALLBACK_PATH;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function exactWorkspaceSsoOrigin(raw: unknown): string | null {
|
|
41
|
+
if (typeof raw !== "string" || !raw || CONTROL_CHARS.test(raw)) return null;
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(raw);
|
|
44
|
+
if (
|
|
45
|
+
url.protocol !== "https:" &&
|
|
46
|
+
!(url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname))
|
|
47
|
+
) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
url.username ||
|
|
52
|
+
url.password ||
|
|
53
|
+
url.pathname !== "/" ||
|
|
54
|
+
url.search ||
|
|
55
|
+
url.hash
|
|
56
|
+
) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return url.origin;
|
|
60
|
+
} catch {
|
|
61
|
+
// coercion-ok: malformed registry origins are rejected as absent.
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse the same additive custom registry used by browser identity SSO. The
|
|
68
|
+
* raw value is passed in so this shared module stays safe to import in the
|
|
69
|
+
* browser bundle.
|
|
70
|
+
*/
|
|
71
|
+
export function parseWorkspaceSsoAppRegistrations(
|
|
72
|
+
raw: unknown,
|
|
73
|
+
): WorkspaceSsoAppRegistration[] {
|
|
74
|
+
if (typeof raw !== "string" || !raw.trim()) return [];
|
|
75
|
+
let value: unknown;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
// coercion-ok: malformed custom registry data must fail closed.
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(value)) return [];
|
|
83
|
+
|
|
84
|
+
const registrations: WorkspaceSsoAppRegistration[] = [];
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
for (const entry of value) {
|
|
87
|
+
if (!entry || typeof entry !== "object") continue;
|
|
88
|
+
const candidate = entry as Record<string, unknown>;
|
|
89
|
+
const appId = candidate.appId;
|
|
90
|
+
const clientId = candidate.clientId;
|
|
91
|
+
const origin = exactWorkspaceSsoOrigin(candidate.origin);
|
|
92
|
+
if (
|
|
93
|
+
typeof appId !== "string" ||
|
|
94
|
+
!APP_ID.test(appId) ||
|
|
95
|
+
typeof clientId !== "string" ||
|
|
96
|
+
!CLIENT_ID.test(clientId) ||
|
|
97
|
+
!origin ||
|
|
98
|
+
candidate.callbackPath !== WORKSPACE_SSO_CALLBACK_PATH ||
|
|
99
|
+
!Array.isArray(candidate.capabilities) ||
|
|
100
|
+
!candidate.capabilities.includes("identity-sso") ||
|
|
101
|
+
seen.has(appId) ||
|
|
102
|
+
Object.prototype.hasOwnProperty.call(
|
|
103
|
+
CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
|
|
104
|
+
appId,
|
|
105
|
+
)
|
|
106
|
+
) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
seen.add(appId);
|
|
110
|
+
registrations.push({
|
|
111
|
+
appId,
|
|
112
|
+
clientId,
|
|
113
|
+
origin,
|
|
114
|
+
callbackPath: WORKSPACE_SSO_CALLBACK_PATH,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return registrations;
|
|
118
|
+
}
|