[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>
This commit is contained in:
Rohit Malhotra 2024-12-17 15:13:40 -05:00 committed by GitHub
parent dc3e43b999
commit f9d052c493
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 620 additions and 455 deletions

View File

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

View File

@ -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,
};

View File

@ -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 retrieveGitHubAppRepositories = async (
installationIndex: number,
installations: number[],
page = 1,
per_page = 30,
) => {
const installationId = installations[installationIndex];
const response = await openHands.get<GitHubAppRepository>(
"/api/github/repositories",
{
params: {
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
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,
};
};
/**
* 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 github.get<GitHubRepository[]>("/user/repos", {
params: {
sort: "pushed",
page,
per_page,
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
page,
per_page,
},
},
transformResponse: (data) => {
const parsedData: GitHubRepository[] | GitHubErrorReponse =
JSON.parse(data);
if (isGitHubErrorReponse(parsedData)) {
throw new Error(parsedData.message);
}
return parsedData;
},
});
);
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];
};

View File

@ -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

View File

@ -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;
}

View File

@ -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}

View File

@ -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;

View File

@ -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)}

View File

@ -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", {

View File

@ -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],

View File

@ -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",
});
};

View File

@ -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;
};

View File

@ -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

View File

@ -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}

View File

@ -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();

View File

@ -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>
);
}

View File

@ -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();
}
};
/**

View File

@ -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 }[];
}

View File

@ -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)}`;
};

View File

@ -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(),

View File

@ -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

View File

@ -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]:

View File

@ -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')(

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 == '':

View File

@ -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
return False
if request.url.path not in self.target_paths:
return False
return True
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'},
)
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

View File

@ -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)

View File

@ -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

View File

@ -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())

View File

@ -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()

View File

@ -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

View File

@ -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
openhands/server/types.py Normal file
View File

@ -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