Эх сурвалжийг харах

[Refactor]: Changes to Github Authentication (#5371)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Rohit Malhotra 11 сар өмнө
parent
commit
f9d052c493
35 өөрчлөгдсөн 618 нэмэгдсэн , 453 устгасан
  1. 0 5
      frontend/public/config.json
  2. 84 2
      frontend/src/api/github-axios-instance.ts
  3. 66 48
      frontend/src/api/github.ts
  4. 17 1
      frontend/src/api/open-hands.ts
  5. 1 0
      frontend/src/api/open-hands.types.ts
  6. 34 3
      frontend/src/components/features/github/github-repo-selector.tsx
  7. 1 1
      frontend/src/components/features/github/github-repositories-suggestion-box.tsx
  8. 33 17
      frontend/src/components/shared/modals/account-settings/account-settings-form.tsx
  9. 4 2
      frontend/src/components/shared/modals/settings/settings-form.tsx
  10. 27 5
      frontend/src/context/auth-context.tsx
  11. 21 0
      frontend/src/hooks/query/use-app-installations.ts
  12. 65 0
      frontend/src/hooks/query/use-app-repositories.ts
  13. 3 1
      frontend/src/hooks/query/use-user-repositories.ts
  14. 7 2
      frontend/src/routes/_oh._index/route.tsx
  15. 1 1
      frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts
  16. 8 0
      frontend/src/routes/_oh/route.tsx
  17. 7 2
      frontend/src/services/settings.ts
  18. 8 0
      frontend/src/types/github.d.ts
  19. 2 1
      frontend/src/utils/generate-github-auth-url.ts
  20. 2 2
      frontend/src/utils/settings-utils.ts
  21. 0 3
      openhands/core/config/app_config.py
  22. 1 1
      openhands/runtime/base.py
  23. 5 4
      openhands/server/app.py
  24. 2 2
      openhands/server/auth.py
  25. 58 0
      openhands/server/config/openhands_config.py
  26. 0 129
      openhands/server/github_utils.py
  27. 4 12
      openhands/server/listen_socket.py
  28. 37 40
      openhands/server/middleware.py
  29. 10 0
      openhands/server/mock/listen.py
  30. 0 100
      openhands/server/routes/auth.py
  31. 56 0
      openhands/server/routes/github.py
  32. 10 1
      openhands/server/routes/public.py
  33. 2 0
      openhands/server/shared.py
  34. 0 68
      openhands/server/sheets_client.py
  35. 42 0
      openhands/server/types.py

+ 0 - 5
frontend/public/config.json

@@ -1,5 +0,0 @@
-{
-  "APP_MODE": "oss",
-  "GITHUB_CLIENT_ID": "",
-  "POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
-}

+ 84 - 2
frontend/src/api/github-axios-instance.ts

@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { AxiosError } from "axios";
 
 const github = axios.create({
   baseURL: "https://api.github.com",
@@ -18,4 +18,86 @@ const removeAuthTokenHeader = () => {
   }
 };
 
-export { github, setAuthTokenHeader, removeAuthTokenHeader };
+/**
+ * Checks if response has attributes to perform refresh
+ */
+const canRefresh = (error: unknown): boolean =>
+  !!(
+    error instanceof AxiosError &&
+    error.config &&
+    error.response &&
+    error.response.status
+  );
+
+/**
+ * Checks if the data is a GitHub error response
+ * @param data The data to check
+ * @returns Boolean indicating if the data is a GitHub error response
+ */
+export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
+  data: T | GitHubErrorReponse | null,
+): data is GitHubErrorReponse =>
+  !!data && "message" in data && data.message !== undefined;
+
+// Axios interceptor to handle token refresh
+const setupAxiosInterceptors = (
+  refreshToken: () => Promise<boolean>,
+  logout: () => void,
+) => {
+  github.interceptors.response.use(
+    // Pass successful responses through
+    (response) => {
+      const parsedData = response.data;
+      if (isGitHubErrorReponse(parsedData)) {
+        const error = new AxiosError(
+          "Failed",
+          "",
+          response.config,
+          response.request,
+          response,
+        );
+        throw error;
+      }
+      return response;
+    },
+    // Retry request exactly once if token is expired
+    async (error) => {
+      if (!canRefresh(error)) {
+        return Promise.reject(new Error("Failed to refresh token"));
+      }
+
+      const originalRequest = error.config;
+
+      // Check if the error is due to an expired token
+      if (
+        error.response.status === 401 &&
+        !originalRequest._retry // Prevent infinite retry loops
+      ) {
+        originalRequest._retry = true;
+        try {
+          const refreshed = await refreshToken();
+          if (refreshed) {
+            return await github(originalRequest);
+          }
+
+          logout();
+          return await Promise.reject(new Error("Failed to refresh token"));
+        } catch (refreshError) {
+          // If token refresh fails, evict the user
+          logout();
+          return Promise.reject(refreshError);
+        }
+      }
+
+      // If the error is not due to an expired token, propagate the error
+      return Promise.reject(error);
+    },
+  );
+};
+
+export {
+  github,
+  setAuthTokenHeader,
+  removeAuthTokenHeader,
+  setupAxiosInterceptors,
+};

+ 66 - 48
frontend/src/api/github.ts

@@ -1,42 +1,81 @@
 import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
 import { github } from "./github-axios-instance";
+import { openHands } from "./open-hands-axios";
 
 /**
- * Checks if the data is a GitHub error response
- * @param data The data to check
- * @returns Boolean indicating if the data is a GitHub error response
+ * Given the user, retrieves app installations IDs for OpenHands Github App
+ * Uses user access token for Github App
  */
-export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
-  data: T | GitHubErrorReponse | null,
-): data is GitHubErrorReponse =>
-  !!data && "message" in data && data.message !== undefined;
+export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
+  const response = await github.get<GithubAppInstallation>(
+    "/user/installations",
+  );
+
+  return response.data.installations.map((installation) => installation.id);
+};
 
 /**
- * Given a GitHub token, retrieves the repositories of the authenticated user
- * @param token The GitHub token
- * @returns A list of repositories or an error response
+ * Retrieves repositories where OpenHands Github App has been installed
+ * @param installationIndex Pagination cursor position for app installation IDs
+ * @param installations Collection of all App installation IDs for OpenHands Github App
+ * @returns A list of repositories
  */
