mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
[Feat]: Support self hosted gitlab + enterprise github (#8274)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
This commit is contained in:
@@ -138,8 +138,8 @@ describe("Content", () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
gitlab: "some-token",
|
||||
github: null,
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
@@ -163,7 +163,7 @@ describe("Content", () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
@@ -241,8 +241,8 @@ describe("Form submission", () => {
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token" },
|
||||
gitlab: { token: "" },
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
});
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
@@ -250,8 +250,8 @@ describe("Form submission", () => {
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token" },
|
||||
gitlab: { token: "" },
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,7 +290,7 @@ describe("Form submission", () => {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: null,
|
||||
gitlab: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,7 +321,7 @@ describe("Form submission", () => {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: null,
|
||||
gitlab: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("HomeScreen", () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
github: null,
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ConfigureGitHubRepositoriesAnchor({
|
||||
href={`https://github.com/apps/${slug}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="px-11 py-9"
|
||||
className="py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
|
||||
@@ -6,14 +6,18 @@ import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
onGitHubHostChange: (value: string) => void;
|
||||
isGitHubTokenSet: boolean;
|
||||
name: string;
|
||||
githubHostSet: string | null | undefined;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({
|
||||
onChange,
|
||||
onGitHubHostChange,
|
||||
isGitHubTokenSet,
|
||||
name,
|
||||
githubHostSet,
|
||||
}: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -37,6 +41,24 @@ export function GitHubTokenInput({
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
onChange={onGitHubHostChange || (() => {})}
|
||||
name="github-host-input"
|
||||
testId="github-host-input"
|
||||
label={t(I18nKey.GITHUB$HOST_LABEL)}
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
placeholder="github.com"
|
||||
defaultValue={githubHostSet || undefined}
|
||||
startContent={
|
||||
githubHostSet && githubHostSet.trim() !== "" ? (
|
||||
<KeyStatusIcon testId="gh-set-host-indicator" isSet />
|
||||
) : (
|
||||
<KeyStatusIcon testId="gh-set-host-indicator" isSet={false} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitHubTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,18 @@ import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitLabTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
onGitLabHostChange: (value: string) => void;
|
||||
isGitLabTokenSet: boolean;
|
||||
name: string;
|
||||
gitlabHostSet: string | null | undefined;
|
||||
}
|
||||
|
||||
export function GitLabTokenInput({
|
||||
onChange,
|
||||
onGitLabHostChange,
|
||||
isGitLabTokenSet,
|
||||
name,
|
||||
gitlabHostSet,
|
||||
}: GitLabTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -37,6 +41,24 @@ export function GitLabTokenInput({
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
onChange={onGitLabHostChange || (() => {})}
|
||||
name="gitlab-host-input"
|
||||
testId="gitlab-host-input"
|
||||
label={t(I18nKey.GITLAB$HOST_LABEL)}
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
placeholder="gitlab.com"
|
||||
defaultValue={gitlabHostSet || undefined}
|
||||
startContent={
|
||||
gitlabHostSet && gitlabHostSet.trim() !== "" ? (
|
||||
<KeyStatusIcon testId="gl-set-host-indicator" isSet />
|
||||
) : (
|
||||
<KeyStatusIcon testId="gl-set-host-indicator" isSet={false} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitLabTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -134,6 +134,7 @@ export enum I18nKey {
|
||||
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
|
||||
LANGUAGE$LABEL = "LANGUAGE$LABEL",
|
||||
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
|
||||
GITHUB$HOST_LABEL = "GITHUB$HOST_LABEL",
|
||||
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
|
||||
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
|
||||
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
|
||||
@@ -483,6 +484,7 @@ export enum I18nKey {
|
||||
MODEL_SELECTOR$VERIFIED = "MODEL_SELECTOR$VERIFIED",
|
||||
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
|
||||
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
|
||||
GITLAB$HOST_LABEL = "GITLAB$HOST_LABEL",
|
||||
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
|
||||
@@ -2145,6 +2145,21 @@
|
||||
"de": "GitHub-Token",
|
||||
"uk": "GitHub Токен"
|
||||
},
|
||||
"GITHUB$HOST_LABEL": {
|
||||
"en": "GitHub Host (optional)",
|
||||
"ja": "GitHubホスト (オプション)",
|
||||
"zh-CN": "GitHub主机 (可选)",
|
||||
"zh-TW": "GitHub主機 (選填)",
|
||||
"ko-KR": "GitHub 호스트 (선택사항)",
|
||||
"no": "GitHub-vert (valgfritt)",
|
||||
"it": "Host GitHub (opzionale)",
|
||||
"pt": "Host do GitHub (opcional)",
|
||||
"es": "Host de GitHub (opcional)",
|
||||
"ar": "مضيف GitHub (اختياري)",
|
||||
"fr": "Hôte GitHub (optionnel)",
|
||||
"tr": "GitHub Sunucusu (isteğe bağlı)",
|
||||
"de": "GitHub-Host (optional)"
|
||||
},
|
||||
"GITHUB$TOKEN_OPTIONAL": {
|
||||
"en": "GitHub Token (Optional)",
|
||||
"ja": "GitHubトークン(任意)",
|
||||
@@ -7432,6 +7447,21 @@
|
||||
"de": "GitLab-Token",
|
||||
"uk": "GitLab токен"
|
||||
},
|
||||
"GITLAB$HOST_LABEL": {
|
||||
"en": "GitLab Host (optional)",
|
||||
"ja": "GitLabホスト (オプション)",
|
||||
"zh-CN": "GitLab主机 (可选)",
|
||||
"zh-TW": "GitLab主機 (選填)",
|
||||
"ko-KR": "GitLab 호스트 (선택사항)",
|
||||
"no": "GitLab-vert (valgfritt)",
|
||||
"it": "Host GitLab (opzionale)",
|
||||
"pt": "Host do GitLab (opcional)",
|
||||
"es": "Host de GitLab (opcional)",
|
||||
"ar": "مضيف GitLab (اختياري)",
|
||||
"fr": "Hôte GitLab (optionnel)",
|
||||
"tr": "GitLab Sunucusu (isteğe bağlı)",
|
||||
"de": "GitLab-Host (optional)"
|
||||
},
|
||||
"GITLAB$GET_TOKEN": {
|
||||
"en": "Generate a token on",
|
||||
"ja": "トークンを生成する",
|
||||
|
||||
@@ -23,8 +23,9 @@ function GitSettingsScreen() {
|
||||
const { mutate: saveGitProviders, isPending } = useAddGitProviders();
|
||||
const { mutate: disconnectGitTokens } = useLogout();
|
||||
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const { providers } = useUserProviders();
|
||||
const { isLoading } = useSettings();
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
|
||||
@@ -32,6 +33,14 @@ function GitSettingsScreen() {
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const [githubHostInputHasValue, setGithubHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
|
||||
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = providers.includes("github");
|
||||
const isGitLabTokenSet = providers.includes("gitlab");
|
||||
@@ -47,12 +56,14 @@ function GitSettingsScreen() {
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
const githubHost = formData.get("github-host-input")?.toString() || "";
|
||||
const gitlabHost = formData.get("gitlab-host-input")?.toString() || "";
|
||||
|
||||
saveGitProviders(
|
||||
{
|
||||
providers: {
|
||||
github: { token: githubToken },
|
||||
gitlab: { token: gitlabToken },
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -66,12 +77,18 @@ function GitSettingsScreen() {
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
setGithubHostInputHasValue(false);
|
||||
setGitlabHostInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue;
|
||||
const formIsClean =
|
||||
!githubTokenInputHasValue &&
|
||||
!gitlabTokenInputHasValue &&
|
||||
!githubHostInputHasValue &&
|
||||
!gitlabHostInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
@@ -80,55 +97,68 @@ function GitSettingsScreen() {
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
isGitHubTokenSet={isGitHubTokenSet}
|
||||
onChange={(value) => {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
onGitHubHostChange={(value) => {
|
||||
setGitlabHostInputHasValue(!!value);
|
||||
}}
|
||||
githubHostSet={existingGithubHost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<GitLabTokenInput
|
||||
name="gitlab-token-input"
|
||||
isGitLabTokenSet={isGitLabTokenSet}
|
||||
onChange={(value) => {
|
||||
setGitlabTokenInputHasValue(!!value);
|
||||
}}
|
||||
onGitLabHostChange={(value) => {
|
||||
setGitlabHostInputHasValue(!!value);
|
||||
}}
|
||||
gitlabHostSet={existingGitlabHost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
|
||||
)}
|
||||
|
||||
{!isSaas && !isLoading && (
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
isGitHubTokenSet={isGitHubTokenSet}
|
||||
onChange={(value) => {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<GitLabTokenInput
|
||||
name="gitlab-token-input"
|
||||
isGitLabTokenSet={isGitLabTokenSet}
|
||||
onChange={(value) => {
|
||||
setGitlabTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<>
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
export type ProviderToken = {
|
||||
token: string;
|
||||
host: string | null;
|
||||
};
|
||||
|
||||
export type MCPSSEServer = {
|
||||
|
||||
@@ -47,7 +47,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
if token:
|
||||
self.token = token
|
||||
|
||||
if base_domain:
|
||||
if base_domain and base_domain != 'github.com':
|
||||
self.BASE_URL = f'https://{base_domain}/api/v3'
|
||||
|
||||
self.external_auth_id = external_auth_id
|
||||
|
||||
@@ -31,6 +31,7 @@ from openhands.server.types import AppMode
|
||||
class ProviderToken(BaseModel):
|
||||
token: SecretStr | None = Field(default=None)
|
||||
user_id: str | None = Field(default=None)
|
||||
host: str | None = Field(default=None)
|
||||
|
||||
model_config = {
|
||||
'frozen': True, # Makes the entire model immutable
|
||||
@@ -40,7 +41,7 @@ class ProviderToken(BaseModel):
|
||||
@classmethod
|
||||
def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken:
|
||||
"""Factory method to create a ProviderToken from various input types"""
|
||||
if isinstance(token_value, ProviderToken):
|
||||
if isinstance(token_value, cls):
|
||||
return token_value
|
||||
elif isinstance(token_value, dict):
|
||||
token_str = token_value.get('token', '')
|
||||
@@ -49,10 +50,11 @@ class ProviderToken(BaseModel):
|
||||
if token_str is None:
|
||||
token_str = ''
|
||||
user_id = token_value.get('user_id')
|
||||
return cls(token=SecretStr(token_str), user_id=user_id)
|
||||
host = token_value.get('host')
|
||||
return cls(token=SecretStr(token_str), user_id=user_id, host=host)
|
||||
|
||||
else:
|
||||
raise ValueError('Unsupport Provider token type')
|
||||
raise ValueError('Unsupported Provider token type')
|
||||
|
||||
|
||||
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
|
||||
|
||||
@@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.settings import (
|
||||
GETCustomSecrets,
|
||||
@@ -9,6 +11,7 @@ from openhands.server.settings import (
|
||||
POSTProviderModel,
|
||||
)
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_secrets,
|
||||
)
|
||||
@@ -51,24 +54,44 @@ async def invalidate_legacy_secrets_store(
|
||||
return None
|
||||
|
||||
|
||||
async def check_provider_tokens(provider_info: POSTProviderModel) -> str:
|
||||
if provider_info.provider_tokens:
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in provider_info.provider_tokens.items():
|
||||
if token_value.token:
|
||||
confirmed_token_type = await validate_provider_token(token_value.token)
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
return f'Invalid token. Please make sure it is a valid {token_type.value} token.'
|
||||
|
||||
def process_token_validation_result(
|
||||
confirmed_token_type: ProviderType | None,
|
||||
token_type: ProviderType):
|
||||
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
return f'Invalid token. Please make sure it is a valid {token_type.value} token.'
|
||||
|
||||
return ''
|
||||
|
||||
async def check_provider_tokens(
|
||||
incoming_provider_tokens: POSTProviderModel,
|
||||
existing_provider_tokens: PROVIDER_TOKEN_TYPE) -> str:
|
||||
|
||||
msg = ''
|
||||
if incoming_provider_tokens.provider_tokens:
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
|
||||
if token_value.token:
|
||||
confirmed_token_type = await validate_provider_token(token_value.token, token_value.host) # FE always sends latest host
|
||||
msg = process_token_validation_result(confirmed_token_type, token_type)
|
||||
|
||||
existing_token = existing_provider_tokens.get(token_type, None)
|
||||
if existing_token and (existing_token.host != token_value.host) and existing_token.token:
|
||||
confirmed_token_type = await validate_provider_token(existing_token.token, token_value.host) # Host has changed, check it against existing token
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
msg = process_token_validation_result(confirmed_token_type, token_type)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@app.post('/add-git-providers')
|
||||
async def store_provider_tokens(
|
||||
provider_info: POSTProviderModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens)
|
||||
) -> JSONResponse:
|
||||
provider_err_msg = await check_provider_tokens(provider_info)
|
||||
|
||||
provider_err_msg = await check_provider_tokens(provider_info, provider_tokens)
|
||||
if provider_err_msg:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -90,8 +113,7 @@ async def store_provider_tokens(
|
||||
if existing_token and existing_token.token:
|
||||
provider_info.provider_tokens[provider] = existing_token
|
||||
|
||||
else: # nothing passed in means keep current settings
|
||||
provider_info.provider_tokens = dict(user_secrets.provider_tokens)
|
||||
provider_info.provider_tokens[provider] = provider_info.provider_tokens[provider].model_copy(update={'host': token_value.host})
|
||||
|
||||
updated_secrets = user_secrets.model_copy(
|
||||
update={'provider_tokens': provider_info.provider_tokens}
|
||||
|
||||
@@ -11,6 +11,7 @@ from openhands.server.settings import (
|
||||
GETSettingsModel,
|
||||
)
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
@@ -19,6 +20,7 @@ from openhands.server.user_auth import (
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.server.shared import server_config
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
@@ -38,10 +40,13 @@ async def load_settings(
|
||||
content={'error': 'Settings not found'},
|
||||
)
|
||||
|
||||
|
||||
# On initial load, user secrets may not be populated with values migrated from settings store
|
||||
user_secrets = await invalidate_legacy_secrets_store(
|
||||
settings, settings_store, secrets_store
|
||||
)
|
||||
|
||||
|
||||
# If invalidation is successful, then the returned user secrets holds the most recent values
|
||||
git_providers = (
|
||||
user_secrets.provider_tokens if user_secrets else provider_tokens
|
||||
@@ -51,7 +56,8 @@ async def load_settings(
|
||||
if git_providers:
|
||||
for provider_type, provider_token in git_providers.items():
|
||||
if provider_token.token or provider_token.user_id:
|
||||
provider_tokens_set[provider_type] = None
|
||||
provider_tokens_set[provider_type] = provider_token.host
|
||||
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude='secrets_store'),
|
||||
|
||||
@@ -38,6 +38,8 @@ class GETSettingsModel(Settings):
|
||||
)
|
||||
llm_api_key_set: bool
|
||||
|
||||
model_config = {'use_enum_values': True}
|
||||
|
||||
|
||||
class GETCustomSecrets(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -45,7 +45,7 @@ class UserSecrets(BaseModel):
|
||||
expose_secrets = info.context and info.context.get('expose_secrets', False)
|
||||
|
||||
for token_type, provider_token in provider_tokens.items():
|
||||
if not provider_token or not provider_token.token:
|
||||
if not provider_token:
|
||||
continue
|
||||
|
||||
token_type_str = (
|
||||
@@ -53,11 +53,16 @@ class UserSecrets(BaseModel):
|
||||
if isinstance(token_type, ProviderType)
|
||||
else str(token_type)
|
||||
)
|
||||
|
||||
token = None
|
||||
if provider_token.token:
|
||||
token = provider_token.token.get_secret_value() if expose_secrets else pydantic_encoder(provider_token.token)
|
||||
|
||||
tokens[token_type_str] = {
|
||||
'token': provider_token.token.get_secret_value()
|
||||
if expose_secrets
|
||||
else pydantic_encoder(provider_token.token),
|
||||
'token': token,
|
||||
'host': provider_token.host,
|
||||
'user_id': provider_token.user_id,
|
||||
|
||||
}
|
||||
|
||||
return tokens
|
||||
|
||||
@@ -283,3 +283,152 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert ProviderType.GITHUB in stored_settings.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_git_providers_with_host(test_client, file_secrets_store):
|
||||
"""Test adding git providers with host parameter."""
|
||||
# Create initial user secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
user_secrets = UserSecrets(provider_tokens=provider_tokens)
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Add a GitHub provider with a host
|
||||
add_provider_data = {
|
||||
'provider_tokens': {
|
||||
'github': {'token': 'new-github-token', 'host': 'github.enterprise.com'}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_provider_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the new provider token and host
|
||||
stored_secrets = await file_secrets_store.load()
|
||||
assert ProviderType.GITHUB in stored_secrets.provider_tokens
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||
== 'new-github-token'
|
||||
)
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].host
|
||||
== 'github.enterprise.com'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_git_providers_update_host_only(test_client, file_secrets_store):
|
||||
"""Test updating only the host for an existing provider token."""
|
||||
# Create initial user secrets with a token
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(
|
||||
token=SecretStr('github-token'), host='github.com'
|
||||
)
|
||||
}
|
||||
user_secrets = UserSecrets(provider_tokens=provider_tokens)
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Update only the host
|
||||
update_host_data = {
|
||||
'provider_tokens': {
|
||||
'github': {
|
||||
'token': '', # Empty token means keep existing token
|
||||
'host': 'github.enterprise.com',
|
||||
}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=update_host_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the host was updated but the token remains the same
|
||||
stored_secrets = await file_secrets_store.load()
|
||||
assert ProviderType.GITHUB in stored_secrets.provider_tokens
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||
== 'github-token'
|
||||
)
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].host
|
||||
== 'github.enterprise.com'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_git_providers_invalid_token_with_host(
|
||||
test_client, file_secrets_store
|
||||
):
|
||||
"""Test adding an invalid token with a host."""
|
||||
# Create initial user secrets
|
||||
user_secrets = UserSecrets()
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Mock validate_provider_token to return None (invalid token)
|
||||
with patch(
|
||||
'openhands.integrations.utils.validate_provider_token',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
# Try to add an invalid GitHub provider with a host
|
||||
add_provider_data = {
|
||||
'provider_tokens': {
|
||||
'github': {'token': 'invalid-token', 'host': 'github.enterprise.com'}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_provider_data)
|
||||
assert response.status_code == 401
|
||||
assert 'Invalid token' in response.json()['error']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_store):
|
||||
"""Test adding multiple git providers with different hosts."""
|
||||
# Create initial user secrets
|
||||
user_secrets = UserSecrets()
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Add multiple providers with hosts
|
||||
add_providers_data = {
|
||||
'provider_tokens': {
|
||||
'github': {'token': 'github-token', 'host': 'github.enterprise.com'},
|
||||
'gitlab': {'token': 'gitlab-token', 'host': 'gitlab.enterprise.com'},
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_providers_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that both providers were stored with their respective hosts
|
||||
stored_secrets = await file_secrets_store.load()
|
||||
assert ProviderType.GITHUB in stored_secrets.provider_tokens
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||
== 'github-token'
|
||||
)
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITHUB].host
|
||||
== 'github.enterprise.com'
|
||||
)
|
||||
|
||||
assert ProviderType.GITLAB in stored_secrets.provider_tokens
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||
== 'gitlab-token'
|
||||
)
|
||||
assert (
|
||||
stored_secrets.provider_tokens[ProviderType.GITLAB].host
|
||||
== 'gitlab.enterprise.com'
|
||||
)
|
||||
|
||||
@@ -60,13 +60,16 @@ async def test_check_provider_tokens_valid():
|
||||
provider_token = ProviderToken(token=SecretStr('valid-token'))
|
||||
providers = POSTProviderModel(provider_tokens={ProviderType.GITHUB: provider_token})
|
||||
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
# Mock the validate_provider_token function to return GITHUB for valid tokens
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token'
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = ProviderType.GITHUB
|
||||
|
||||
result = await check_provider_tokens(providers)
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string for valid token
|
||||
assert result == ''
|
||||
@@ -79,13 +82,16 @@ async def test_check_provider_tokens_invalid():
|
||||
provider_token = ProviderToken(token=SecretStr('invalid-token'))
|
||||
providers = POSTProviderModel(provider_tokens={ProviderType.GITHUB: provider_token})
|
||||
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
# Mock the validate_provider_token function to return None for invalid tokens
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token'
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = None
|
||||
|
||||
result = await check_provider_tokens(providers)
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return error message for invalid token
|
||||
assert 'Invalid token' in result
|
||||
@@ -98,7 +104,11 @@ async def test_check_provider_tokens_wrong_type():
|
||||
# We can't test with an unsupported provider type directly since the model enforces valid types
|
||||
# Instead, we'll test with an empty provider_tokens dictionary
|
||||
providers = POSTProviderModel(provider_tokens={})
|
||||
result = await check_provider_tokens(providers)
|
||||
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string for no providers
|
||||
assert result == ''
|
||||
@@ -109,7 +119,10 @@ async def test_check_provider_tokens_no_tokens():
|
||||
"""Test check_provider_tokens with no tokens."""
|
||||
providers = POSTProviderModel(provider_tokens={})
|
||||
|
||||
result = await check_provider_tokens(providers)
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string when no tokens provided
|
||||
assert result == ''
|
||||
|
||||
Reference in New Issue
Block a user