Repository: All-Hands-AI/OpenHands
Pull request: [Feat]: Gitlab p2 - let user add PAT via FE
- [x] This change is worth documenting at https://docs.all-hands.dev/
- [x] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below

**End-user friendly description of the problem this fixes or functionality that this introduces.**
- User's can configure multiple providers at once (Github + Gitlab) for local OpenHands
- Add your PATs in the settings page

---
**Give a summary of what the PR does, explaining any non-trivial design decisions.**


---
**Link of any specific issues this addresses.**
Closes #7080

---

To run this PR locally, use the following command:
```
docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0126b6f-nikolaik   --name openhands-app-0126b6f   docker.all-hands.dev/all-hands-ai/openhands:0126b6f
```

Problem statement:
Support GitLab PAT
**What problem or use case are you trying to solve?**
We should support GitLab PAT in Openhands (for auth, repository lists, etc)

**Describe the UX of the solution you'd like**
Use the same PAT field in the settings page; it should accept either a Github or Gitlab token to auth the user in

**Do you have thoughts on the technical implementation?**

**Describe alternatives you've considered**

**Additional context**

Hints from issue discussion:
I am awaiting the completion of this task, which enables the development of support for other Git providers, such as Azure DevOps and/or AWS CodeCommit.
> I am awaiting the completion of this task, which enables the development of support for other Git providers, such as Azure DevOps and/or AWS CodeCommit.

I'm using Azure DevOps, starting from an empty prompt, and asking OH to clone the repo from Azure DevOps using the PAT I've provided via env var. 
forĝejo too please
https://forgejo.org
Gitea too please!

https://about.gitea.com/

Commit message: fix merge overrwrites

