mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
[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:
parent
dc3e43b999
commit
f9d052c493
@ -1,5 +0,0 @@
|
||||
{
|
||||
"APP_MODE": "oss",
|
||||
"GITHUB_CLIENT_ID": "",
|
||||
"POSTHOG_CLIENT_KEY": "phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA"
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { setSelectedRepository } from "#/state/initial-query-slice";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface GitHubRepositorySelectorProps {
|
||||
onSelect: () => void;
|
||||
@ -12,11 +13,25 @@ export function GitHubRepositorySelector({
|
||||
onSelect,
|
||||
repositories,
|
||||
}: GitHubRepositorySelectorProps) {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Add option to install app onto more repos
|
||||
const finalRepositories =
|
||||
config?.APP_MODE === "saas"
|
||||
? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
|
||||
: repositories;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleRepoSelection = (id: string | null) => {
|
||||
const repo = repositories.find((r) => r.id.toString() === id);
|
||||
if (repo) {
|
||||
const repo = finalRepositories.find((r) => r.id.toString() === id);
|
||||
if (id === "-1000") {
|
||||
if (config?.APP_SLUG)
|
||||
window.open(
|
||||
`https://github.com/apps/${config.APP_SLUG}/installations/new`,
|
||||
"_blank",
|
||||
);
|
||||
} else if (repo) {
|
||||
// set query param
|
||||
dispatch(setSelectedRepository(repo.full_name));
|
||||
posthog.capture("repository_selected");
|
||||
@ -29,6 +44,19 @@ export function GitHubRepositorySelector({
|
||||
dispatch(setSelectedRepository(null));
|
||||
};
|
||||
|
||||
const emptyContent = config?.APP_SLUG ? (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
Add more repositories...
|
||||
</a>
|
||||
) : (
|
||||
"No results found."
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
data-testid="github-repo-selector"
|
||||
@ -43,8 +71,11 @@ export function GitHubRepositorySelector({
|
||||
}}
|
||||
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
|
||||
clearButtonProps={{ onClick: handleClearSelection }}
|
||||
listboxProps={{
|
||||
emptyContent,
|
||||
}}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
{finalRepositories.map((repo) => (
|
||||
<AutocompleteItem
|
||||
data-testid="github-repo-item"
|
||||
key={repo.id}
|
||||
|
||||
@ -1,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;
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
removeAuthTokenHeader as removeOpenHandsAuthTokenHeader,
|
||||
removeGitHubTokenHeader as removeOpenHandsGitHubTokenHeader,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
import {
|
||||
setAuthTokenHeader as setGitHubAuthTokenHeader,
|
||||
removeAuthTokenHeader as removeGitHubAuthTokenHeader,
|
||||
setupAxiosInterceptors as setupGithubAxiosInterceptors,
|
||||
} from "#/api/github-axios-instance";
|
||||
|
||||
interface AuthContextType {
|
||||
@ -18,6 +20,7 @@ interface AuthContextType {
|
||||
setGitHubToken: (token: string | null) => void;
|
||||
clearToken: () => void;
|
||||
clearGitHubToken: () => void;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@ -69,19 +72,37 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const refreshToken = async (): Promise<boolean> => {
|
||||
const config = await OpenHands.getConfig();
|
||||
|
||||
if (config.APP_MODE !== "saas" || !gitHubTokenState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newToken = await OpenHands.refreshToken(config.APP_MODE);
|
||||
if (newToken) {
|
||||
setGitHubToken(newToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearGitHubToken();
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedToken = localStorage.getItem("token");
|
||||
const storedGitHubToken = localStorage.getItem("ghToken");
|
||||
|
||||
setToken(storedToken);
|
||||
setGitHubToken(storedGitHubToken);
|
||||
setupGithubAxiosInterceptors(refreshToken, logout);
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
clearGitHubToken();
|
||||
posthog.reset();
|
||||
};
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
token: tokenState,
|
||||
@ -90,6 +111,7 @@ function AuthProvider({ children }: React.PropsWithChildren) {
|
||||
setGitHubToken,
|
||||
clearToken,
|
||||
clearGitHubToken,
|
||||
refreshToken,
|
||||
logout,
|
||||
}),
|
||||
[tokenState, gitHubTokenState],
|
||||
|
||||
21
frontend/src/hooks/query/use-app-installations.ts
Normal file
21
frontend/src/hooks/query/use-app-installations.ts
Normal 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",
|
||||
});
|
||||
};
|
||||
65
frontend/src/hooks/query/use-app-repositories.ts
Normal file
65
frontend/src/hooks/query/use-app-repositories.ts
Normal 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;
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -5,6 +5,8 @@ import posthog from "posthog-js";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useAppRepositories } from "#/hooks/query/use-app-repositories";
|
||||
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@ -25,7 +27,8 @@ function Home() {
|
||||
|
||||
const { data: config } = useConfig();
|
||||
const { data: user } = useGitHubUser();
|
||||
const { data: repositories } = useUserRepositories();
|
||||
const { data: appRepositories } = useAppRepositories();
|
||||
const { data: userRepositories } = useUserRepositories();
|
||||
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
gitHubToken,
|
||||
@ -52,7 +55,9 @@ function Home() {
|
||||
<GitHubRepositoriesSuggestionBox
|
||||
handleSubmit={() => formRef.current?.requestSubmit()}
|
||||
repositories={
|
||||
repositories?.pages.flatMap((page) => page.data) || []
|
||||
userRepositories?.pages.flatMap((page) => page.data) ||
|
||||
appRepositories?.pages.flatMap((page) => page.data) ||
|
||||
[]
|
||||
}
|
||||
gitHubAuthUrl={gitHubAuthUrl}
|
||||
user={user || null}
|
||||
|
||||
@ -1,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();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export const LATEST_SETTINGS_VERSION = 3;
|
||||
export const LATEST_SETTINGS_VERSION = 4;
|
||||
|
||||
export type Settings = {
|
||||
LLM_MODEL: string;
|
||||
@ -35,10 +35,11 @@ export const getCurrentSettingsVersion = () => {
|
||||
export const settingsAreUpToDate = () =>
|
||||
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
|
||||
|
||||
export const maybeMigrateSettings = () => {
|
||||
export const maybeMigrateSettings = (logout: () => void) => {
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
@ -53,6 +54,10 @@ export const maybeMigrateSettings = () => {
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
8
frontend/src/types/github.d.ts
vendored
8
frontend/src/types/github.d.ts
vendored
@ -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 }[];
|
||||
}
|
||||
|
||||
@ -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)}`;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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')(
|
||||
|
||||
@ -30,10 +30,10 @@ def get_sid_from_token(token: str, jwt_secret: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def sign_token(payload: dict[str, object], jwt_secret: str) -> str:
|
||||
def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
|
||||
"""Signs a JWT token."""
|
||||
# payload = {
|
||||
# "sid": sid,
|
||||
# # "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||
# }
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||
return jwt.encode(payload, jwt_secret, algorithm=algorithm)
|
||||
|
||||
58
openhands/server/config/openhands_config.py
Normal file
58
openhands/server/config/openhands_config.py
Normal 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
|
||||
@ -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
|
||||
@ -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 == '':
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import requests
|
||||
|
||||
from openhands.server.github_utils import (
|
||||
GITHUB_CLIENT_ID,
|
||||
GITHUB_CLIENT_SECRET,
|
||||
authenticate_github_user,
|
||||
)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Request,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.auth import sign_token
|
||||
from openhands.server.shared import config
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
class AuthCode(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@app.post('/github/callback')
|
||||
def github_callback(auth_code: AuthCode):
|
||||
# Prepare data for the token exchange request
|
||||
data = {
|
||||
'client_id': GITHUB_CLIENT_ID,
|
||||
'client_secret': GITHUB_CLIENT_SECRET,
|
||||
'code': auth_code.code,
|
||||
}
|
||||
|
||||
logger.debug('Exchanging code for GitHub token')
|
||||
|
||||
headers = {'Accept': 'application/json'}
|
||||
response = requests.post(
|
||||
'https://github.com/login/oauth/access_token', data=data, headers=headers
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Failed to exchange code for token: {response.text}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'Failed to exchange code for token'},
|
||||
)
|
||||
|
||||
token_response = response.json()
|
||||
|
||||
if 'access_token' not in token_response:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'No access token in response'},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'access_token': token_response['access_token']},
|
||||
)
|
||||
|
||||
|
||||
@app.post('/authenticate')
|
||||
async def authenticate(request: Request):
|
||||
token = request.headers.get('X-GitHub-Token')
|
||||
if not await authenticate_github_user(token):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Not authorized via GitHub waitlist'},
|
||||
)
|
||||
|
||||
# Create a signed JWT token with 1-hour expiration
|
||||
cookie_data = {
|
||||
'github_token': token,
|
||||
'exp': int(time.time()) + 3600, # 1 hour expiration
|
||||
}
|
||||
signed_token = sign_token(cookie_data, config.jwt_secret)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
|
||||
)
|
||||
|
||||
# Set secure cookie with signed token
|
||||
response.set_cookie(
|
||||
key='github_auth',
|
||||
value=signed_token,
|
||||
max_age=3600, # 1 hour in seconds
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite='strict',
|
||||
)
|
||||
return response
|
||||
56
openhands/server/routes/github.py
Normal file
56
openhands/server/routes/github.py
Normal 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())
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
42
openhands/server/types.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user