-export const retrieveGitHubUserRepositories = async (
+export const retrieveGitHubAppRepositories = async (
+  installationIndex: number,
+  installations: number[],
   page = 1,
   per_page = 30,
 ) => {
-  const response = await github.get<GitHubRepository[]>("/user/repos", {
-    params: {
-      sort: "pushed",
-      page,
-      per_page,
+  const installationId = installations[installationIndex];
+  const response = await openHands.get<GitHubAppRepository>(
+    "/api/github/repositories",
+    {
+      params: {
+        sort: "pushed",
+        page,
+        per_page,
+        installation_id: installationId,
+      },
     },
-    transformResponse: (data) => {
-      const parsedData: GitHubRepository[] | GitHubErrorReponse =
-        JSON.parse(data);
+  );
 
-      if (isGitHubErrorReponse(parsedData)) {
-        throw new Error(parsedData.message);
-      }
+  const link = response.headers.link ?? "";
+  const nextPage = extractNextPageFromLink(link);
+  let nextInstallation: number | null;
+
+  if (nextPage) {
+    nextInstallation = installationIndex;
+  } else if (installationIndex + 1 < installations.length) {
+    nextInstallation = installationIndex + 1;
+  } else {
+    nextInstallation = null;
+  }
+
+  return {
+    data: response.data.repositories,
+    nextPage,
+    installationIndex: nextInstallation,
+  };
+};
 
-      return parsedData;
+/**
+ * Given a PAT, retrieves the repositories of the user
+ * @returns A list of repositories
+ */
+export const retrieveGitHubUserRepositories = async (
+  page = 1,
+  per_page = 30,
+) => {
+  const response = await openHands.get<GitHubRepository[]>(
+    "/api/github/repositories",
+    {
+      params: {
+        sort: "pushed",
+        page,
+        per_page,
+      },
     },
-  });
+  );
 
   const link = response.headers.link ?? "";
   const nextPage = extractNextPageFromLink(link);
@@ -46,21 +85,10 @@ export const retrieveGitHubUserRepositories = async (
 
 /**
  * Given a GitHub token, retrieves the authenticated user
- * @param token The GitHub token
  * @returns The authenticated user or an error response
  */
 export const retrieveGitHubUser = async () => {
-  const response = await github.get<GitHubUser>("/user", {
-    transformResponse: (data) => {
-      const parsedData: GitHubUser | GitHubErrorReponse = JSON.parse(data);
-
-      if (isGitHubErrorReponse(parsedData)) {
-        throw new Error(parsedData.message);
-      }
-
-      return parsedData;
-    },
-  });
+  const response = await github.get<GitHubUser>("/user");
 
   const { data } = response;
 
@@ -79,24 +107,14 @@ export const retrieveGitHubUser = async () => {
 export const retrieveLatestGitHubCommit = async (
   repository: string,
 ): Promise<GitHubCommit> => {
-  const response = await github.get<GitHubCommit>(
+  const response = await github.get<GitHubCommit[]>(
     `/repos/${repository}/commits`,
     {
       params: {
         per_page: 1,
       },
-      transformResponse: (data) => {
-        const parsedData: GitHubCommit[] | GitHubErrorReponse =
-          JSON.parse(data);
-
-        if (isGitHubErrorReponse(parsedData)) {
-          throw new Error(parsedData.message);
-        }
-
-        return parsedData[0];
-      },
     },
   );
 
-  return response.data;
+  return response.data[0];
 };

+ 17 - 1
frontend/src/api/open-hands.ts

@@ -42,7 +42,9 @@ class OpenHands {
   }
 
   static async getConfig(): Promise<GetConfigResponse> {
-    const { data } = await openHands.get<GetConfigResponse>("/config.json");
+    const { data } = await openHands.get<GetConfigResponse>(
+      "/api/options/config",
+    );
     return data;
   }
 
@@ -136,6 +138,20 @@ class OpenHands {
     return response.status === 200;
   }
 
+  /**
+   * Refresh Github Token
+   * @returns Refreshed Github access token
+   */
+  static async refreshToken(
+    appMode: GetConfigResponse["APP_MODE"],
+  ): Promise<string> {
+    if (appMode === "oss") return "";
+
+    const response =
+      await openHands.post<GitHubAccessTokenResponse>("/api/refresh-token");
+    return response.data.access_token;
+  }
+
   /**
    * Get the blob of the workspace zip
    * @returns Blob of the workspace zip

+ 1 - 0
frontend/src/api/open-hands.types.ts

@@ -43,6 +43,7 @@ export interface Feedback {
 
 export interface GetConfigResponse {
   APP_MODE: "saas" | "oss";
+  APP_SLUG?: string;
   GITHUB_CLIENT_ID: string;
   POSTHOG_CLIENT_KEY: string;
 }

+ 34 - 3
frontend/src/components/features/github/github-repo-selector.tsx

@@ -2,6 +2,7 @@ import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
 import { useDispatch } from "react-redux";
 import posthog from "posthog-js";
 import { setSelectedRepository } from "#/state/initial-query-slice";
+import { useConfig } from "#/hooks/query/use-config";
 
 interface GitHubRepositorySelectorProps {
   onSelect: () => void;
@@ -12,11 +13,25 @@ export function GitHubRepositorySelector({
   onSelect,
   repositories,
 }: GitHubRepositorySelectorProps) {
+  const { data: config } = useConfig();
+
+  // Add option to install app onto more repos
+  const finalRepositories =
+    config?.APP_MODE === "saas"
+      ? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
+      : repositories;
+
   const dispatch = useDispatch();
 
   const handleRepoSelection = (id: string | null) => {
-    const repo = repositories.find((r) => r.id.toString() === id);
-    if (repo) {
+    const repo = finalRepositories.find((r) => r.id.toString() === id);
+    if (id === "-1000") {
+      if (config?.APP_SLUG)
+        window.open(
+          `https://github.com/apps/${config.APP_SLUG}/installations/new`,
+          "_blank",
+        );
+    } else if (repo) {
       // set query param
       dispatch(setSelectedRepository(repo.full_name));
       posthog.capture("repository_selected");
@@ -29,6 +44,19 @@ export function GitHubRepositorySelector({
     dispatch(setSelectedRepository(null));
   };
 
+  const emptyContent = config?.APP_SLUG ? (
+    <a
+      href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
+      target="_blank"
+      rel="noreferrer noopener"
+      className="underline"
+    >
+      Add more repositories...
+    </a>
+  ) : (
+    "No results found."
+  );
+
   return (
     <Autocomplete
       data-testid="github-repo-selector"
@@ -43,8 +71,11 @@ export function GitHubRepositorySelector({
       }}
       onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
       clearButtonProps={{ onClick: handleClearSelection }}
+      listboxProps={{
+        emptyContent,
+      }}
     >
-      {repositories.map((repo) => (
+      {finalRepositories.map((repo) => (
         <AutocompleteItem
           data-testid="github-repo-item"
           key={repo.id}

+ 1 - 1
frontend/src/components/features/github/github-repositories-suggestion-box.tsx

@@ -1,11 +1,11 @@
 import React from "react";
-import { isGitHubErrorReponse } from "#/api/github";
 import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
 import GitHubLogo from "#/assets/branding/github-logo.svg?react";
 import { GitHubRepositorySelector } from "./github-repo-selector";
 import { ModalButton } from "#/components/shared/buttons/modal-button";
 import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
 import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { isGitHubErrorReponse } from "#/api/github-axios-instance";
 
 interface GitHubRepositoriesSuggestionBoxProps {
   handleSubmit: () => void;

+ 33 - 17
frontend/src/components/shared/modals/account-settings/account-settings-form.tsx

@@ -13,6 +13,7 @@ import { handleCaptureConsent } from "#/utils/handle-capture-consent";
 import { ModalButton } from "../../buttons/modal-button";
 import { CustomInput } from "../../custom-input";
 import { FormFieldset } from "../../form-fieldset";
+import { useConfig } from "#/hooks/query/use-config";
 
 interface AccountSettingsFormProps {
   onClose: () => void;
@@ -28,6 +29,7 @@ export function AccountSettingsForm({
   analyticsConsent,
 }: AccountSettingsFormProps) {
   const { gitHubToken, setGitHubToken, logout } = useAuth();
+  const { data: config } = useConfig();
   const { saveSettings } = useUserPrefs();
   const { t } = useTranslation();
 
@@ -64,6 +66,16 @@ export function AccountSettingsForm({
         <div className="w-full flex flex-col gap-2">
           <BaseModalTitle title="Account Settings" />
 
+          {config?.APP_MODE === "saas" && config?.APP_SLUG && (
+            <a
+              href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
+              target="_blank"
+              rel="noreferrer noopener"
+              className="underline"
+            >
+              Configure Github Repositories
+            </a>
+          )}
           <FormFieldset
             id="language"
             label="Language"
@@ -75,23 +87,27 @@ export function AccountSettingsForm({
             }))}
           />
 
-          <CustomInput
-            name="ghToken"
-            label="GitHub Token"
-            type="password"
-            defaultValue={gitHubToken ?? ""}
-          />
-          <BaseModalDescription>
-            {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
-            <a
-              href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
-              target="_blank"
-              rel="noreferrer noopener"
-              className="text-[#791B80] underline"
-            >
-              {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
-            </a>
-          </BaseModalDescription>
+          {config?.APP_MODE !== "saas" && (
+            <>
+              <CustomInput
+                name="ghToken"
+                label="GitHub Token"
+                type="password"
+                defaultValue={gitHubToken ?? ""}
+              />
+              <BaseModalDescription>
+                {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
+                <a
+                  href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
+                  target="_blank"
+                  rel="noreferrer noopener"
+                  className="text-[#791B80] underline"
+                >
+                  {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
+                </a>
+              </BaseModalDescription>
+            </>
+          )}
           {gitHubError && (
             <p className="text-danger text-xs">
               {t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}

+ 4 - 2
frontend/src/components/shared/modals/settings/settings-form.tsx

@@ -24,6 +24,7 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
 import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
 import { ModalBackdrop } from "../modal-backdrop";
 import { ModelSelector } from "./model-selector";
+import { useAuth } from "#/context/auth-context";
 
 interface SettingsFormProps {
   disabled?: boolean;
@@ -44,6 +45,7 @@ export function SettingsForm({
 }: SettingsFormProps) {
   const { saveSettings } = useUserPrefs();
   const endSession = useEndSession();
+  const { logout } = useAuth();
 
   const location = useLocation();
   const { t } = useTranslation();
@@ -96,9 +98,9 @@ export function SettingsForm({
     const isUsingAdvancedOptions = keys.includes("use-advanced-options");
     const newSettings = extractSettings(formData);
 
-    saveSettings(newSettings);
     saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
-    updateSettingsVersion();
+    updateSettingsVersion(logout);
+    saveSettings(newSettings);
     resetOngoingSession();
 
     posthog.capture("settings_saved", {

+ 27 - 5
frontend/src/context/auth-context.tsx

@@ -1,5 +1,6 @@
 import posthog from "posthog-js";
 import React from "react";
+import OpenHands from "#/api/open-hands";
 import {
   removeAuthTokenHeader as removeOpenHandsAuthTokenHeader,
   removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
@@ -9,6 +10,7 @@ import {
 import {
   setAuthTokenHeader as setGitHubAuthTokenHeader,
   removeAuthTokenHeader as removeGitHubAuthTokenHeader,
+  setupAxiosInterceptors as setupGithubAxiosInterceptors,
 } from "#/api/github-axios-instance";
 
 interface AuthContextType {
@@ -18,6 +20,7 @@ interface AuthContextType {
   setGitHubToken: (token: string | null) => void;
   clearToken: () => void;
   clearGitHubToken: () => void;
+  refreshToken: () => Promise<boolean>;
   logout: () => void;
 }
 
@@ -69,19 +72,37 @@ function AuthProvider({ children }: React.PropsWithChildren) {
     }
   };
 
+  const logout = () => {
+    clearGitHubToken();
+    posthog.reset();
+  };
+
+  const refreshToken = async (): Promise<boolean> => {
+    const config = await OpenHands.getConfig();
+
+    if (config.APP_MODE !== "saas" || !gitHubTokenState) {
+      return false;
+    }
+
+    const newToken = await OpenHands.refreshToken(config.APP_MODE);
+    if (newToken) {
+      setGitHubToken(newToken);
+      return true;
+    }
+
+    clearGitHubToken();
+    return false;
+  };
+
   React.useEffect(() => {
     const storedToken = localStorage.getItem("token");
     const storedGitHubToken = localStorage.getItem("ghToken");
 
     setToken(storedToken);
     setGitHubToken(storedGitHubToken);
+    setupGithubAxiosInterceptors(refreshToken, logout);
   }, []);
 
-  const logout = () => {
-    clearGitHubToken();
-    posthog.reset();
-  };
-
   const value = React.useMemo(
     () => ({
       token: tokenState,
@@ -90,6 +111,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
       setGitHubToken,
       clearToken,
       clearGitHubToken,
+      refreshToken,
       logout,
     }),
     [tokenState, gitHubTokenState],

+ 21 - 0
frontend/src/hooks/query/use-app-installations.ts

@@ -0,0 +1,21 @@
+import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "#/context/auth-context";
+import { useConfig } from "./use-config";
+import { retrieveGitHubAppInstallations } from "#/api/github";
+
+export const useAppInstallations = () => {
+  const { data: config } = useConfig();
+  const { gitHubToken } = useAuth();
+
+  return useQuery({
+    queryKey: ["installations", gitHubToken, config?.GITHUB_CLIENT_ID],
+    queryFn: async () => {
+      const data = await retrieveGitHubAppInstallations();
+      return data;
+    },
+    enabled:
+      !!gitHubToken &&
+      !!config?.GITHUB_CLIENT_ID &&
+      config?.APP_MODE === "saas",
+  });
+};

+ 65 - 0
frontend/src/hooks/query/use-app-repositories.ts

@@ -0,0 +1,65 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import React from "react";
+import { retrieveGitHubAppRepositories } from "#/api/github";
+import { useAuth } from "#/context/auth-context";
+import { useAppInstallations } from "./use-app-installations";
+import { useConfig } from "./use-config";
+
+export const useAppRepositories = () => {
+  const { gitHubToken } = useAuth();
+  const { data: config } = useConfig();
+  const { data: installations } = useAppInstallations();
+
+  const repos = useInfiniteQuery({
+    queryKey: ["repositories", gitHubToken, installations],
+    queryFn: async ({
+      pageParam,
+    }: {
+      pageParam: { installationIndex: number | null; repoPage: number | null };
+    }) => {
+      const { repoPage, installationIndex } = pageParam;
+
+      if (!installations) {
+        throw new Error("Missing installation list");
+      }
+
+      return retrieveGitHubAppRepositories(
+        installationIndex || 0,
+        installations,
+        repoPage || 1,
+        30,
+      );
+    },
+    initialPageParam: { installationIndex: 0, repoPage: 1 },
+    getNextPageParam: (lastPage) => {
+      if (lastPage.nextPage) {
+        return {
+          installationIndex: lastPage.installationIndex,
+          repoPage: lastPage.nextPage,
+        };
+      }
+
+      if (lastPage.installationIndex !== null) {
+        return { installationIndex: lastPage.installationIndex, repoPage: 1 };
+      }
+
+      return null;
+    },
+    enabled:
+      !!gitHubToken &&
+      Array.isArray(installations) &&
+      installations.length > 0 &&
+      config?.APP_MODE === "saas",
+  });
+
+  // TODO: Once we create our custom dropdown component, we should fetch data onEndReached
+  // (nextui autocomplete doesn't support onEndReached nor is it compatible for extending)
+  const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos;
+  React.useEffect(() => {
+    if (!isFetchingNextPage && isSuccess && hasNextPage) {
+      fetchNextPage();
+    }
+  }, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]);
+
+  return repos;
+};

+ 3 - 1
frontend/src/hooks/query/use-user-repositories.ts

@@ -2,9 +2,11 @@ import { useInfiniteQuery } from "@tanstack/react-query";
 import React from "react";
 import { retrieveGitHubUserRepositories } from "#/api/github";
 import { useAuth } from "#/context/auth-context";
+import { useConfig } from "./use-config";
 
 export const useUserRepositories = () => {
   const { gitHubToken } = useAuth();
+  const { data: config } = useConfig();
 
   const repos = useInfiniteQuery({
     queryKey: ["repositories", gitHubToken],
@@ -12,7 +14,7 @@ export const useUserRepositories = () => {
       retrieveGitHubUserRepositories(pageParam, 100),
     initialPageParam: 1,
     getNextPageParam: (lastPage) => lastPage.nextPage,
-    enabled: !!gitHubToken,
+    enabled: !!gitHubToken && config?.APP_MODE === "oss",
   });
 
   // TODO: Once we create our custom dropdown component, we should fetch data onEndReached

+ 7 - 2
frontend/src/routes/_oh._index/route.tsx

@@ -5,6 +5,8 @@ import posthog from "posthog-js";
 import { setImportedProjectZip } from "#/state/initial-query-slice";
 import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
 import { useUserRepositories } from "#/hooks/query/use-user-repositories";
+import { useAppRepositories } from "#/hooks/query/use-app-repositories";
+
 import { useGitHubUser } from "#/hooks/query/use-github-user";
 import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
 import { useConfig } from "#/hooks/query/use-config";
@@ -25,7 +27,8 @@ function Home() {
 
   const { data: config } = useConfig();
   const { data: user } = useGitHubUser();
-  const { data: repositories } = useUserRepositories();
+  const { data: appRepositories } = useAppRepositories();
+  const { data: userRepositories } = useUserRepositories();
 
   const gitHubAuthUrl = useGitHubAuthUrl({
     gitHubToken,
@@ -52,7 +55,9 @@ function Home() {
           <GitHubRepositoriesSuggestionBox
             handleSubmit={() => formRef.current?.requestSubmit()}
             repositories={
-              repositories?.pages.flatMap((page) => page.data) || []
+              userRepositories?.pages.flatMap((page) => page.data) ||
+              appRepositories?.pages.flatMap((page) => page.data) ||
+              []
             }
             gitHubAuthUrl={gitHubAuthUrl}
             user={user || null}

+ 1 - 1
frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts

@@ -1,7 +1,6 @@
 import React from "react";
 import toast from "react-hot-toast";
 import { useDispatch, useSelector } from "react-redux";
-import { isGitHubErrorReponse } from "#/api/github";
 import { useAuth } from "#/context/auth-context";
 import {
   useWsClient,
@@ -13,6 +12,7 @@ import { RootState } from "#/store";
 import { base64ToBlob } from "#/utils/base64-to-blob";
 import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
 import { useGitHubUser } from "../../../hooks/query/use-github-user";
+import { isGitHubErrorReponse } from "#/api/github-axios-instance";
 
 export const useHandleRuntimeActive = () => {
   const { gitHubToken } = useAuth();

+ 8 - 0
frontend/src/routes/_oh/route.tsx

@@ -9,6 +9,7 @@ import { useConfig } from "#/hooks/query/use-config";
 import { Sidebar } from "#/components/features/sidebar/sidebar";
 import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
 import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
+import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
 
 export function ErrorBoundary() {
   const error = useRouteError();
@@ -76,6 +77,9 @@ export default function MainApp() {
   const isInWaitlist =
     !isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
 
+  const { settingsAreUpToDate } = useUserPrefs();
+  const [showAIConfig, setShowAIConfig] = React.useState(true);
+
   return (
     <div
       data-testid="root-layout"
@@ -96,6 +100,10 @@ export default function MainApp() {
           onClose={() => setConsentFormIsOpen(false)}
         />
       )}
+
+      {(isAuthed || !settingsAreUpToDate) && showAIConfig && (
+        <SettingsModal onClose={() => setShowAIConfig(false)} />
+      )}
     </div>
   );
 }

+ 7 - 2
frontend/src/services/settings.ts

@@ -1,4 +1,4 @@
-export const LATEST_SETTINGS_VERSION = 3;
+export const LATEST_SETTINGS_VERSION = 4;
 
 export type Settings = {
   LLM_MODEL: string;
@@ -35,10 +35,11 @@ export const getCurrentSettingsVersion = () => {
 export const settingsAreUpToDate = () =>
   getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
 
-export const maybeMigrateSettings = () => {
+export const maybeMigrateSettings = (logout: () => void) => {
   // Sometimes we ship major changes, like a new default agent.
   // In this case, we may want to override a previous choice made by the user.
   const currentVersion = getCurrentSettingsVersion();
+
   if (currentVersion < 1) {
     localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
   }
@@ -53,6 +54,10 @@ export const maybeMigrateSettings = () => {
   if (currentVersion < 3) {
     localStorage.removeItem("token");
   }
+
+  if (currentVersion < 4) {
+    logout();
+  }
 };
 
 /**

+ 8 - 0
frontend/src/types/github.d.ts

@@ -18,6 +18,10 @@ interface GitHubRepository {
   full_name: string;
 }
 
+interface GitHubAppRepository {
+  repositories: GitHubRepository[];
+}
+
 interface GitHubCommit {
   html_url: string;
   sha: string;
@@ -27,3 +31,7 @@ interface GitHubCommit {
     };
   };
 }
+
+interface GithubAppInstallation {
+  installations: { id: number }[];
+}

+ 2 - 1
frontend/src/utils/generate-github-auth-url.ts

@@ -6,5 +6,6 @@
  */
 export const generateGitHubAuthUrl = (clientId: string, requestUrl: URL) => {
   const redirectUri = `${requestUrl.origin}/oauth/github/callback`;
-  return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user,workflow`;
+  const scope = "repo,user,workflow,offline_access";
+  return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
 };

+ 2 - 2
frontend/src/utils/settings-utils.ts

@@ -82,9 +82,9 @@ const saveSettingsView = (view: "basic" | "advanced") => {
  * Updates the settings version in local storage if the current settings are not up to date.
  * If the settings are outdated, it attempts to migrate them before updating the version.
  */
-const updateSettingsVersion = () => {
+const updateSettingsVersion = (logout: () => void) => {
   if (!settingsAreUpToDate()) {
-    maybeMigrateSettings();
+    maybeMigrateSettings(logout);
     localStorage.setItem(
       "SETTINGS_VERSION",
       LATEST_SETTINGS_VERSION.toString(),

+ 0 - 3
openhands/core/config/app_config.py

@@ -66,9 +66,6 @@ class AppConfig:
     modal_api_token_secret: str = ''
     disable_color: bool = False
     jwt_secret: str = ''
-    attach_session_middleware_class: str = (
-        'openhands.server.middleware.AttachSessionMiddleware'
-    )
     debug: bool = False
     file_uploads_max_file_size_mb: int = 0
     file_uploads_restrict_file_types: bool = False

+ 1 - 1
openhands/runtime/base.py

@@ -221,7 +221,7 @@ class Runtime(FileEditRuntimeMixin):
         action = CmdRunAction(
             command=f'git clone {url} {dir_name} ; cd {dir_name} ; git checkout -b openhands-workspace'
         )
-        self.log('info', 'Cloning repo: {selected_repository}')
+        self.log('info', f'Cloning repo: {selected_repository}')
         self.run_action(action)
 
     def get_custom_microagents(self, selected_repository: str | None) -> list[str]:

+ 5 - 4
openhands/server/app.py

@@ -16,13 +16,13 @@ from openhands.server.middleware import (
     NoCacheMiddleware,
     RateLimitMiddleware,
 )
-from openhands.server.routes.auth import app as auth_api_router
 from openhands.server.routes.conversation import app as conversation_api_router
 from openhands.server.routes.feedback import app as feedback_api_router
 from openhands.server.routes.files import app as files_api_router
+from openhands.server.routes.github import app as github_api_router
 from openhands.server.routes.public import app as public_api_router
 from openhands.server.routes.security import app as security_api_router
-from openhands.server.shared import config, session_manager
+from openhands.server.shared import openhands_config, session_manager
 from openhands.utils.import_utils import get_impl
 
 
@@ -51,15 +51,16 @@ async def health():
     return 'OK'
 
 
-app.include_router(auth_api_router)
 app.include_router(public_api_router)
 app.include_router(files_api_router)
 app.include_router(conversation_api_router)
 app.include_router(security_api_router)
 app.include_router(feedback_api_router)
+app.include_router(github_api_router)
+
 
 AttachSessionMiddlewareImpl = get_impl(
-    AttachSessionMiddleware, config.attach_session_middleware_class
+    AttachSessionMiddleware, openhands_config.attach_session_middleware_path
 )
 app.middleware('http')(AttachSessionMiddlewareImpl(app, target_router=files_api_router))
 app.middleware('http')(

+ 2 - 2
openhands/server/auth.py

@@ -30,10 +30,10 @@ def get_sid_from_token(token: str, jwt_secret: str) -> str:
     return ''
 
 
-def sign_token(payload: dict[str, object], jwt_secret: str) -> str:
+def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
     """Signs a JWT token."""
     # payload = {
     #     "sid": sid,
     #     # "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
     # }
-    return jwt.encode(payload, jwt_secret, algorithm='HS256')
+    return jwt.encode(payload, jwt_secret, algorithm=algorithm)

+ 58 - 0
openhands/server/config/openhands_config.py

@@ -0,0 +1,58 @@
+import os
+
+from fastapi import HTTPException
+
+from openhands.core.logger import openhands_logger as logger
+from openhands.server.types import AppMode, OpenhandsConfigInterface
+from openhands.utils.import_utils import get_impl
+
+
+class OpenhandsConfig(OpenhandsConfigInterface):
+    config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
+    app_mode = AppMode.OSS
+    posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
+    github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
+    attach_session_middleware_path = (
+        'openhands.server.middleware.AttachSessionMiddleware'
+    )
+
+    def verify_config(self):
+        if self.config_cls:
+            raise ValueError('Unexpected config path provided')
+
+    def verify_github_repo_list(self, installation_id: int | None):
+        if self.app_mode == AppMode.OSS and installation_id:
+            raise HTTPException(
+                status_code=400,
+                detail='Unexpected installation ID',
+            )
+
+    def get_config(self):
+        config = {
+            'APP_MODE': self.app_mode,
+            'GITHUB_CLIENT_ID': self.github_client_id,
+            'POSTHOG_CLIENT_KEY': self.posthog_client_key,
+        }
+
+        return config
+
+    async def github_auth(self, data: dict):
+        """
+        Skip Github Auth for AppMode OSS
+        """
+        pass
+
+
+def load_openhands_config():
+    config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
+    logger.info(f'Using config class {config_cls}')
+
+    if config_cls:
+        openhands_config_cls = get_impl(OpenhandsConfig, config_cls)
+    else:
+        openhands_config_cls = OpenhandsConfig
+
+    openhands_config = openhands_config_cls()
+    openhands_config.verify_config()
+
+    return openhands_config

+ 0 - 129
openhands/server/github_utils.py

@@ -1,129 +0,0 @@
-import os
-
-from github import Github
-from github.GithubException import GithubException
-from tenacity import retry, stop_after_attempt, wait_exponential
-
-from openhands.core.logger import openhands_logger as logger
-from openhands.server.sheets_client import GoogleSheetsClient
-from openhands.utils.async_utils import call_sync_from_async
-
-GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '').strip()
-GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '').strip()
-
-
-class UserVerifier:
-    def __init__(self) -> None:
-        logger.debug('Initializing UserVerifier')
-        self.file_users: list[str] | None = None
-        self.sheets_client: GoogleSheetsClient | None = None
-        self.spreadsheet_id: str | None = None
-
-        # Initialize from environment variables
-        self._init_file_users()
-        self._init_sheets_client()
-
-    def _init_file_users(self) -> None:
-        """Load users from text file if configured"""
-        waitlist = os.getenv('GITHUB_USER_LIST_FILE')
-        if not waitlist:
-            logger.debug('GITHUB_USER_LIST_FILE not configured')
-            return
-
-        if not os.path.exists(waitlist):
-            logger.error(f'User list file not found: {waitlist}')
-            raise FileNotFoundError(f'User list file not found: {waitlist}')
-
-        try:
-            with open(waitlist, 'r') as f:
-                self.file_users = [line.strip() for line in f if line.strip()]
-            logger.info(
-                f'Successfully loaded {len(self.file_users)} users from {waitlist}'
-            )
-        except Exception as e:
-            logger.error(f'Error reading user list file {waitlist}: {str(e)}')
-
-    def _init_sheets_client(self) -> None:
-        """Initialize Google Sheets client if configured"""
-        sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
-
-        if not sheet_id:
-            logger.debug('GITHUB_USERS_SHEET_ID not configured')
-            return
-
-        logger.debug('Initializing Google Sheets integration')
-        self.sheets_client = GoogleSheetsClient()
-        self.spreadsheet_id = sheet_id
-
-    def is_active(self) -> bool:
-        return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
-
-    def is_user_allowed(self, username: str) -> bool:
-        """Check if user is allowed based on file and/or sheet configuration"""
-        if not self.is_active():
-            return True
-
-        logger.debug(f'Checking if GitHub user {username} is allowed')
-        if self.file_users:
-            if username in self.file_users:
-                logger.debug(f'User {username} found in text file allowlist')
-                return True
-            logger.debug(f'User {username} not found in text file allowlist')
-
-        if self.sheets_client and self.spreadsheet_id:
-            sheet_users = self.sheets_client.get_usernames(self.spreadsheet_id)
-            if username in sheet_users:
-                logger.debug(f'User {username} found in Google Sheets allowlist')
-                return True
-            logger.debug(f'User {username} not found in Google Sheets allowlist')
-
-        logger.debug(f'User {username} not found in any allowlist')
-        return False
-
-
-async def authenticate_github_user(auth_token) -> bool:
-    user_verifier = UserVerifier()
-
-    if not user_verifier.is_active():
-        logger.debug('No user verification sources configured - allowing all users')
-        return True
-
-    logger.debug('Checking GitHub token')
-
-    if not auth_token:
-        logger.warning('No GitHub token provided')
-        return False
-
-    login = await get_github_user(auth_token)
-
-    if not user_verifier.is_user_allowed(login):
-        logger.warning(f'GitHub user {login} not in allow list')
-        return False
-
-    logger.info(f'GitHub user {login} authenticated')
-    return True
-
-
-@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
-async def get_github_user(token: str) -> str:
-    """Get GitHub user info from token.
-
-    Args:
-        token: GitHub access token
-
-    Returns:
-        github handle of the user
-    """
-    logger.debug('Fetching GitHub user info from token')
-    g = Github(token)
-    try:
-        user = await call_sync_from_async(g.get_user)
-    except GithubException as e:
-        logger.error(f'Error making request to GitHub API: {str(e)}')
-        logger.error(e)
-        raise
-    finally:
-        g.close()
-    login = user.login
-    logger.info(f'Successfully retrieved GitHub user: {login}')
-    return login

+ 4 - 12
openhands/server/listen_socket.py

@@ -1,5 +1,3 @@
-from fastapi import status
-
 from openhands.core.logger import openhands_logger as logger
 from openhands.core.schema.action import ActionType
 from openhands.events.action import (
@@ -12,9 +10,8 @@ from openhands.events.observation.agent import AgentStateChangedObservation
 from openhands.events.serialization import event_to_dict
 from openhands.events.stream import AsyncEventStreamWrapper
 from openhands.server.auth import get_sid_from_token, sign_token
-from openhands.server.github_utils import authenticate_github_user
 from openhands.server.session.session_init_data import SessionInitData
-from openhands.server.shared import config, session_manager, sio
+from openhands.server.shared import config, openhands_config, session_manager, sio
 
 
 @sio.event
@@ -27,16 +24,15 @@ async def oh_action(connection_id: str, data: dict):
     # If it's an init, we do it here.
     action = data.get('action', '')
     if action == ActionType.INIT:
-        token = data.pop('token', None)
+        await openhands_config.github_auth(data)
         github_token = data.pop('github_token', None)
+        token = data.pop('token', None)
         latest_event_id = int(data.pop('latest_event_id', -1))
         kwargs = {k.lower(): v for k, v in (data.get('args') or {}).items()}
         session_init_data = SessionInitData(**kwargs)
         session_init_data.github_token = github_token
         session_init_data.selected_repository = data.get('selected_repository', None)
-        await init_connection(
-            connection_id, token, github_token, session_init_data, latest_event_id
-        )
+        await init_connection(connection_id, token, session_init_data, latest_event_id)
         return
 
     logger.info(f'sio:oh_action:{connection_id}')
@@ -46,13 +42,9 @@ async def oh_action(connection_id: str, data: dict):
 async def init_connection(
     connection_id: str,
     token: str | None,
-    gh_token: str | None,
     session_init_data: SessionInitData,
     latest_event_id: int,
 ):
-    if not await authenticate_github_user(gh_token):
-        raise RuntimeError(status.WS_1008_POLICY_VIOLATION)
-
     if token:
         sid = get_sid_from_token(token, config.jwt_secret)
         if sid == '':

+ 37 - 40
openhands/server/middleware.py

@@ -4,7 +4,6 @@ from datetime import datetime, timedelta
 from typing import Callable
 from urllib.parse import urlparse
 
-import jwt
 from fastapi import APIRouter, Request, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse
@@ -13,8 +12,8 @@ from starlette.types import ASGIApp
 
 from openhands.core.logger import openhands_logger as logger
 from openhands.server.auth import get_sid_from_token
-from openhands.server.github_utils import UserVerifier
 from openhands.server.shared import config, session_manager
+from openhands.server.types import SessionMiddlewareInterface
 
 
 class LocalhostCORSMiddleware(CORSMiddleware):
@@ -109,53 +108,32 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
         return await call_next(request)
 
 
-class AttachSessionMiddleware:
+class AttachSessionMiddleware(SessionMiddlewareInterface):
     def __init__(self, app, target_router: APIRouter):
         self.app = app
         self.target_router = target_router
         self.target_paths = {route.path for route in target_router.routes}
 
-    async def __call__(self, request: Request, call_next: Callable):
-        do_attach = False
-        if request.url.path in self.target_paths:
-            do_attach = True
-
+    def _should_attach(self, request) -> bool:
+        """
+        Determine if the middleware should attach a session for the given request.
+        """
         if request.method == 'OPTIONS':
-            do_attach = False
-
-        if not do_attach:
-            return await call_next(request)
-
-        user_verifier = UserVerifier()
-        if user_verifier.is_active():
-            signed_token = request.cookies.get('github_auth')
-            if not signed_token:
-                return JSONResponse(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    content={'error': 'Not authenticated'},
-                )
-            try:
-                jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
-            except Exception as e:
-                logger.warning(f'Invalid token: {e}')
-                return JSONResponse(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    content={'error': 'Invalid token'},
-                )
-
-        if not request.headers.get('Authorization'):
-            logger.warning('Missing Authorization header')
-            return JSONResponse(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                content={'error': 'Missing Authorization header'},
-            )
+            return False
+        if request.url.path not in self.target_paths:
+            return False
+        return True
 
-        auth_token = request.headers.get('Authorization')
+    async def _attach_session(self, request: Request) -> JSONResponse | None:
+        """
+        Attach the user's session based on the provided authentication token.
+        """
+        auth_token = request.headers.get('Authorization', '')
         if 'Bearer' in auth_token:
             auth_token = auth_token.split('Bearer')[1].strip()
 
         request.state.sid = get_sid_from_token(auth_token, config.jwt_secret)
-        if request.state.sid == '':
+        if not request.state.sid:
             logger.warning('Invalid token')
             return JSONResponse(
                 status_code=status.HTTP_401_UNAUTHORIZED,
@@ -165,13 +143,32 @@ class AttachSessionMiddleware:
         request.state.conversation = await session_manager.attach_to_conversation(
             request.state.sid
         )
-        if request.state.conversation is None:
+        if not request.state.conversation:
             return JSONResponse(
                 status_code=status.HTTP_404_NOT_FOUND,
                 content={'error': 'Session not found'},
             )
+        return None
+
+    async def _detach_session(self, request: Request) -> None:
+        """
+        Detach the user's session.
+        """
+        await session_manager.detach_from_conversation(request.state.conversation)
+
+    async def __call__(self, request: Request, call_next: Callable):
+        if not self._should_attach(request):
+            return await call_next(request)
+
+        response = await self._attach_session(request)
+        if response:
+            return response
+
         try:
+            # Continue processing the request
             response = await call_next(request)
         finally:
-            await session_manager.detach_from_conversation(request.state.conversation)
+            # Ensure the session is detached
+            await self._detach_session(request)
+
         return response

+ 10 - 0
openhands/server/mock/listen.py

@@ -58,5 +58,15 @@ def refresh_files():
     return ['hello_world.py']
 
 
+@app.get('/api/options/config')
+def get_config():
+    return {'APP_MODE': 'oss'}
+
+
+@app.get('/api/options/security-analyzers')
+def get_analyzers():
+    return []
+
+
 if __name__ == '__main__':
     uvicorn.run(app, host='127.0.0.1', port=3000)

+ 0 - 100
openhands/server/routes/auth.py

@@ -1,100 +0,0 @@
-import time
-import warnings
-
-import requests
-
-from openhands.server.github_utils import (
-    GITHUB_CLIENT_ID,
-    GITHUB_CLIENT_SECRET,
-    authenticate_github_user,
-)
-
-with warnings.catch_warnings():
-    warnings.simplefilter('ignore')
-
-from fastapi import (
-    APIRouter,
-    Request,
-    status,
-)
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel
-
-from openhands.core.logger import openhands_logger as logger
-from openhands.server.auth import sign_token
-from openhands.server.shared import config
-
-app = APIRouter(prefix='/api')
-
-
-class AuthCode(BaseModel):
-    code: str
-
-
-@app.post('/github/callback')
-def github_callback(auth_code: AuthCode):
-    # Prepare data for the token exchange request
-    data = {
-        'client_id': GITHUB_CLIENT_ID,
-        'client_secret': GITHUB_CLIENT_SECRET,
-        'code': auth_code.code,
-    }
-
-    logger.debug('Exchanging code for GitHub token')
-
-    headers = {'Accept': 'application/json'}
-    response = requests.post(
-        'https://github.com/login/oauth/access_token', data=data, headers=headers
-    )
-
-    if response.status_code != 200:
-        logger.error(f'Failed to exchange code for token: {response.text}')
-        return JSONResponse(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            content={'error': 'Failed to exchange code for token'},
-        )
-
-    token_response = response.json()
-
-    if 'access_token' not in token_response:
-        return JSONResponse(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            content={'error': 'No access token in response'},
-        )
-
-    return JSONResponse(
-        status_code=status.HTTP_200_OK,
-        content={'access_token': token_response['access_token']},
-    )
-
-
-@app.post('/authenticate')
-async def authenticate(request: Request):
-    token = request.headers.get('X-GitHub-Token')
-    if not await authenticate_github_user(token):
-        return JSONResponse(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            content={'error': 'Not authorized via GitHub waitlist'},
-        )
-
-    # Create a signed JWT token with 1-hour expiration
-    cookie_data = {
-        'github_token': token,
-        'exp': int(time.time()) + 3600,  # 1 hour expiration
-    }
-    signed_token = sign_token(cookie_data, config.jwt_secret)
-
-    response = JSONResponse(
-        status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
-    )
-
-    # Set secure cookie with signed token
-    response.set_cookie(
-        key='github_auth',
-        value=signed_token,
-        max_age=3600,  # 1 hour in seconds
-        httponly=True,
-        secure=True,
-        samesite='strict',
-    )
-    return response

+ 56 - 0
openhands/server/routes/github.py

@@ -0,0 +1,56 @@
+import requests
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import JSONResponse
+
+from openhands.server.shared import openhands_config
+
+app = APIRouter(prefix='/api')
+
+
+@app.get('/github/repositories')
+def get_github_repositories(
+    request: Request,
+    page: int = 1,
+    per_page: int = 10,
+    sort: str = 'pushed',
+    installation_id: int | None = None,
+):
+    # Extract the GitHub token from the headers
+    github_token = request.headers.get('X-GitHub-Token')
+    if not github_token:
+        raise HTTPException(status_code=400, detail='Missing X-GitHub-Token header')
+
+    openhands_config.verify_github_repo_list(installation_id)
+
+    # Add query parameters
+    params: dict[str, str] = {
+        'page': str(page),
+        'per_page': str(per_page),
+    }
+    # Construct the GitHub API URL
+    if installation_id:
+        github_api_url = (
+            f'https://api.github.com/user/installations/{installation_id}/repositories'
+        )
+    else:
+        github_api_url = 'https://api.github.com/user/repos'
+        params['sort'] = sort
+
+    # Set the authorization header with the GitHub token
+    headers = {
+        'Authorization': f'Bearer {github_token}',
+        'Accept': 'application/vnd.github.v3+json',
+    }
+
+    # Fetch repositories from GitHub
+    try:
+        response = requests.get(github_api_url, headers=headers, params=params)
+        response.raise_for_status()  # Raise an error for HTTP codes >= 400
+    except requests.exceptions.RequestException as e:
+        raise HTTPException(
+            status_code=response.status_code if response else 500,
+            detail=f'Error fetching repositories: {str(e)}',
+        )
+
+    # Return the JSON response
+    return JSONResponse(content=response.json())

+ 10 - 1
openhands/server/routes/public.py

@@ -16,7 +16,7 @@ from openhands.controller.agent import Agent
 from openhands.core.config import LLMConfig
 from openhands.core.logger import openhands_logger as logger
 from openhands.llm import bedrock
-from openhands.server.shared import config
+from openhands.server.shared import config, openhands_config
 
 app = APIRouter(prefix='/api/options')
 
@@ -104,3 +104,12 @@ async def get_security_analyzers():
         list: A sorted list of security analyzer names.
     """
     return sorted(SecurityAnalyzers.keys())
+
+
+@app.get('/config')
+async def get_config():
+    """
+    Get current config
+    """
+
+    return openhands_config.get_config()

+ 2 - 0
openhands/server/shared.py

@@ -4,12 +4,14 @@ import socketio
 from dotenv import load_dotenv
 
 from openhands.core.config import load_app_config
+from openhands.server.config.openhands_config import load_openhands_config
 from openhands.server.session import SessionManager
 from openhands.storage import get_file_store
 
 load_dotenv()
 
 config = load_app_config()
+openhands_config = load_openhands_config()
 file_store = get_file_store(config.file_store, config.file_store_path)
 
 client_manager = None

+ 0 - 68
openhands/server/sheets_client.py

@@ -1,68 +0,0 @@
-from typing import List
-
-from google.auth import default
-from googleapiclient.discovery import build
-from googleapiclient.errors import HttpError
-
-from openhands.core.logger import openhands_logger as logger
-
-
-class GoogleSheetsClient:
-    def __init__(self):
-        """Initialize Google Sheets client using workload identity.
-        Uses application default credentials which supports workload identity when running in GCP.
-        """
-        logger.info('Initializing Google Sheets client with workload identity')
-        try:
-            credentials, project = default(
-                scopes=['https://www.googleapis.com/auth/spreadsheets.readonly']
-            )
-            logger.info(f'Successfully obtained credentials for project: {project}')
-            self.service = build('sheets', 'v4', credentials=credentials)
-            logger.info('Successfully initialized Google Sheets API service')
-        except Exception as e:
-            logger.error(f'Failed to initialize Google Sheets client: {str(e)}')
-            self.service = None
-
-    def get_usernames(self, spreadsheet_id: str, range_name: str = 'A:A') -> List[str]:
-        """Get list of usernames from specified Google Sheet.
-
-        Args:
-            spreadsheet_id: The ID of the Google Sheet
-            range_name: The A1 notation of the range to fetch
-
-        Returns:
-            List of usernames from the sheet
-        """
-        if not self.service:
-            logger.error('Google Sheets service not initialized')
-            return []
-
-        try:
-            logger.info(
-                f'Fetching usernames from sheet {spreadsheet_id}, range {range_name}'
-            )
-            result = (
-                self.service.spreadsheets()
-                .values()
-                .get(spreadsheetId=spreadsheet_id, range=range_name)
-                .execute()
-            )
-
-            values = result.get('values', [])
-            usernames = [
-                str(cell[0]).strip() for cell in values if cell and cell[0].strip()
-            ]
-            logger.info(
-                f'Successfully fetched {len(usernames)} usernames from Google Sheet'
-            )
-            return usernames
-
-        except HttpError as err:
-            logger.error(f'Error accessing Google Sheet {spreadsheet_id}: {err}')
-            return []
-        except Exception as e:
-            logger.error(
-                f'Unexpected error accessing Google Sheet {spreadsheet_id}: {str(e)}'
-            )
-            return []

+ 42 - 0
openhands/server/types.py

@@ -0,0 +1,42 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import ClassVar, Protocol
+
+
+class AppMode(Enum):
+    OSS = 'oss'
+    SAAS = 'saas'
+
+
+class SessionMiddlewareInterface(Protocol):
+    """Protocol for session middleware classes."""
+
+    pass
+
+
+class OpenhandsConfigInterface(ABC):
+    CONFIG_PATH: ClassVar[str | None]
+    APP_MODE: ClassVar[AppMode]
+    POSTHOG_CLIENT_KEY: ClassVar[str]
+    GITHUB_CLIENT_ID: ClassVar[str]
+    ATTACH_SESSION_MIDDLEWARE_PATH: ClassVar[str]
+
+    @abstractmethod
+    def verify_config(self) -> None:
+        """Verify configuration settings."""
+        raise NotImplementedError
+
+    @abstractmethod
+    async def verify_github_repo_list(self, installation_id: int | None) -> None:
+        """Verify that repo list is being called via user's profile or Github App installations."""
+        raise NotImplementedError
+
+    @abstractmethod
+    async def get_config(self) -> dict[str, str]:
+        """Configure attributes for frontend"""
+        raise NotImplementedError
+
+    @abstractmethod
+    async def github_auth(self, data: dict) -> None:
+        """Handle GitHub authentication."""
+        raise NotImplementedError