Patch to review:
diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx
index f06c22839022..4ce0bfb818bb 100644
--- a/frontend/__tests__/routes/settings.test.tsx
+++ b/frontend/__tests__/routes/settings.test.tsx
@@ -74,7 +74,7 @@ describe("Settings Screen", () => {
 
     await waitFor(() => {
       screen.getByText("LLM Settings");
-      screen.getByText("GitHub Settings");
+      screen.getByText("Provider Settings");
       screen.getByText("Additional Settings");
       screen.getByText("Reset to defaults");
       screen.getByText("Save Changes");
@@ -105,7 +105,6 @@ describe("Settings Screen", () => {
     it.skip("should render an indicator if the GitHub token is not set", async () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: false,
       });
 
       renderSettingsScreen();
@@ -126,7 +125,6 @@ describe("Settings Screen", () => {
     it("should set asterik placeholder if the GitHub token is set", async () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: true,
       });
 
       renderSettingsScreen();
@@ -140,7 +138,6 @@ describe("Settings Screen", () => {
     it("should render an indicator if the GitHub token is set", async () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: true,
       });
 
       renderSettingsScreen();
@@ -156,27 +153,25 @@ describe("Settings Screen", () => {
       }
     });
 
-    it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
+    it("should render a disabled 'Disconnect Tokens' button if the GitHub token is not set", async () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: false,
       });
 
       renderSettingsScreen();
 
-      const button = await screen.findByText("Disconnect from GitHub");
+      const button = await screen.findByText("Disconnect Tokens");
       expect(button).toBeInTheDocument();
       expect(button).toBeDisabled();
     });
 
-    it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
+    it("should render an enabled 'Disconnect Tokens' button if the GitHub token is set", async () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: true,
       });
 
       renderSettingsScreen();
-      const button = await screen.findByText("Disconnect from GitHub");
+      const button = await screen.findByText("Disconnect Tokens");
       expect(button).toBeInTheDocument();
       expect(button).toBeEnabled();
 
@@ -185,17 +180,16 @@ describe("Settings Screen", () => {
       expect(input).toBeInTheDocument();
     });
 
-    it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
+    it("should logout the user when the 'Disconnect Tokens' button is clicked", async () => {
       const user = userEvent.setup();
 
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: true,
       });
 
       renderSettingsScreen();
 
-      const button = await screen.findByText("Disconnect from GitHub");
+      const button = await screen.findByText("Disconnect Tokens");
       await user.click(button);
 
       expect(handleLogoutMock).toHaveBeenCalled();
@@ -248,7 +242,6 @@ describe("Settings Screen", () => {
       const user = userEvent.setup();
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
-        github_token_is_set: false,
         llm_model: "anthropic/claude-3-5-sonnet-20241022",
       });
       saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
@@ -668,7 +661,6 @@ describe("Settings Screen", () => {
       getSettingsSpy.mockResolvedValue({
         ...MOCK_DEFAULT_USER_SETTINGS,
         language: "no",
-        github_token_is_set: true,
         user_consents_to_analytics: true,
         llm_base_url: "https://test.com",
         llm_model: "anthropic/claude-3-5-sonnet-20241022",
@@ -680,7 +672,7 @@ describe("Settings Screen", () => {
 
       await waitFor(() => {
         expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
-        expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
+        expect(screen.getByText("Disconnect Tokens")).toBeInTheDocument();
         expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
         expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
         expect(screen.getByTestId("base-url-input")).toHaveValue(
@@ -721,7 +713,6 @@ describe("Settings Screen", () => {
       expect(saveSettingsSpy).toHaveBeenCalledWith(
         expect.objectContaining({
           llm_api_key: "", // empty because it's not set previously
-          provider_tokens: undefined,
           language: "no",
         }),
       );
@@ -758,7 +749,6 @@ describe("Settings Screen", () => {
 
       expect(saveSettingsSpy).toHaveBeenCalledWith(
         expect.objectContaining({
-          provider_tokens: undefined,
           llm_api_key: "", // empty because it's not set previously
           llm_model: "openai/gpt-4o",
         }),
@@ -795,13 +785,12 @@ describe("Settings Screen", () => {
       const mockCopy: Partial<PostApiSettings> = {
         ...MOCK_DEFAULT_USER_SETTINGS,
       };
-      delete mockCopy.github_token_is_set;
-      delete mockCopy.unset_github_token;
+      delete mockCopy.provider_tokens_set;
+      delete mockCopy.unset_tokens;
       delete mockCopy.user_consents_to_analytics;
 
       expect(saveSettingsSpy).toHaveBeenCalledWith({
         ...mockCopy,
-        provider_tokens: undefined, // not set
         llm_api_key: "", // reset as well
       });
       expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts
index 4b83229f3ef1..6f1497da349a 100644
--- a/frontend/src/api/github.ts
+++ b/frontend/src/api/github.ts
@@ -15,7 +15,7 @@ export const retrieveGitHubAppRepositories = async (
 ) => {
   const installationId = installations[installationIndex];
   const response = await openHands.get<GitHubRepository[]>(
-    "/api/github/repositories",
+    "/api/user/repositories",
     {
       params: {
         sort: "pushed",
@@ -57,8 +57,15 @@ export const retrieveGitHubUserRepositories = async (
   page = 1,
   per_page = 30,
 ) => {
-  const response = await openHands.get<GitHubRepository[]>(
-    "/api/github/repositories",
+  const response = await openHands.get<{
+    repositories: GitHubRepository[],
+    pagination: {
+      total_count: number,
+      has_more: boolean,
+      provider_cursors: Record<string, string>
+    }
+  }>(
+    "/api/user/repositories",
     {
       params: {
         sort: "pushed",
@@ -68,11 +75,17 @@ export const retrieveGitHubUserRepositories = async (
     },
   );
 
-  const link =
-    response.data.length > 0 && response.data[0].link_header
-      ? response.data[0].link_header
-      : "";
-  const nextPage = extractNextPageFromLink(link);
+  // Check if any provider has more results
+  const hasMore = response.data.pagination.has_more;
+  
+  // For backward compatibility, still use link_header if available
+  const githubCursor = response.data.pagination.provider_cursors?.github;
+  const nextPage = githubCursor ? extractNextPageFromLink(githubCursor) : hasMore ? page + 1 : null;
 
-  return { data: response.data, nextPage };
+  return { 
+    data: response.data.repositories,
+    nextPage,
+    totalCount: response.data.pagination.total_count,
+    providerCursors: response.data.pagination.provider_cursors
+  };
 };
diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts
index b6d6550998b9..a0185ec8a55e 100644
--- a/frontend/src/api/open-hands.ts
+++ b/frontend/src/api/open-hands.ts
@@ -296,7 +296,7 @@ class OpenHands {
   }
 
   static async getGitHubUser(): Promise<GitHubUser> {
-    const response = await openHands.get<GitHubUser>("/api/github/user");
+    const response = await openHands.get<GitHubUser>("/api/user/info");
 
     const { data } = response;
 
@@ -313,7 +313,7 @@ class OpenHands {
   }
 
   static async getGitHubUserInstallationIds(): Promise<number[]> {
-    const response = await openHands.get<number[]>("/api/github/installations");
+    const response = await openHands.get<number[]>("/api/user/installations");
     return response.data;
   }
 
@@ -322,7 +322,7 @@ class OpenHands {
     per_page = 5,
   ): Promise<GitHubRepository[]> {
     const response = await openHands.get<GitHubRepository[]>(
-      "/api/github/search/repositories",
+      "/api/user/search/repositories",
       {
         params: {
           query,
diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx
index ec0917e49ea0..ac126da0405b 100644
--- a/frontend/src/components/features/chat/action-suggestions.tsx
+++ b/frontend/src/components/features/chat/action-suggestions.tsx
@@ -13,7 +13,7 @@ interface ActionSuggestionsProps {
 export function ActionSuggestions({
   onSuggestionsClick,
 }: ActionSuggestionsProps) {
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
   const { selectedRepository } = useSelector(
     (state: RootState) => state.initialQuery,
   );
@@ -32,7 +32,7 @@ export function ActionSuggestions({
         onClose={handleDownloadClose}
         isOpen={isDownloading}
       />
-      {githubTokenIsSet && selectedRepository ? (
+      {providersAreSet && selectedRepository ? (
         <div className="flex flex-row gap-2 justify-center w-full">
           {!hasPullRequest ? (
             <>
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx
index c1c516544733..19752e8dcd1a 100644
--- a/frontend/src/components/features/sidebar/sidebar.tsx
+++ b/frontend/src/components/features/sidebar/sidebar.tsx
@@ -78,7 +78,7 @@ export function Sidebar() {
 
   const handleLogout = async () => {
     if (config?.APP_MODE === "saas") await logout();
-    else await saveUserSettings({ unset_github_token: true });
+    else await saveUserSettings({ unset_tokens: true });
     posthog.reset();
   };
 
diff --git a/frontend/src/context/auth-context.tsx b/frontend/src/context/auth-context.tsx
index 3e42759b9e4b..a94b5a261ee2 100644
--- a/frontend/src/context/auth-context.tsx
+++ b/frontend/src/context/auth-context.tsx
@@ -1,27 +1,36 @@
 import React from "react";
 
 interface AuthContextType {
-  githubTokenIsSet: boolean;
-  setGitHubTokenIsSet: (value: boolean) => void;
+  providerTokensSet: Record<string, boolean>;
+  setProviderTokensSet: (tokens: Record<string, boolean>) => void;
+  providersAreSet: boolean;
+  setProvidersAreSet: (status: boolean) => void;
 }
 
 interface AuthContextProps extends React.PropsWithChildren {
-  initialGithubTokenIsSet?: boolean;
+  initialProviderTokens?: Record<string, boolean>;
 }
 
 const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
 
-function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) {
-  const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(
-    !!initialGithubTokenIsSet,
-  );
+function AuthProvider({
+  children,
+  initialProviderTokens = {},
+}: AuthContextProps) {
+  const [providerTokensSet, setProviderTokensSet] = React.useState<
+    Record<string, boolean>
+  >(initialProviderTokens);
+
+  const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
 
   const value = React.useMemo(
     () => ({
-      githubTokenIsSet,
-      setGitHubTokenIsSet,
+      providerTokensSet,
+      setProviderTokensSet,
+      providersAreSet,
+      setProvidersAreSet,
     }),
-    [githubTokenIsSet, setGitHubTokenIsSet],
+    [providerTokensSet],
   );
 
   return <AuthContext value={value}>{children}</AuthContext>;
diff --git a/frontend/src/context/settings-context.tsx b/frontend/src/context/settings-context.tsx
index 356b2ebfa133..66384d461201 100644
--- a/frontend/src/context/settings-context.tsx
+++ b/frontend/src/context/settings-context.tsx
@@ -55,7 +55,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
   const value = React.useMemo(
     () => ({
       saveUserSettings,
-      settings: userSettings,
+      settings: userSettings as Settings,
     }),
     [saveUserSettings, userSettings],
   );
diff --git a/frontend/src/hooks/mutation/use-logout.ts b/frontend/src/hooks/mutation/use-logout.ts
index 4207c1dd191f..1b47e8dab430 100644
--- a/frontend/src/hooks/mutation/use-logout.ts
+++ b/frontend/src/hooks/mutation/use-logout.ts
@@ -3,13 +3,13 @@ import OpenHands from "#/api/open-hands";
 import { useAuth } from "#/context/auth-context";
 
 export const useLogout = () => {
-  const { setGitHubTokenIsSet } = useAuth();
+  const { setProvidersAreSet } = useAuth();
   const queryClient = useQueryClient();
 
   return useMutation({
     mutationFn: OpenHands.logout,
     onSuccess: async () => {
-      setGitHubTokenIsSet(false);
+      setProvidersAreSet(false);
       await queryClient.invalidateQueries();
     },
   });
diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts
index 50791977aa6f..324c5d2fcbd6 100644
--- a/frontend/src/hooks/mutation/use-save-settings.ts
+++ b/frontend/src/hooks/mutation/use-save-settings.ts
@@ -17,11 +17,11 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
       ? ""
       : settings.LLM_API_KEY?.trim() || undefined,
     remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
-    provider_tokens: settings.provider_tokens,
-    unset_github_token: settings.unset_github_token,
     enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
     enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
     user_consents_to_analytics: settings.user_consents_to_analytics,
+    provider_tokens: settings.provider_tokens,
+    unset_tokens: settings.unset_tokens,
   };
 
   await OpenHands.saveSettings(apiSettings);
diff --git a/frontend/src/hooks/query/use-app-installations.ts b/frontend/src/hooks/query/use-app-installations.ts
index 356c0147c7cc..cb764dafe1a4 100644
--- a/frontend/src/hooks/query/use-app-installations.ts
+++ b/frontend/src/hooks/query/use-app-installations.ts
@@ -5,13 +5,13 @@ import { useAuth } from "#/context/auth-context";
 
 export const useAppInstallations = () => {
   const { data: config } = useConfig();
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
 
   return useQuery({
-    queryKey: ["installations", githubTokenIsSet, config?.GITHUB_CLIENT_ID],
+    queryKey: ["installations", providersAreSet, config?.GITHUB_CLIENT_ID],
     queryFn: OpenHands.getGitHubUserInstallationIds,
     enabled:
-      githubTokenIsSet &&
+      providersAreSet &&
       !!config?.GITHUB_CLIENT_ID &&
       config?.APP_MODE === "saas",
     staleTime: 1000 * 60 * 5, // 5 minutes
diff --git a/frontend/src/hooks/query/use-app-repositories.ts b/frontend/src/hooks/query/use-app-repositories.ts
index a38b7857aedf..e7e2669684a8 100644
--- a/frontend/src/hooks/query/use-app-repositories.ts
+++ b/frontend/src/hooks/query/use-app-repositories.ts
@@ -6,12 +6,12 @@ import { useConfig } from "./use-config";
 import { useAuth } from "#/context/auth-context";
 
 export const useAppRepositories = () => {
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
   const { data: config } = useConfig();
   const { data: installations } = useAppInstallations();
 
   const repos = useInfiniteQuery({
-    queryKey: ["repositories", githubTokenIsSet, installations],
+    queryKey: ["repositories", providersAreSet, installations],
     queryFn: async ({
       pageParam,
     }: {
@@ -46,7 +46,7 @@ export const useAppRepositories = () => {
       return null;
     },
     enabled:
-      githubTokenIsSet &&
+      providersAreSet &&
       Array.isArray(installations) &&
       installations.length > 0 &&
       config?.APP_MODE === "saas",
diff --git a/frontend/src/hooks/query/use-github-user.ts b/frontend/src/hooks/query/use-github-user.ts
index 61421946b3f7..90ca4f317da3 100644
--- a/frontend/src/hooks/query/use-github-user.ts
+++ b/frontend/src/hooks/query/use-github-user.ts
@@ -8,16 +8,16 @@ import { useLogout } from "../mutation/use-logout";
 import { useCurrentSettings } from "#/context/settings-context";
 
 export const useGitHubUser = () => {
-  const { githubTokenIsSet } = useAuth();
-  const { setGitHubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
+  const { setProvidersAreSet } = useAuth();
   const { mutateAsync: logout } = useLogout();
   const { saveUserSettings } = useCurrentSettings();
   const { data: config } = useConfig();
 
   const user = useQuery({
-    queryKey: ["user", githubTokenIsSet],
+    queryKey: ["user", providersAreSet],
     queryFn: OpenHands.getGitHubUser,
-    enabled: githubTokenIsSet && !!config?.APP_MODE,
+    enabled: providersAreSet && !!config?.APP_MODE,
     retry: false,
     staleTime: 1000 * 60 * 5, // 5 minutes
     gcTime: 1000 * 60 * 15, // 15 minutes
@@ -38,8 +38,8 @@ export const useGitHubUser = () => {
   const handleLogout = async () => {
     if (config?.APP_MODE === "saas") await logout();
     else {
-      await saveUserSettings({ unset_github_token: true });
-      setGitHubTokenIsSet(false);
+      await saveUserSettings({ unset_tokens: true });
+      setProvidersAreSet(false);
     }
     posthog.reset();
   };
diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts
index 4e49d50ec7f5..170d9dcec85d 100644
--- a/frontend/src/hooks/query/use-is-authed.ts
+++ b/frontend/src/hooks/query/use-is-authed.ts
@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
 import { useAuth } from "#/context/auth-context";
 
 export const useIsAuthed = () => {
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
   const { data: config } = useConfig();
 
   const appMode = React.useMemo(() => config?.APP_MODE, [config]);
 
   return useQuery({
-    queryKey: ["user", "authenticated", githubTokenIsSet, appMode],
+    queryKey: ["user", "authenticated", providersAreSet, appMode],
     queryFn: () => OpenHands.authenticate(appMode!),
     enabled: !!appMode,
     staleTime: 1000 * 60 * 5, // 5 minutes
diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts
index 055aa99559b5..c8358785ccc1 100644
--- a/frontend/src/hooks/query/use-settings.ts
+++ b/frontend/src/hooks/query/use-settings.ts
@@ -17,20 +17,20 @@ const getSettingsQueryFn = async () => {
     SECURITY_ANALYZER: apiSettings.security_analyzer,
     LLM_API_KEY: apiSettings.llm_api_key,
     REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
-    GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
+    PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
     ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
     ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
     USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
-    PROVIDER_TOKENS: apiSettings.provider_tokens,
     IS_NEW_USER: false,
   };
 };
 
 export const useSettings = () => {
-  const { setGitHubTokenIsSet, githubTokenIsSet } = useAuth();
+  const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
+    useAuth();
 
   const query = useQuery({
-    queryKey: ["settings", githubTokenIsSet],
+    queryKey: ["settings", providerTokensSet],
     queryFn: getSettingsQueryFn,
     // Only retry if the error is not a 404 because we
     // would want to show the modal immediately if the
@@ -50,8 +50,14 @@ export const useSettings = () => {
   }, [query.data?.LLM_API_KEY]);
 
   React.useEffect(() => {
-    setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
-  }, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
+    if (query.data?.PROVIDER_TOKENS_SET) {
+      setProviderTokensSet(query.data.PROVIDER_TOKENS_SET);
+      const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
+        (value) => value,
+      );
+      setProvidersAreSet(atLeastOneSet);
+    }
+  }, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
 
   // We want to return the defaults if the settings aren't found so the user can still see the
   // options to make their initial save. We don't set the defaults in `initialData` above because
diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts
index cb5487826854..c58ebb65d216 100644
--- a/frontend/src/hooks/query/use-user-repositories.ts
+++ b/frontend/src/hooks/query/use-user-repositories.ts
@@ -5,16 +5,16 @@ import { useConfig } from "./use-config";
 import { useAuth } from "#/context/auth-context";
 
 export const useUserRepositories = () => {
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
   const { data: config } = useConfig();
 
   const repos = useInfiniteQuery({
-    queryKey: ["repositories", githubTokenIsSet],
+    queryKey: ["repositories", providersAreSet],
     queryFn: async ({ pageParam }) =>
       retrieveGitHubUserRepositories(pageParam, 100),
     initialPageParam: 1,
     getNextPageParam: (lastPage) => lastPage.nextPage,
-    enabled: githubTokenIsSet && config?.APP_MODE === "oss",
+    enabled: providersAreSet && config?.APP_MODE === "oss",
     staleTime: 1000 * 60 * 5, // 5 minutes
     gcTime: 1000 * 60 * 15, // 15 minutes
   });
diff --git a/frontend/src/hooks/use-app-logout.ts b/frontend/src/hooks/use-app-logout.ts
index 403e443eb06a..fd47849b8e60 100644
--- a/frontend/src/hooks/use-app-logout.ts
+++ b/frontend/src/hooks/use-app-logout.ts
@@ -9,7 +9,7 @@ export const useAppLogout = () => {
 
   const handleLogout = async () => {
     if (config?.APP_MODE === "saas") await logout();
-    else await saveUserSettings({ unset_github_token: true });
+    else await saveUserSettings({ unset_tokens: true });
   };
 
   return { handleLogout };
diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts
index b058913b613d..c4d7ae8d3024 100644
--- a/frontend/src/hooks/use-github-auth-url.ts
+++ b/frontend/src/hooks/use-github-auth-url.ts
@@ -9,15 +9,15 @@ interface UseGitHubAuthUrlConfig {
 }
 
 export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => {
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
 
   return React.useMemo(() => {
-    if (config.appMode === "saas" && !githubTokenIsSet)
+    if (config.appMode === "saas" && !providersAreSet)
       return generateGitHubAuthUrl(
         config.gitHubClientId || "",
         new URL(window.location.href),
       );
 
     return null;
-  }, [githubTokenIsSet, config.appMode, config.gitHubClientId]);
+  }, [providersAreSet, config.appMode, config.gitHubClientId]);
 };
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index b3caacd210c8..85ffe3b3b998 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -18,7 +18,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
   security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
   remote_runtime_resource_factor:
     DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
-  github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
+  provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
   enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
   enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
   user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
@@ -149,13 +149,13 @@ const openHandsHandlers = [
 export const handlers = [
   ...STRIPE_BILLING_HANDLERS,
   ...openHandsHandlers,
-  http.get("/api/github/repositories", () =>
+  http.get("/api/user/repositories", () =>
     HttpResponse.json([
       { id: 1, full_name: "octocat/hello-world" },
       { id: 2, full_name: "octocat/earth" },
     ]),
   ),
-  http.get("/api/github/user", () => {
+  http.get("/api/user/info", () => {
     const user: GitHubUser = {
       id: 1,
       login: "octocat",
@@ -187,12 +187,13 @@ export const handlers = [
   }),
   http.get("/api/settings", async () => {
     await delay();
+
     const { settings } = MOCK_USER_PREFERENCES;
 
     if (!settings) return HttpResponse.json(null, { status: 404 });
 
-    if (Object.keys(settings.provider_tokens).length > 0)
-      settings.github_token_is_set = true;
+    if (Object.keys(settings.provider_tokens_set).length > 0)
+      settings.provider_tokens_set = { github: false, gitlab: false };
 
     return HttpResponse.json(settings);
   }),
@@ -203,10 +204,13 @@ export const handlers = [
       let newSettings: Partial<PostApiSettings> = {};
       if (typeof body === "object") {
         newSettings = { ...body };
-        if (newSettings.unset_github_token) {
-          newSettings.provider_tokens = { github: "", gitlab: "" };
-          newSettings.github_token_is_set = false;
-          delete newSettings.unset_github_token;
+
+        if (newSettings.provider_tokens_set) {
+          newSettings.provider_tokens_set = {
+            github: false,
+            gitlab: false,
+          };
+          delete newSettings.unset_tokens;
         }
       }
 
diff --git a/frontend/src/routes/_oh/route.tsx b/frontend/src/routes/_oh/route.tsx
index 31f960587674..fe119144670d 100644
--- a/frontend/src/routes/_oh/route.tsx
+++ b/frontend/src/routes/_oh/route.tsx
@@ -59,7 +59,7 @@ export default function MainApp() {
   const navigate = useNavigate();
   const { pathname } = useLocation();
   const [searchParams] = useSearchParams();
-  const { githubTokenIsSet } = useAuth();
+  const { providersAreSet } = useAuth();
   const { data: settings } = useSettings();
   const { error, isFetching } = useBalance();
   const { migrateUserConsent } = useMigrateUserConsent();
@@ -132,7 +132,7 @@ export default function MainApp() {
 
       {renderWaitlistModal && (
         <WaitlistModal
-          ghTokenIsSet={githubTokenIsSet}
+          ghTokenIsSet={providersAreSet}
           githubAuthUrl={gitHubAuthUrl}
         />
       )}
diff --git a/frontend/src/routes/account-settings.tsx b/frontend/src/routes/account-settings.tsx
index 1f814e7ab44f..fa689052575d 100644
--- a/frontend/src/routes/account-settings.tsx
+++ b/frontend/src/routes/account-settings.tsx
@@ -63,7 +63,6 @@ function AccountSettings() {
         isCustomModel(resources.models, settings.LLM_MODEL) ||
         hasAdvancedSettingsSet({
           ...settings,
-          PROVIDER_TOKENS: settings.PROVIDER_TOKENS || {},
         })
       );
     }
@@ -72,7 +71,13 @@ function AccountSettings() {
   };
 
   const hasAppSlug = !!config?.APP_SLUG;
-  const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
+  const providerTokensSet = settings?.PROVIDER_TOKENS_SET || {};
+  const isGithubTokenSet = providerTokensSet.github || false;
+  const isGitlabTokenSet = providerTokensSet.gitlab || false;
+  // Check if at least one provider has a true value
+  const hasAnyProviderToken = Object.values(providerTokensSet).some(
+    (value) => value === true,
+  );
   const isLLMKeySet = settings?.LLM_API_KEY === "**********";
   const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
   const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
@@ -122,6 +127,8 @@ function AccountSettings() {
         ? undefined // don't update if it's already set
         : ""); // reset if it's first time save to avoid 500 error
 
+    const githubToken = formData.get("github-token-input")?.toString();
+    const gitlabToken = formData.get("gitlab-token-input")?.toString();
     // we don't want the user to be able to modify these settings in SaaS
     const finalLlmModel = shouldHandleSpecialSaasCase
       ? undefined
@@ -131,15 +138,14 @@ function AccountSettings() {
       : llmBaseUrl;
     const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
 
-    const githubToken = formData.get("github-token-input")?.toString();
     const newSettings = {
-      github_token: githubToken,
-      provider_tokens: githubToken
-        ? {
-            github: githubToken,
-            gitlab: "",
-          }
-        : undefined,
+      provider_tokens:
+        githubToken || gitlabToken
+          ? {
+              github: githubToken || "",
+              gitlab: gitlabToken || "",
+            }
+          : undefined,
       LANGUAGE: languageValue,
       user_consents_to_analytics: userConsentsToAnalytics,
       ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
@@ -381,7 +387,7 @@ function AccountSettings() {
 
           <section className="flex flex-col gap-6">
             <h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
-              GitHub Settings
+              Provider Settings
             </h2>
             {isSaas && hasAppSlug && (
               <Link
@@ -402,20 +408,33 @@ function AccountSettings() {
                   label="GitHub Token"
                   type="password"
                   className="w-[680px]"
-                  startContent={
-                    isGitHubTokenSet && (
-                      <KeyStatusIcon isSet={!!isGitHubTokenSet} />
-                    )
-                  }
-                  placeholder={isGitHubTokenSet ? "**********" : ""}
+                  startContent={<KeyStatusIcon isSet={isGithubTokenSet} />}
+                  placeholder={isGithubTokenSet ? "**********" : ""}
                 />
 
                 <HelpLink
                   testId="github-token-help-anchor"
-                  text="Get your token"
+                  text="Get your GitHub token"
                   linkText="here"
                   href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
                 />
+
+                <SettingsInput
+                  testId="gitlab-token-input"
+                  name="gitlab-token-input"
+                  label="GitLab Token"
+                  type="password"
+                  className="w-[680px]"
+                  startContent={<KeyStatusIcon isSet={isGitlabTokenSet} />}
+                  placeholder={isGitlabTokenSet ? "**********" : ""}
+                />
+
+                <HelpLink
+                  testId="gitlab-token-help-anchor"
+                  text="Get your GitLab token"
+                  linkText="here"
+                  href="https://gitlab.com/-/profile/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository"
+                />
               </>
             )}
 
@@ -423,9 +442,9 @@ function AccountSettings() {
               type="button"
               variant="secondary"
               onClick={handleLogout}
-              isDisabled={!isGitHubTokenSet}
+              isDisabled={!hasAnyProviderToken}
             >
-              Disconnect from GitHub
+              Disconnect Tokens
             </BrandButton>
           </section>
 
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts
index 91b95d202135..34ffd4263646 100644
--- a/frontend/src/services/settings.ts
+++ b/frontend/src/services/settings.ts
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS: Settings = {
   CONFIRMATION_MODE: false,
   SECURITY_ANALYZER: "",
   REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
-  GITHUB_TOKEN_IS_SET: false,
+  PROVIDER_TOKENS_SET: { github: false, gitlab: false },
   ENABLE_DEFAULT_CONDENSER: true,
   ENABLE_SOUND_NOTIFICATIONS: false,
   USER_CONSENTS_TO_ANALYTICS: false,
diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts
index 6e13a2ec5053..7042e5632ff6 100644
--- a/frontend/src/types/settings.ts
+++ b/frontend/src/types/settings.ts
@@ -9,7 +9,7 @@ export type Settings = {
   CONFIRMATION_MODE: boolean;
   SECURITY_ANALYZER: string;
   REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
-  GITHUB_TOKEN_IS_SET: boolean;
+  PROVIDER_TOKENS_SET: Record<string, boolean>;
   ENABLE_DEFAULT_CONDENSER: boolean;
   ENABLE_SOUND_NOTIFICATIONS: boolean;
   USER_CONSENTS_TO_ANALYTICS: boolean | null;
@@ -26,21 +26,20 @@ export type ApiSettings = {
   confirmation_mode: boolean;
   security_analyzer: string;
   remote_runtime_resource_factor: number | null;
-  github_token_is_set: boolean;
   enable_default_condenser: boolean;
   enable_sound_notifications: boolean;
   user_consents_to_analytics: boolean | null;
-  provider_tokens: Record<Provider, string>;
+  provider_tokens_set: Record<string, boolean>;
 };
 
 export type PostSettings = Settings & {
-  provider_tokens: Record<Provider, string>;
-  unset_github_token: boolean;
+  provider_tokens: Record<string, string>;
+  unset_tokens: boolean;
   user_consents_to_analytics: boolean | null;
 };
 
 export type PostApiSettings = ApiSettings & {
-  provider_tokens: Record<Provider, string>;
-  unset_github_token: boolean;
+  provider_tokens: Record<string, string>;
+  unset_tokens: boolean;
   user_consents_to_analytics: boolean | null;
 };
diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts
index 374047c36a6f..ca876fe6e207 100644
--- a/frontend/src/utils/has-advanced-settings-set.ts
+++ b/frontend/src/utils/has-advanced-settings-set.ts
@@ -1,7 +1,7 @@
 import { DEFAULT_SETTINGS } from "#/services/settings";
 import { Settings } from "#/types/settings";
 
-export const hasAdvancedSettingsSet = (settings: Settings): boolean =>
+export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
   !!settings.LLM_BASE_URL ||
   settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
   settings.REMOTE_RUNTIME_RESOURCE_FACTOR !==
diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx
index d39ced887a39..08f5871a8448 100644
--- a/frontend/test-utils.tsx
+++ b/frontend/test-utils.tsx
@@ -66,7 +66,7 @@ export function renderWithProviders(
   function Wrapper({ children }: PropsWithChildren) {
     return (
       <Provider store={store}>
-        <AuthProvider initialGithubTokenIsSet={true}>
+        <AuthProvider initialProviderTokens={{ github: false, gitlab: false }}>
           <QueryClientProvider
             client={
               new QueryClient({
diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py
index 9e3dae38e797..def025103bbb 100644
--- a/openhands/integrations/gitlab/gitlab_service.py
+++ b/openhands/integrations/gitlab/gitlab_service.py
@@ -22,6 +22,7 @@ class GitLabService(GitService):
     def __init__(
         self,
         user_id: str | None = None,
+        external_auth_id: str | None = None,
         external_auth_token: SecretStr | None = None,
         token: SecretStr | None = None,
         external_token_manager: bool = False,
@@ -46,7 +47,7 @@ async def _get_gitlab_headers(self) -> dict:
     def _has_token_expired(self, status_code: int) -> bool:
         return status_code == 401
 
-    async def get_latest_token(self) -> SecretStr:
+    async def get_latest_token(self) -> SecretStr | None:
         return self.token
 
     async def _fetch_data(
@@ -109,7 +110,39 @@ async def search_repositories(
     async def get_repositories(
         self, page: int, per_page: int, sort: str, installation_id: int | None
     ) -> list[Repository]:
-        return []
+        if installation_id:
+            return []  # Not implementing installation_token case yet
+        
+        url = f'{self.BASE_URL}/projects'
+        # Map GitHub's sort values to GitLab's order_by values
+        order_by = {
+            'pushed': 'last_activity_at',
+            'updated': 'last_activity_at',
+            'created': 'created_at',
+            'full_name': 'name'
+        }.get(sort, 'last_activity_at')
+
+        params = {
+            'page': str(page),
+            'per_page': str(per_page),
+            'order_by': order_by,
+            'sort': 'desc',  # GitLab uses sort for direction (asc/desc)
+            'owned': 1,  # Use 1 instead of True
+            'membership': 1  # Use 1 instead of True
+        }
+        response, headers = await self._fetch_data(url, params)
+        
+        next_link: str = headers.get('Link', '')
+        repos = [
+            Repository(
+                id=repo.get('id'),
+                full_name=repo.get('path_with_namespace'),
+                stargazers_count=repo.get('star_count'),
+                link_header=next_link,
+            )
+            for repo in response
+        ]
+        return repos
 
 
 gitlab_service_cls = os.environ.get(
diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py
index b918526875ce..50edc08eb16c 100644
--- a/openhands/integrations/provider.py
+++ b/openhands/integrations/provider.py
@@ -1,5 +1,8 @@
 from __future__ import annotations
 
+from pydantic import BaseModel, SecretStr, SerializationInfo, field_serializer
+from pydantic.json import pydantic_encoder
+
 from enum import Enum
 from types import MappingProxyType
 
@@ -12,7 +15,6 @@
     model_validator,
 )
 from pydantic.json import pydantic_encoder
-
 from openhands.integrations.github.github_service import GithubServiceImpl
 from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
 from openhands.integrations.service_types import (
@@ -130,15 +132,23 @@ class ProviderHandler:
     def __init__(
         self,
         provider_tokens: PROVIDER_TOKEN_TYPE,
+        external_auth_id: str | None = None,
         external_auth_token: SecretStr | None = None,
+        external_token_manager: bool = False,
     ):
+        if not isinstance(provider_tokens, MappingProxyType):
+            raise TypeError(
+                f'provider_tokens must be a MappingProxyType, got {type(provider_tokens).__name__}'
+            )
+
         self.service_class_map: dict[ProviderType, type[GitService]] = {
             ProviderType.GITHUB: GithubServiceImpl,
             ProviderType.GITLAB: GitLabServiceImpl,
         }
 
-        # Create immutable copy through SecretStore
+        self.external_auth_id = external_auth_id
         self.external_auth_token = external_auth_token
+        self.external_token_manager = external_token_manager
         self._provider_tokens = provider_tokens
 
     @property
@@ -152,8 +162,10 @@ def _get_service(self, provider: ProviderType) -> GitService:
         service_class = self.service_class_map[provider]
         return service_class(
             user_id=token.user_id,
+            external_auth_id=self.external_auth_id,
             external_auth_token=self.external_auth_token,
             token=token.token,
+            external_token_manager=self.external_token_manager,
         )
 
     async def get_user(self) -> User:
@@ -166,27 +178,63 @@ async def get_user(self) -> User:
                 continue
         raise AuthenticationError('Need valid provider token')
 
-    async def get_latest_provider_tokens(self) -> dict[ProviderType, SecretStr]:
-        """Get latest token from services"""
-        tokens = {}
-        for provider in self.provider_tokens:
-            service = self._get_service(provider)
-            tokens[provider] = await service.get_latest_token()
-
-        return tokens
-
     async def get_repositories(
         self, page: int, per_page: int, sort: str, installation_id: int | None
-    ) -> list[Repository]:
-        """Get repositories from all available providers"""
+    ) -> dict:
+        """Get repositories from all available providers with pagination support
+        
+        Returns:
+            dict: {
+                'repositories': list[Repository],  # Combined list of repositories
+                'pagination': {
+                    'total_count': int,  # Total number of repositories across all providers
+                    'has_more': bool,    # True if any provider has more results
+                    'provider_cursors': dict[str, any]  # Provider-specific pagination cursors
+                }
+            }
+        """
         all_repos = []
+        total_count = 0
+        has_more = False
+        provider_cursors = {}
+        
+        # Calculate offset for each provider based on page and per_page
+        provider_count = len(self.provider_tokens)
+        items_per_provider = per_page // provider_count if provider_count > 0 else per_page
+        
         for provider in self.provider_tokens:
             try:
                 service = self._get_service(provider)
+                # Each provider gets its share of the requested items
                 repos = await service.get_repositories(
-                    page, per_page, sort, installation_id
+                    page, items_per_provider, sort, installation_id
                 )
-                all_repos.extend(repos)
+                
+                if repos:
+                    all_repos.extend(repos)
+                    # Store provider-specific pagination info
+                    if hasattr(repos[0], 'link_header'):
+                        provider_cursors[provider.value] = repos[0].link_header
+                    if hasattr(repos[0], 'total_count'):
+                        total_count += repos[0].total_count
+                        has_more = has_more or len(repos) < repos[0].total_count
+                    else:
+                        # If provider doesn't support total_count, assume more if we got full page
+                        has_more = has_more or len(repos) >= items_per_provider
+                        
             except Exception:
                 continue
-        return all_repos
+                
+        # Sort combined results by the requested sort field
+        if sort == 'pushed':
+            # Sort by pushed_at, putting None values last
+            all_repos.sort(key=lambda x: (x.pushed_at is None, x.pushed_at or ''), reverse=True)
+            
+        return {
+            'repositories': all_repos[:per_page],  # Return only requested number of items
+            'pagination': {
+                'total_count': total_count,
+                'has_more': has_more,
+                'provider_cursors': provider_cursors
+            }
+        }
diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py
index 916acbf35acd..ea468aae5cbb 100644
--- a/openhands/integrations/service_types.py
+++ b/openhands/integrations/service_types.py
@@ -33,6 +33,7 @@ class Repository(BaseModel):
     full_name: str
     stargazers_count: int | None = None
     link_header: str | None = None
+    pushed_at: str | None = None  # ISO 8601 format date string
 
 
 class AuthenticationError(ValueError):
@@ -52,15 +53,16 @@ class GitService(Protocol):
 
     def __init__(
         self,
-        user_id: str | None,
-        token: SecretStr | None,
-        external_auth_token: SecretStr | None,
+        user_id: str | None = None,
+        token: SecretStr | None = None,
+        external_auth_id: str | None = None,
+        external_auth_token: SecretStr | None = None,
         external_token_manager: bool = False,
     ) -> None:
         """Initialize the service with authentication details"""
         ...
 
-    async def get_latest_token(self) -> SecretStr:
+    async def get_latest_token(self) -> SecretStr | None:
         """Get latest working token of the users"""
         ...
 
diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py
index c51e9eb5c0bf..3f0cb8cdc9ec 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -41,7 +41,11 @@
     UserRejectObservation,
 )
 from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
-from openhands.integrations.github.github_service import GithubServiceImpl
+from openhands.integrations.provider import (
+    PROVIDER_TOKEN_TYPE,
+    ProviderHandler,
+    ProviderType,
+)
 from openhands.microagent import (
     BaseMicroAgent,
     load_microagents_from_dir,
@@ -98,6 +102,7 @@ def __init__(
         attach_to_existing: bool = False,
         headless_mode: bool = False,
         user_id: str | None = None,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
     ):
         self.sid = sid
         self.event_stream = event_stream
@@ -131,6 +136,7 @@ def __init__(
         )
 
         self.user_id = user_id
+        self.git_provider_tokens = git_provider_tokens
 
         # TODO: remove once done debugging expired github token
         self.prev_token: SecretStr | None = None
@@ -171,6 +177,8 @@ def send_error_message(self, message_id: str, message: str):
     # ====================================================================
 
     def add_env_vars(self, env_vars: dict[str, str]) -> None:
+        env_vars = {key.upper(): value for key, value in env_vars.items()}
+
         # Add env vars to the IPython shell (if Jupyter is used)
         if any(isinstance(plugin, JupyterRequirement) for plugin in self.plugins):
             code = 'import os\n'
@@ -216,56 +224,65 @@ def on_event(self, event: Event) -> None:
         if isinstance(event, Action):
             asyncio.get_event_loop().run_until_complete(self._handle_action(event))
 
+    async def _export_latest_git_provider_tokens(self, event: Action) -> None:
+        """
+        Refresh runtime provider tokens when agent attemps to run action with provider token
+        """
+        if not self.git_provider_tokens:
+            return
+
+        providers_called = ProviderHandler.check_cmd_action_for_provider_token_ref(
+            event
+        )
+
+        if not providers_called:
+            return
+
+        provider_handler = ProviderHandler(
+            provider_tokens=self.git_provider_tokens,
+            external_auth_id=self.user_id,
+            external_token_manager=True,
+        )
+
+        logger.info(f'Fetching latest github token for runtime: {self.sid}')
+        env_vars = await provider_handler.get_env_vars(
+            required_providers=providers_called, expose_secrets=False, get_latest=True
+        )
+
+        if ProviderType.GITHUB not in env_vars:
+            logger.error(f'Failed to refresh github token for runtime: {self.sid}')
+            return
+
+        raw_token = env_vars[ProviderType.GITHUB].get_secret_value()
+        if not self.prev_token:
+            logger.info(
+                f'Setting github token in runtime: {self.sid}\nToken value: {raw_token[0:5]}; length: {len(raw_token)}'
+            )
+        elif self.prev_token.get_secret_value() != raw_token:
+            logger.info(
+                f'Setting new github token in runtime {self.sid}\nToken value: {raw_token[0:5]}; length: {len(raw_token)}'
+            )
+
+        self.prev_token = SecretStr(raw_token)
+
+        try:
+            provider_handler = ProviderHandler(provider_tokens=self.git_provider_tokens)
+            await provider_handler.set_event_stream_secrets(
+                self.event_stream, env_vars=env_vars
+            )
+            self.add_env_vars(provider_handler.expose_env_vars(env_vars))
+        except Exception as e:
+            logger.warning(
+                f'Failed export latest github token to runtime: {self.sid}, {e}'
+            )
+
     async def _handle_action(self, event: Action) -> None:
         if event.timeout is None:
             # We don't block the command if this is a default timeout action
             event.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
         assert event.timeout is not None
         try:
-            if isinstance(event, CmdRunAction):
-                if self.user_id and 'GITHUB_TOKEN' in event.command:
-                    gh_client = GithubServiceImpl(
-                        external_auth_id=self.user_id, external_token_manager=True
-                    )
-                    logger.info(f'Fetching latest github token for runtime: {self.sid}')
-                    token = await gh_client.get_latest_token()
-                    if not token:
-                        logger.error(
-                            f'Failed to refresh github token for runtime: {self.sid}'
-                        )
-
-                    if token:
-                        raw_token = token.get_secret_value()
-
-                        if not self.prev_token:
-                            logger.info(
-                                f'Setting github token in runtime: {self.sid}\nToken value: {raw_token[0:5]}; length: {len(raw_token)}'
-                            )
-
-                        elif self.prev_token.get_secret_value() != raw_token:
-                            logger.info(
-                                f'Setting new github token in runtime {self.sid}\nToken value: {raw_token[0:5]}; length: {len(raw_token)}'
-                            )
-
-                        self.prev_token = token
-
-                        env_vars = {
-                            'GITHUB_TOKEN': raw_token,
-                        }
-
-                        try:
-                            self.add_env_vars(env_vars)
-                        except Exception as e:
-                            logger.warning(
-                                f'Failed export latest github token to runtime: {self.sid}, {e}'
-                            )
-
-                        self.event_stream.update_secrets(
-                            {
-                                'github_token': raw_token,
-                            }
-                        )
-
+            await self._export_latest_git_provider_tokens(event)
             observation: Observation = await call_sync_from_async(
                 self.run_action, event
             )
@@ -293,14 +310,20 @@ async def _handle_action(self, event: Action) -> None:
 
     def clone_repo(
         self,
-        github_token: SecretStr,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE,
         selected_repository: str,
         selected_branch: str | None,
     ) -> str:
-        if not github_token or not selected_repository:
+        if (
+            ProviderType.GITHUB not in git_provider_tokens
+            or not git_provider_tokens[ProviderType.GITHUB].token
+            or not selected_repository
+        ):
             raise ValueError(
                 'github_token and selected_repository must be provided to clone a repository'
             )
+
+        github_token: SecretStr = git_provider_tokens[ProviderType.GITHUB].token
         url = f'https://{github_token.get_secret_value()}@github.com/{selected_repository}.git'
         dir_name = selected_repository.split('/')[-1]
 
diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py
index bcdbe2be1526..77d8a3ad8fce 100644
--- a/openhands/runtime/impl/action_execution/action_execution_client.py
+++ b/openhands/runtime/impl/action_execution/action_execution_client.py
@@ -36,6 +36,7 @@
 )
 from openhands.events.serialization import event_to_dict, observation_from_dict
 from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
 from openhands.runtime.base import Runtime
 from openhands.runtime.plugins import PluginRequirement
 from openhands.runtime.utils.request import send_request
@@ -60,6 +61,7 @@ def __init__(
         attach_to_existing: bool = False,
         headless_mode: bool = True,
         user_id: str | None = None,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None
     ):
         self.session = HttpSession()
         self.action_semaphore = threading.Semaphore(1)  # Ensure one action at a time
@@ -76,6 +78,7 @@ def __init__(
             attach_to_existing,
             headless_mode,
             user_id,
+            git_provider_tokens,
         )
 
     @abstractmethod
diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py
index 6492dbc72414..0e3325ce0bea 100644
--- a/openhands/runtime/impl/remote/remote_runtime.py
+++ b/openhands/runtime/impl/remote/remote_runtime.py
@@ -16,6 +16,7 @@
 )
 from openhands.core.logger import openhands_logger as logger
 from openhands.events import EventStream
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
 from openhands.runtime.builder.remote import RemoteRuntimeBuilder
 from openhands.runtime.impl.action_execution.action_execution_client import (
     ActionExecutionClient,
@@ -47,6 +48,7 @@ def __init__(
         attach_to_existing: bool = False,
         headless_mode: bool = True,
         user_id: str | None = None,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
     ):
         super().__init__(
             config,
@@ -58,6 +60,7 @@ def __init__(
             attach_to_existing,
             headless_mode,
             user_id,
+            git_provider_tokens,
         )
         if self.config.sandbox.api_key is None:
             raise ValueError(
diff --git a/openhands/server/auth.py b/openhands/server/auth.py
index eeaee04bafa1..0a11bfef2fd9 100644
--- a/openhands/server/auth.py
+++ b/openhands/server/auth.py
@@ -28,7 +28,6 @@ def get_github_token(request: Request) -> SecretStr | None:
 
 def get_github_user_id(request: Request) -> str | None:
     provider_tokens = get_provider_tokens(request)
-
     if provider_tokens and ProviderType.GITHUB in provider_tokens:
         return provider_tokens[ProviderType.GITHUB].user_id
 
diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py
index 33676def89c6..9a495189c5da 100644
--- a/openhands/server/routes/github.py
+++ b/openhands/server/routes/github.py
@@ -17,10 +17,21 @@
 )
 from openhands.server.auth import get_access_token, get_provider_tokens
 
-app = APIRouter(prefix='/api/github')
+app = APIRouter(prefix='/api/user')
 
 
-@app.get('/repositories', response_model=list[Repository])
+from pydantic import BaseModel
+
+class PaginationInfo(BaseModel):
+    total_count: int
+    has_more: bool
+    provider_cursors: dict[str, str]
+
+class RepositoryResponse(BaseModel):
+    repositories: list[Repository]
+    pagination: PaginationInfo
+
+@app.get('/repositories', response_model=RepositoryResponse)
 async def get_github_repositories(
     page: int = 1,
     per_page: int = 10,
@@ -29,17 +40,17 @@ async def get_github_repositories(
     provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
     access_token: SecretStr | None = Depends(get_access_token),
 ):
-    if provider_tokens and ProviderType.GITHUB in provider_tokens:
-        token = provider_tokens[ProviderType.GITHUB]
-        client = GithubServiceImpl(
-            user_id=token.user_id, external_auth_token=access_token, token=token.token
+    if provider_tokens:
+
+        client = ProviderHandler(
+            provider_tokens=provider_tokens, external_auth_token=access_token
         )
 
         try:
             repos: list[Repository] = await client.get_repositories(
                 page, per_page, sort, installation_id
             )
-            return repos
+            return RepositoryResponse(**repos)
 
         except AuthenticationError as e:
             return JSONResponse(
@@ -59,8 +70,8 @@ async def get_github_repositories(
     )
 
 
-@app.get('/user', response_model=User)
-async def get_github_user(
+@app.get('/info', response_model=User)
+async def get_user(
     provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
     access_token: SecretStr | None = Depends(get_access_token),
 ):
@@ -86,7 +97,7 @@ async def get_github_user(
             )
 
     return JSONResponse(
-        content='GitHub token required.',
+        content='Git token required.',
         status_code=status.HTTP_401_UNAUTHORIZED,
     )
 
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 91c872bfab11..84597f27e579 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -3,15 +3,15 @@
 
 from fastapi import APIRouter, Body, Request, status
 from fastapi.responses import JSONResponse
-from pydantic import BaseModel, SecretStr
+from pydantic import BaseModel
 
 from openhands.core.logger import openhands_logger as logger
 from openhands.events.action.message import MessageAction
-from openhands.integrations.github.github_service import GithubServiceImpl
-from openhands.integrations.provider import ProviderType
+from openhands.integrations.provider import (
+    PROVIDER_TOKEN_TYPE,
+)
 from openhands.runtime import get_runtime_cls
 from openhands.server.auth import (
-    get_access_token,
     get_github_user_id,
     get_provider_tokens,
     get_user_id,
@@ -44,7 +44,7 @@ class InitSessionRequest(BaseModel):
 
 async def _create_new_conversation(
     user_id: str | None,
-    token: SecretStr | None,
+    git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
     selected_repository: str | None,
     selected_branch: str | None,
     initial_user_msg: str | None,
@@ -78,7 +78,7 @@ async def _create_new_conversation(
         logger.warn('Settings not present, not starting conversation')
         raise MissingSettingsError('Settings not found')
 
-    session_init_args['provider_token'] = token
+    session_init_args['git_provider_tokens'] = git_provider_tokens
     session_init_args['selected_repository'] = selected_repository
     session_init_args['selected_branch'] = selected_branch
     conversation_init_data = ConversationInitData(**session_init_args)
@@ -146,19 +146,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
     using the returned conversation ID.
     """
     logger.info('Initializing new conversation')
-    user_id = None
-    github_token = None
     provider_tokens = get_provider_tokens(request)
-    if provider_tokens and ProviderType.GITHUB in provider_tokens:
-        token = provider_tokens[ProviderType.GITHUB]
-        user_id = token.user_id
-        gh_client = GithubServiceImpl(
-            user_id=user_id,
-            external_auth_token=get_access_token(request),
-            token=token.token,
-        )
-        github_token = await gh_client.get_latest_token()
-
     selected_repository = data.selected_repository
     selected_branch = data.selected_branch
     initial_user_msg = data.initial_user_msg
@@ -168,7 +156,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
         # Create conversation with initial message
         conversation_id = await _create_new_conversation(
             get_user_id(request),
-            github_token,
+            provider_tokens,
             selected_repository,
             selected_branch,
             initial_user_msg,
diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py
index a953e7a005ec..5a5485ea60ad 100644
--- a/openhands/server/routes/settings.py
+++ b/openhands/server/routes/settings.py
@@ -9,6 +9,7 @@
 from openhands.server.settings import GETSettingsModel, POSTSettingsModel, Settings
 from openhands.server.shared import SettingsStoreImpl, config
 
+
 app = APIRouter(prefix='/api')
 
 
@@ -24,10 +25,24 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
                 content={'error': 'Settings not found'},
             )
 
-        github_token_is_set = bool(user_id) or bool(get_provider_tokens(request))
+        provider_tokens_set = {}
+        
+        if bool(user_id):
+            provider_tokens_set[ProviderType.GITHUB.value] = True
+        
+        provider_tokens = get_provider_tokens(request)
+        if provider_tokens:
+            all_provider_types = [provider.value for provider in ProviderType]
+            provider_tokens_types = [provider.value for provider in provider_tokens]
+            for provider_type in all_provider_types:
+                if provider_type in provider_tokens_types:
+                    provider_tokens_set[provider_type] = True
+                else:
+                    provider_tokens_set[provider_type] = False
+
         settings_with_token_data = GETSettingsModel(
             **settings.model_dump(exclude='secrets_store'),
-            github_token_is_set=github_token_is_set,
+            provider_tokens_set=provider_tokens_set,
         )
 
         settings_with_token_data.llm_api_key = settings.llm_api_key
@@ -90,7 +105,7 @@ async def store_settings(
                 )
 
             # Handle token updates immutably
-            if settings.unset_github_token:
+            if settings.unset_tokens:
                 settings = settings.model_copy(update={"secrets_store": SecretStore()})
 
             else:  # Only merge if not unsetting tokens
@@ -174,4 +189,4 @@ def convert_to_settings(settings_with_token_data: POSTSettingsModel) -> Settings
             provider_tokens=tokens
         )})
 
-    return settings
\ No newline at end of file
+    return settings
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index 58518ab7a306..4da80bdc2a3a 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -1,9 +1,8 @@
 import asyncio
 import time
 from logging import LoggerAdapter
-from typing import Callable
-
-from pydantic import SecretStr
+from types import MappingProxyType
+from typing import Callable, cast
 
 from openhands.controller import AgentController
 from openhands.controller.agent import Agent
@@ -15,6 +14,7 @@
 from openhands.events.action import ChangeAgentStateAction, MessageAction
 from openhands.events.event import EventSource
 from openhands.events.stream import EventStream
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
 from openhands.memory.memory import Memory
 from openhands.microagent.microagent import BaseMicroAgent
 from openhands.runtime import get_runtime_cls
@@ -77,10 +77,10 @@ async def start(
         config: AppConfig,
         agent: Agent,
         max_iterations: int,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
         max_budget_per_task: float | None = None,
         agent_to_llm_config: dict[str, LLMConfig] | None = None,
         agent_configs: dict[str, AgentConfig] | None = None,
-        github_token: SecretStr | None = None,
         selected_repository: str | None = None,
         selected_branch: str | None = None,
         initial_message: MessageAction | None = None,
@@ -114,7 +114,7 @@ async def start(
                 runtime_name=runtime_name,
                 config=config,
                 agent=agent,
-                github_token=github_token,
+                git_provider_tokens=git_provider_tokens,
                 selected_repository=selected_repository,
                 selected_branch=selected_branch,
             )
@@ -136,12 +136,12 @@ async def start(
                 repo_directory=repo_directory,
             )
 
-            if github_token:
-                self.event_stream.set_secrets(
-                    {
-                        'github_token': github_token.get_secret_value(),
-                    }
+            if git_provider_tokens:
+                provider_handler = ProviderHandler(provider_tokens=git_provider_tokens)
+                await provider_handler.set_event_stream_secrets(
+                    self.event_stream
                 )
+
             if not self._closed:
                 if initial_message:
                     self.event_stream.add_event(initial_message, EventSource.USER)
@@ -211,7 +211,7 @@ async def _create_runtime(
         runtime_name: str,
         config: AppConfig,
         agent: Agent,
-        github_token: SecretStr | None = None,
+        git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
         selected_repository: str | None = None,
         selected_branch: str | None = None,
     ) -> bool:
@@ -231,18 +231,10 @@ async def _create_runtime(
 
         self.logger.debug(f'Initializing runtime `{runtime_name}` now...')
         runtime_cls = get_runtime_cls(runtime_name)
-        env_vars = (
-            {
-                'GITHUB_TOKEN': github_token.get_secret_value(),
-            }
-            if github_token
-            else None
-        )
-
-        kwargs = {}
-        if runtime_cls == RemoteRuntime:
-            kwargs['user_id'] = self.user_id
 
+        provider_handler = ProviderHandler(git_provider_tokens or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})))
+        raw_env_vars: dict[str, str] = await provider_handler.get_env_vars(expose_secrets=True)
+    
         self.runtime = runtime_cls(
             config=config,
             event_stream=self.event_stream,
@@ -251,8 +243,9 @@ async def _create_runtime(
             status_callback=self._status_callback,
             headless_mode=False,
             attach_to_existing=False,
-            env_vars=env_vars,
-            **kwargs,
+            env_vars=raw_env_vars,
+            git_provider_tokens=git_provider_tokens,
+            user_id=self.user_id,
         )
 
         # FIXME: this sleep is a terrible hack.
@@ -270,10 +263,10 @@ async def _create_runtime(
                 )
             return False
 
-        if selected_repository:
+        if selected_repository and git_provider_tokens:
             await call_sync_from_async(
                 self.runtime.clone_repo,
-                github_token,
+                git_provider_tokens,
                 selected_repository,
                 selected_branch,
             )
diff --git a/openhands/server/session/conversation_init_data.py b/openhands/server/session/conversation_init_data.py
index ce7acbbe3e50..1ce27e3e9641 100644
--- a/openhands/server/session/conversation_init_data.py
+++ b/openhands/server/session/conversation_init_data.py
@@ -1,5 +1,7 @@
-from pydantic import Field, SecretStr
+from types import MappingProxyType
+from pydantic import Field
 
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
 from openhands.server.settings import Settings
 
 
@@ -8,6 +10,10 @@ class ConversationInitData(Settings):
     Session initialization data for the web environment - a deep copy of the global config is made and then overridden with this data.
     """
 
-    provider_token: SecretStr | None = Field(default=None)
+    git_provider_tokens: PROVIDER_TOKEN_TYPE | None = Field(default=None, frozen=True)
     selected_repository: str | None = Field(default=None)
     selected_branch: str | None = Field(default=None)
+
+    model_config = {
+        'arbitrary_types_allowed': True,
+    }
\ No newline at end of file
diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py
index 1690e39d3044..85a09d0ff7d5 100644
--- a/openhands/server/session/session.py
+++ b/openhands/server/session/session.py
@@ -23,6 +23,7 @@
 from openhands.events.observation.error import ErrorObservation
 from openhands.events.serialization import event_from_dict, event_to_dict
 from openhands.events.stream import EventStreamSubscriber
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
 from openhands.llm.llm import LLM
 from openhands.server.session.agent_session import AgentSession
 from openhands.server.session.conversation_init_data import ConversationInitData
@@ -123,11 +124,11 @@ async def initialize_agent(
 
         agent = Agent.get_cls(agent_cls)(llm, agent_config)
 
-        provider_token = None
+        git_provider_tokens = None
         selected_repository = None
         selected_branch = None
         if isinstance(settings, ConversationInitData):
-            provider_token = settings.provider_token
+            git_provider_tokens = settings.git_provider_tokens
             selected_repository = settings.selected_repository
             selected_branch = settings.selected_branch
 
@@ -140,7 +141,7 @@ async def initialize_agent(
                 max_budget_per_task=self.config.max_budget_per_task,
                 agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
                 agent_configs=self.config.get_agent_configs(),
-                github_token=provider_token,
+                git_provider_tokens=git_provider_tokens,
                 selected_repository=selected_repository,
                 selected_branch=selected_branch,
                 initial_message=initial_message,
diff --git a/openhands/server/settings.py b/openhands/server/settings.py
index d14a93bcbea2..ac8f5adc4b8c 100644
--- a/openhands/server/settings.py
+++ b/openhands/server/settings.py
@@ -105,7 +105,7 @@ class POSTSettingsModel(Settings):
     Settings for POST requests
     """
 
-    unset_github_token: bool | None = None
+    unset_tokens: bool | None = None
     # Override provider_tokens to accept string tokens from frontend
     provider_tokens: dict[str, str] = {}
 
@@ -119,4 +119,4 @@ class GETSettingsModel(Settings):
     Settings with additional token data for the frontend
     """
 
-    github_token_is_set: bool | None = None
+    provider_tokens_set: dict[str, bool] | None = None
diff --git a/tests/unit/test_provider_immutability.py b/tests/unit/test_provider_immutability.py
index c8988dbe8e93..40339e5b2afe 100644
--- a/tests/unit/test_provider_immutability.py
+++ b/tests/unit/test_provider_immutability.py
@@ -227,3 +227,8 @@ def test_token_conversion():
     )
 
     assert len(store6.provider_tokens.keys()) == 0
+
+
+def test_provider_handler_type_enforcement():
+    with pytest.raises((TypeError)):
+        ProviderHandler(provider_tokens={'a': 'b'})


Code review comments:
diff_hunk: @@ -97,7 +97,7 @@ async def get_github_user(
             )
 
     return JSONResponse(
-        content='GitHub token required.',
+        content='Git token required.',
path: openhands/server/routes/github.py
text: @user1:
Just an idea, is this shown to the user? In that case, "git token" is not clear, maybe like "Git provider token required. (such as GitHub)"