From f9d052c4938d7a936423a7bf06d8a9221bd0b1ce Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 17 Dec 2024 15:13:40 -0500 Subject: [PATCH] [Refactor]: Changes to Github Authentication (#5371) Co-authored-by: openhands Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> Co-authored-by: Engel Nyst --- frontend/public/config.json | 5 - frontend/src/api/github-axios-instance.ts | 86 +++++++++++- frontend/src/api/github.ts | 114 +++++++++------- frontend/src/api/open-hands.ts | 18 ++- frontend/src/api/open-hands.types.ts | 1 + .../features/github/github-repo-selector.tsx | 37 ++++- .../github-repositories-suggestion-box.tsx | 2 +- .../account-settings-form.tsx | 50 ++++--- .../shared/modals/settings/settings-form.tsx | 6 +- frontend/src/context/auth-context.tsx | 32 ++++- .../src/hooks/query/use-app-installations.ts | 21 +++ .../src/hooks/query/use-app-repositories.ts | 65 +++++++++ .../src/hooks/query/use-user-repositories.ts | 4 +- frontend/src/routes/_oh._index/route.tsx | 9 +- .../hooks/use-handle-runtime-active.ts | 2 +- frontend/src/routes/_oh/route.tsx | 8 ++ frontend/src/services/settings.ts | 9 +- frontend/src/types/github.d.ts | 8 ++ .../src/utils/generate-github-auth-url.ts | 3 +- frontend/src/utils/settings-utils.ts | 4 +- openhands/core/config/app_config.py | 3 - openhands/runtime/base.py | 2 +- openhands/server/app.py | 9 +- openhands/server/auth.py | 4 +- openhands/server/config/openhands_config.py | 58 ++++++++ openhands/server/github_utils.py | 129 ------------------ openhands/server/listen_socket.py | 16 +-- openhands/server/middleware.py | 77 +++++------ openhands/server/mock/listen.py | 10 ++ openhands/server/routes/auth.py | 100 -------------- openhands/server/routes/github.py | 56 ++++++++ openhands/server/routes/public.py | 11 +- openhands/server/shared.py | 2 + openhands/server/sheets_client.py | 68 --------- openhands/server/types.py | 42 ++++++ 35 files changed, 618 insertions(+), 453 deletions(-) delete mode 100644 frontend/public/config.json create mode 100644 frontend/src/hooks/query/use-app-installations.ts create mode 100644 frontend/src/hooks/query/use-app-repositories.ts create mode 100644 openhands/server/config/openhands_config.py delete mode 100644 openhands/server/github_utils.py delete mode 100644 openhands/server/routes/auth.py create mode 100644 openhands/server/routes/github.py delete mode 100644 openhands/server/sheets_client.py create mode 100644 openhands/server/types.py diff --git a/frontend/public/config.json b/frontend/public/config.json deleted file mode 100644 index 7dbb7e1d966c..000000000000 --- a/frontend/public/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "APP_MODE": "oss", - "GITHUB_CLIENT_ID": "", - "POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA" -} diff --git a/frontend/src/api/github-axios-instance.ts b/frontend/src/api/github-axios-instance.ts index 0735efd52d77..21ad5ea90651 100644 --- a/frontend/src/api/github-axios-instance.ts +++ b/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 = >( + data: T | GitHubErrorReponse | null, +): data is GitHubErrorReponse => + !!data && "message" in data && data.message !== undefined; + +// Axios interceptor to handle token refresh +const setupAxiosInterceptors = ( + refreshToken: () => Promise, + 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, +}; diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts index a07527992fa8..b315e2d930a7 100644 --- a/frontend/src/api/github.ts +++ b/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 = >( - data: T | GitHubErrorReponse | null, -): data is GitHubErrorReponse => - !!data && "message" in data && data.message !== undefined; +export const retrieveGitHubAppInstallations = async (): Promise => { + const response = await github.get( + "/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("/user/repos", { - params: { - sort: "pushed", - page, - per_page, + const installationId = installations[installationIndex]; + const response = await openHands.get( + "/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( + "/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("/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("/user"); const { data } = response; @@ -79,24 +107,14 @@ export const retrieveGitHubUser = async () => { export const retrieveLatestGitHubCommit = async ( repository: string, ): Promise => { - const response = await github.get( + const response = await github.get( `/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]; }; diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index f3c5c1896905..1543351dd088 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -42,7 +42,9 @@ class OpenHands { } static async getConfig(): Promise { - const { data } = await openHands.get("/config.json"); + const { data } = await openHands.get( + "/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 { + if (appMode === "oss") return ""; + + const response = + await openHands.post("/api/refresh-token"); + return response.data.access_token; + } + /** * Get the blob of the workspace zip * @returns Blob of the workspace zip diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index c1e1a2d4b821..919d370751ca 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/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; } diff --git a/frontend/src/components/features/github/github-repo-selector.tsx b/frontend/src/components/features/github/github-repo-selector.tsx index 4e6a6f8fc6e2..dd645c892fdc 100644 --- a/frontend/src/components/features/github/github-repo-selector.tsx +++ b/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 ? ( + + Add more repositories... + + ) : ( + "No results found." + ); + return ( handleRepoSelection(id?.toString() ?? null)} clearButtonProps={{ onClick: handleClearSelection }} + listboxProps={{ + emptyContent, + }} > - {repositories.map((repo) => ( + {finalRepositories.map((repo) => ( void; diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx index 3da9b97e2c8d..94b50780b57a 100644 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ b/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({
+ {config?.APP_MODE === "saas" && config?.APP_SLUG && ( + + Configure Github Repositories + + )} - - - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "} - - {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)} - - + {config?.APP_MODE !== "saas" && ( + <> + + + {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "} + + {t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)} + + + + )} {gitHubError && (

{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)} diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index f6063ce68c98..b1bb66ba853e 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/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", { diff --git a/frontend/src/context/auth-context.tsx b/frontend/src/context/auth-context.tsx index 1eab83fa76d0..ddce62a2cee1 100644 --- a/frontend/src/context/auth-context.tsx +++ b/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; logout: () => void; } @@ -69,19 +72,37 @@ function AuthProvider({ children }: React.PropsWithChildren) { } }; + const logout = () => { + clearGitHubToken(); + posthog.reset(); + }; + + const refreshToken = async (): Promise => { + 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], diff --git a/frontend/src/hooks/query/use-app-installations.ts b/frontend/src/hooks/query/use-app-installations.ts new file mode 100644 index 000000000000..861ed4bd27a1 --- /dev/null +++ b/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", + }); +}; diff --git a/frontend/src/hooks/query/use-app-repositories.ts b/frontend/src/hooks/query/use-app-repositories.ts new file mode 100644 index 000000000000..b06150a3ac0f --- /dev/null +++ b/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; +}; diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts index c2ccaee8a1de..222b390575f3 100644 --- a/frontend/src/hooks/query/use-user-repositories.ts +++ b/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 diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index 60c5954aaaf8..6785e390fb98 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/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() { 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} diff --git a/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts b/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts index e5162f4dc0de..4fa131fe5659 100644 --- a/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts +++ b/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(); diff --git a/frontend/src/routes/_oh/route.tsx b/frontend/src/routes/_oh/route.tsx index 7812dec4d115..5055e24d2383 100644 --- a/frontend/src/routes/_oh/route.tsx +++ b/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 (

setConsentFormIsOpen(false)} /> )} + + {(isAuthed || !settingsAreUpToDate) && showAIConfig && ( + setShowAIConfig(false)} /> + )}
); } diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 53c717d32163..ee2a30a9db4b 100644 --- a/frontend/src/services/settings.ts +++ b/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(); + } }; /** diff --git a/frontend/src/types/github.d.ts b/frontend/src/types/github.d.ts index 20c395ce08d3..7f18464f4ffd 100644 --- a/frontend/src/types/github.d.ts +++ b/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 }[]; +} diff --git a/frontend/src/utils/generate-github-auth-url.ts b/frontend/src/utils/generate-github-auth-url.ts index 9927576b411a..87370b978496 100644 --- a/frontend/src/utils/generate-github-auth-url.ts +++ b/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)}`; }; diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index 0e8b9aae2753..9f0dfa24c9fd 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/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(), diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py index 497cb9061e38..c049e6817473 100644 --- a/openhands/core/config/app_config.py +++ b/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 diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 5d4e79e19c80..e2d8044ba78a 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -221,7 +221,7 @@ def clone_repo(self, github_token: str | None, selected_repository: str | None): 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]: diff --git a/openhands/server/app.py b/openhands/server/app.py index 6207ccf2eb0b..96388835378b 100644 --- a/openhands/server/app.py +++ b/openhands/server/app.py @@ -16,13 +16,13 @@ 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')( diff --git a/openhands/server/auth.py b/openhands/server/auth.py index d668650f5834..4b3fccdda7f1 100644 --- a/openhands/server/auth.py +++ b/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) diff --git a/openhands/server/config/openhands_config.py b/openhands/server/config/openhands_config.py new file mode 100644 index 000000000000..33d79906ff90 --- /dev/null +++ b/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 diff --git a/openhands/server/github_utils.py b/openhands/server/github_utils.py deleted file mode 100644 index 887136a170ca..000000000000 --- a/openhands/server/github_utils.py +++ /dev/null @@ -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 diff --git a/openhands/server/listen_socket.py b/openhands/server/listen_socket.py index 76fe331cc10d..4875a2f8f8b5 100644 --- a/openhands/server/listen_socket.py +++ b/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.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 == '': diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index 803887471e0e..044bc2c9a5f0 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -4,7 +4,6 @@ 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 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 @@ async def dispatch(self, request, call_next): 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 @@ async def __call__(self, request: Request, call_next: Callable): 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 diff --git a/openhands/server/mock/listen.py b/openhands/server/mock/listen.py index 81367a855d70..30aaef68589a 100644 --- a/openhands/server/mock/listen.py +++ b/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) diff --git a/openhands/server/routes/auth.py b/openhands/server/routes/auth.py deleted file mode 100644 index 67151f7e96e0..000000000000 --- a/openhands/server/routes/auth.py +++ /dev/null @@ -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 diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py new file mode 100644 index 000000000000..273c3fe6cc61 --- /dev/null +++ b/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()) diff --git a/openhands/server/routes/public.py b/openhands/server/routes/public.py index dae4278078ec..5a8925b741b4 100644 --- a/openhands/server/routes/public.py +++ b/openhands/server/routes/public.py @@ -16,7 +16,7 @@ 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() diff --git a/openhands/server/shared.py b/openhands/server/shared.py index a7cc5c87c0cf..1b8bd8301a16 100644 --- a/openhands/server/shared.py +++ b/openhands/server/shared.py @@ -4,12 +4,14 @@ 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 diff --git a/openhands/server/sheets_client.py b/openhands/server/sheets_client.py deleted file mode 100644 index c2db1a343477..000000000000 --- a/openhands/server/sheets_client.py +++ /dev/null @@ -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 [] diff --git a/openhands/server/types.py b/openhands/server/types.py new file mode 100644 index 000000000000..2deca93fe904 --- /dev/null +++ b/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