feat: secrets manager settings (#8068)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
This commit is contained in:
sp.wack 2025-05-15 19:30:10 +04:00 committed by GitHub
parent 7a4ea23b9d
commit 04d585513c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1571 additions and 105 deletions

View File

@ -0,0 +1,565 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
{
name: "My_Secret_1",
description: "My first secret",
},
{
name: "My_Secret_2",
description: "My second secret",
},
];
const RouterStub = createRoutesStub([
{
Component: Outlet,
path: "/settings",
children: [
{
Component: SecretsSettingsScreen,
path: "/settings/secrets",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSecretsSettings = () =>
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
beforeEach(() => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
});
describe("Content", () => {
it("should render the secrets settings screen", () => {
renderSecretsSettings();
screen.getByTestId("secrets-settings-screen");
});
it("should NOT render a button to connect with git if they havent already in oss", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getConfigSpy).toHaveBeenCalled();
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
});
it("should render a button to connect with git if they havent already in saas", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// @ts-expect-error - only return the config we need
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
await userEvent.click(button);
screen.getByTestId("git-settings-screen");
});
it("should render a message if there are no existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
await screen.findByTestId("no-secrets-message");
});
it("should render existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
const secrets = await screen.findAllByTestId("secret-item");
expect(secrets).toHaveLength(2);
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
});
describe("Secret actions", () => {
it("should create a new secret", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
createSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
const secrets = screen.queryAllByTestId("secret-item");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(secretForm).toBeInTheDocument();
expect(secrets).toHaveLength(0);
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const descriptionInput =
within(secretForm).getByTestId("description-input");
const submitButton = within(secretForm).getByTestId("submit-button");
vi.clearAllMocks(); // reset mocks to check for upcoming calls
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.type(descriptionInput, "My custom secret description");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
"My custom secret description",
);
// hide form & render items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(getSecretsSpy).toHaveBeenCalled();
});
it("should edit a secret", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const descriptionInput = within(editForm).getByTestId("description-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
expect(nameInput).toHaveValue("My_Secret_1");
expect(descriptionInput).toHaveValue("My first secret");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.clear(descriptionInput);
await userEvent.type(descriptionInput, "My edited secret description");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My edited secret description",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).toHaveTextContent(/my_edited_secret/i);
});
it("should be able to cancel the create or edit form", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// cancel button
const cancelButton = within(secretForm).getByTestId("cancel-button");
await userEvent.click(cancelButton);
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-secret-button")).toBeInTheDocument();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// cancel button
const cancelEditButton = within(editForm).getByTestId("cancel-button");
await userEvent.click(cancelEditButton);
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should undo the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
updateSecretSpy.mockRejectedValue(new Error("Failed to update secret"));
renderSecretsSettings();
// render edit button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const firstSecret = within(secrets[0]);
const editButton = firstSecret.getByTestId("edit-secret-button");
await userEvent.click(editButton);
// render edit form
const editForm = screen.getByTestId("edit-secret-form");
expect(editForm).toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// enter details
const nameInput = within(editForm).getByTestId("name-input");
const submitButton = within(editForm).getByTestId("submit-button");
// should not show value input
const valueInput = within(editForm).queryByTestId("value-input");
expect(valueInput).not.toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Edited_Secret");
await userEvent.click(submitButton);
// make POST request
expect(updateSecretSpy).toHaveBeenCalledWith(
"My_Secret_1",
"My_Edited_Secret",
"My first secret",
);
// hide form
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
// no optimistic update
const updatedSecrets = await screen.findAllByTestId("secret-item");
expect(updatedSecrets).toHaveLength(2);
expect(updatedSecrets[0]).not.toHaveTextContent(/my edited secret/i);
});
it("should remove the secret from the list after deletion", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(1);
expect(screen.queryByText("My_Secret_2")).not.toBeInTheDocument();
});
it("should be able to cancel the delete confirmation modal", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockResolvedValue(true);
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const cancelButton = within(confirmationModal).getByTestId("cancel-button");
await userEvent.click(cancelButton);
// no DELETE request
expect(deleteSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
});
it("should revert the optimistic update if the request fails", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
deleteSecretSpy.mockRejectedValue(new Error("Failed to delete secret"));
renderSecretsSettings();
// render delete button within a secret list item
const secrets = await screen.findAllByTestId("secret-item");
const secondSecret = within(secrets[1]);
const deleteButton = secondSecret.getByTestId("delete-secret-button");
await userEvent.click(deleteButton);
// confirmation modal
const confirmationModal = screen.getByTestId("confirmation-modal");
const confirmButton =
within(confirmationModal).getByTestId("confirm-button");
await userEvent.click(confirmButton);
// make DELETE request
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
// optimistic update
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
});
it("should hide the no items message when in form view", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
it("should not allow spaces in secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My Custom Secret With Spaces");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "MyCustomSecret");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"MyCustomSecret",
"my-custom-secret-value",
undefined,
);
});
it("should not allow existing secret names", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE.slice(0, 1));
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, "My_Secret_1");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
expect(createSecretSpy).toHaveBeenCalledWith(
"My_Custom_Secret",
"my-custom-secret-value",
undefined,
);
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).not.toBeInTheDocument();
});
it("should not submit whitespace secret names or values", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, " ");
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "My_Custom_Secret");
await userEvent.clear(valueInput);
await userEvent.type(valueInput, " ");
await userEvent.click(submitButton);
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).toBeInTheDocument();
});
it("should not reset ipout values on an invalid submit", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
await userEvent.click(button);
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
// enter details
const nameInput = within(secretForm).getByTestId("name-input");
const valueInput = within(secretForm).getByTestId("value-input");
const submitButton = within(secretForm).getByTestId("submit-button");
await userEvent.type(nameInput, MOCK_GET_SECRETS_RESPONSE[0].name);
await userEvent.type(valueInput, "my-custom-secret-value");
await userEvent.click(submitButton);
// make POST request
expect(createSecretSpy).not.toHaveBeenCalled();
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
expect(valueInput).toHaveValue("my-custom-secret-value");
});
});

View File

@ -79,7 +79,7 @@ describe("Settings Screen", () => {
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "git", "application"];
const sectionsToInclude = ["llm", "git", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
@ -110,7 +110,13 @@ describe("Settings Screen", () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
const sectionsToInclude = ["git", "application", "credits", "api keys"];
const sectionsToInclude = [
"git",
"application",
"credits",
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
renderSettingsScreen();

View File

@ -111,6 +111,8 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
];
function isExcludedTechnicalString(str) {

View File

@ -1,8 +1,43 @@
import { Provider, ProviderToken } from "#/types/settings";
import { openHands } from "./open-hands-axios";
import { POSTProviderTokens } from "./secrets-service.types";
import {
CustomSecret,
GetSecretsResponse,
POSTProviderTokens,
} from "./secrets-service.types";
import { Provider, ProviderToken } from "#/types/settings";
export class SecretsService {
static async getSecrets() {
const { data } = await openHands.get<GetSecretsResponse>("/api/secrets");
return data.custom_secrets;
}
static async createSecret(name: string, value: string, description?: string) {
const secret: CustomSecret = {
name,
value,
description,
};
const { status } = await openHands.post("/api/secrets", secret);
return status === 201;
}
static async updateSecret(id: string, name: string, description?: string) {
const secret: Omit<CustomSecret, "value"> = {
name,
description,
};
const { status } = await openHands.put(`/api/secrets/${id}`, secret);
return status === 200;
}
static async deleteSecret(id: string) {
const { status } = await openHands.delete<boolean>(`/api/secrets/${id}`);
return status === 200;
}
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
const tokens: POSTProviderTokens = {
provider_tokens: providers,

View File

@ -1,5 +1,15 @@
import { Provider, ProviderToken } from "#/types/settings";
export type CustomSecret = {
name: string;
value: string;
description?: string;
};
export interface GetSecretsResponse {
custom_secrets: Omit<CustomSecret, "value">[];
}
export interface POSTProviderTokens {
provider_tokens: Record<Provider, ProviderToken>;
}

View File

@ -0,0 +1,202 @@
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
import { SettingsInput } from "../settings-input";
import { cn } from "#/utils/utils";
import { BrandButton } from "../brand-button";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { OptionalTag } from "../optional-tag";
interface SecretFormProps {
mode: "add" | "edit";
selectedSecret: string | null;
onCancel: () => void;
}
export function SecretForm({
mode,
selectedSecret,
onCancel,
}: SecretFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: secrets } = useGetSecrets();
const { mutate: createSecret } = useCreateSecret();
const { mutate: updateSecret } = useUpdateSecret();
const [error, setError] = React.useState<string | null>(null);
const secretDescription =
(mode === "edit" &&
selectedSecret &&
secrets
?.find((secret) => secret.name === selectedSecret)
?.description?.trim()) ||
"";
const handleCreateSecret = (
name: string,
value: string,
description?: string,
) => {
createSecret(
{ name, value, description },
{
onSettled: onCancel,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
},
},
);
};
const updateSecretOptimistically = (
oldName: string,
name: string,
description?: string,
) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.map((secret) => {
if (secret.name === oldName) {
return {
...secret,
name,
description,
};
}
return secret;
});
},
);
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
};
const handleEditSecret = (
secretToEdit: string,
name: string,
description?: string,
) => {
updateSecretOptimistically(secretToEdit, name, description);
updateSecret(
{ secretToEdit, name, description },
{
onSettled: onCancel,
onError: revertOptimisticUpdate,
},
);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get("secret-name")?.toString();
const value = formData.get("secret-value")?.toString().trim();
const description = formData.get("secret-description")?.toString();
if (name) {
setError(null);
const isNameAlreadyUsed = secrets?.some(
(secret) => secret.name === name && secret.name !== selectedSecret,
);
if (isNameAlreadyUsed) {
setError("Secret already exists");
return;
}
if (mode === "add") {
if (!value) {
setError(t("SECRETS$SECRET_VALUE_REQUIRED"));
return;
}
handleCreateSecret(name, value, description || undefined);
} else if (mode === "edit" && selectedSecret) {
handleEditSecret(selectedSecret, name, description || undefined);
}
}
};
const formTestId = mode === "add" ? "add-secret-form" : "edit-secret-form";
return (
<form
data-testid={formTestId}
onSubmit={handleSubmit}
className="flex flex-col items-start gap-6"
>
<SettingsInput
testId="name-input"
name="secret-name"
type="text"
label="Name"
className="w-[350px]"
required
defaultValue={mode === "edit" && selectedSecret ? selectedSecret : ""}
placeholder="e.g. OpenAI_API_Key"
pattern="^\S*$"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
{mode === "add" && (
<label className="flex flex-col gap-2.5 w-fit">
<span className="text-sm">Value</span>
<textarea
data-testid="value-input"
name="secret-value"
required
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
rows={8}
/>
</label>
)}
<label className="flex flex-col gap-2.5 w-fit">
<div className="flex items-center gap-2">
<span className="text-sm">Description</span>
<OptionalTag />
</div>
<input
data-testid="description-input"
name="secret-description"
defaultValue={secretDescription}
className={cn(
"resize-none w-[680px]",
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
<div className="flex items-center gap-4">
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
onClick={onCancel}
>
Cancel
</BrandButton>
<BrandButton testId="submit-button" type="submit" variant="primary">
{mode === "add" && t("SECRETS$ADD_SECRET")}
{mode === "edit" && t("SECRETS$EDIT_SECRET")}
</BrandButton>
</div>
</form>
);
}

View File

@ -0,0 +1,63 @@
import { FaPencil, FaTrash } from "react-icons/fa6";
export function SecretListItemSkeleton() {
return (
<div className="border-t border-[#717888] last-of-type:border-b max-w-[830px] pr-2.5 py-[13px] flex items-center justify-between">
<div className="flex items-center justify-between w-1/3">
<span className="skeleton h-4 w-1/2" />
<span className="skeleton h-4 w-1/4" />
</div>
<div className="flex items-center gap-8">
<span className="skeleton h-4 w-4" />
<span className="skeleton h-4 w-4" />
</div>
</div>
);
}
interface SecretListItemProps {
title: string;
description?: string;
onEdit: () => void;
onDelete: () => void;
}
export function SecretListItem({
title,
description,
onEdit,
onDelete,
}: SecretListItemProps) {
return (
<tr
data-testid="secret-item"
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
>
<td className="w-1/4 text-sm text-content-2">{title}</td>
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
{description || "-"}
</td>
<td className="w-1/4 flex items-center justify-end gap-4">
<button
data-testid="edit-secret-button"
type="button"
onClick={onEdit}
aria-label={`Edit ${title}`}
>
<FaPencil size={16} />
</button>
<button
data-testid="delete-secret-button"
type="button"
onClick={onDelete}
aria-label={`Delete ${title}`}
>
<FaTrash size={16} />
</button>
</td>
</tr>
);
}

View File

@ -14,9 +14,11 @@ interface SettingsInputProps {
startContent?: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
required?: boolean;
min?: number;
max?: number;
step?: number;
pattern?: string;
}
export function SettingsInput({
@ -32,9 +34,11 @@ export function SettingsInput({
startContent,
className,
onChange,
required,
min,
max,
step,
pattern,
}: SettingsInputProps) {
return (
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
@ -55,6 +59,8 @@ export function SettingsInput({
min={min}
max={max}
step={step}
required={required}
pattern={pattern}
className={cn(
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",

View File

@ -0,0 +1,45 @@
import { BrandButton } from "#/components/features/settings/brand-button";
import { ModalBackdrop } from "./modal-backdrop";
interface ConfirmationModalProps {
text: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmationModal({
text,
onConfirm,
onCancel,
}: ConfirmationModalProps) {
return (
<ModalBackdrop onClose={onCancel}>
<div
data-testid="confirmation-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
>
<p>{text}</p>
<div className="w-full flex gap-2">
<BrandButton
testId="cancel-button"
type="button"
onClick={onCancel}
variant="secondary"
className="grow"
>
Cancel
</BrandButton>
<BrandButton
testId="confirm-button"
type="button"
onClick={onConfirm}
variant="primary"
className="grow"
>
Confirm
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}

View File

@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useCreateSecret = () =>
useMutation({
mutationFn: ({
name,
value,
description,
}: {
name: string;
value: string;
description?: string;
}) => SecretsService.createSecret(name, value, description),
});

View File

@ -0,0 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useDeleteSecret = () =>
useMutation({
mutationFn: (id: string) => SecretsService.deleteSecret(id),
});

View File

@ -15,6 +15,7 @@ export const useLogout = () => {
queryClient.removeQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["settings"] });
queryClient.removeQueries({ queryKey: ["user"] });
queryClient.removeQueries({ queryKey: ["secrets"] });
posthog.reset();
await navigate("/");

View File

@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
export const useUpdateSecret = () =>
useMutation({
mutationFn: ({
secretToEdit,
name,
description,
}: {
secretToEdit: string;
name: string;
description?: string;
}) => SecretsService.updateSecret(secretToEdit, name, description),
});

View File

@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useUserProviders } from "../use-user-providers";
import { useConfig } from "./use-config";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { providers } = useUserProviders();
const isOss = config?.APP_MODE === "oss";
return useQuery({
queryKey: ["secrets"],
queryFn: SecretsService.getSecrets,
enabled: isOss || providers.length > 0,
});
};

View File

@ -1,5 +1,11 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
SECRETS$NO_SECRETS_FOUND = "SECRETS$NO_SECRETS_FOUND",
SECRETS$ADD_NEW_SECRET = "SECRETS$ADD_NEW_SECRET",
SECRETS$CONFIRM_DELETE_KEY = "SECRETS$CONFIRM_DELETE_KEY",
SETTINGS$MCP_TITLE = "SETTINGS$MCP_TITLE",
SETTINGS$MCP_DESCRIPTION = "SETTINGS$MCP_DESCRIPTION",
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
@ -60,6 +66,7 @@ export enum I18nKey {
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",

View File

@ -1,4 +1,94 @@
{
"SECRETS$SECRET_VALUE_REQUIRED": {
"en": "Secret value is required",
"ja": "シークレット値は必須です",
"zh-CN": "密钥值是必需的",
"zh-TW": "密鑰值是必需的",
"ko-KR": "비밀 값이 필요합니다",
"no": "Hemmelig verdi er påkrevd",
"it": "Il valore del segreto è obbligatorio",
"pt": "O valor do segredo é obrigatório",
"es": "El valor del secreto es obligatorio",
"ar": "قيمة السر مطلوبة",
"fr": "La valeur du secret est requise",
"tr": "Gizli değer gereklidir",
"de": "Geheimer Wert ist erforderlich"
},
"SECRETS$ADD_SECRET": {
"en": "Add secret",
"ja": "シークレットを追加",
"zh-CN": "添加密钥",
"zh-TW": "添加密鑰",
"ko-KR": "비밀 추가",
"no": "Legg til hemmelighet",
"it": "Aggiungi segreto",
"pt": "Adicionar segredo",
"es": "Añadir secreto",
"ar": "إضافة سر",
"fr": "Ajouter un secret",
"tr": "Gizli ekle",
"de": "Geheimnis hinzufügen"
},
"SECRETS$EDIT_SECRET": {
"en": "Edit secret",
"ja": "シークレットを編集",
"zh-CN": "编辑密钥",
"zh-TW": "編輯密鑰",
"ko-KR": "비밀 편집",
"no": "Rediger hemmelighet",
"it": "Modifica segreto",
"pt": "Editar segredo",
"es": "Editar secreto",
"ar": "تعديل السر",
"fr": "Modifier le secret",
"tr": "Gizliyi düzenle",
"de": "Geheimnis bearbeiten"
},
"SECRETS$NO_SECRETS_FOUND": {
"en": "No secrets found",
"ja": "シークレットが見つかりません",
"zh-CN": "未找到密钥",
"zh-TW": "未找到密鑰",
"ko-KR": "비밀을 찾을 수 없습니다",
"no": "Ingen hemmeligheter funnet",
"it": "Nessun segreto trovato",
"pt": "Nenhum segredo encontrado",
"es": "No se encontraron secretos",
"ar": "لم يتم العثور على أسرار",
"fr": "Aucun secret trouvé",
"tr": "Gizli bulunamadı",
"de": "Keine Geheimnisse gefunden"
},
"SECRETS$ADD_NEW_SECRET": {
"en": "Add a new secret",
"ja": "新しいシークレットを追加",
"zh-CN": "添加新密钥",
"zh-TW": "添加新密鑰",
"ko-KR": "새 비밀 추가",
"no": "Legg til en ny hemmelighet",
"it": "Aggiungi un nuovo segreto",
"pt": "Adicionar um novo segredo",
"es": "Añadir un nuevo secreto",
"ar": "إضافة سر جديد",
"fr": "Ajouter un nouveau secret",
"tr": "Yeni bir gizli ekle",
"de": "Neues Geheimnis hinzufügen"
},
"SECRETS$CONFIRM_DELETE_KEY": {
"en": "Are you sure you want to delete this key?",
"ja": "このキーを削除してもよろしいですか?",
"zh-CN": "您确定要删除此密钥吗?",
"zh-TW": "您確定要刪除此密鑰嗎?",
"ko-KR": "이 키를 삭제하시겠습니까?",
"no": "Er du sikker på at du vil slette denne nøkkelen?",
"it": "Sei sicuro di voler eliminare questa chiave?",
"pt": "Tem certeza de que deseja excluir esta chave?",
"es": "¿Está seguro de que desea eliminar esta clave?",
"ar": "هل أنت متأكد أنك تريد حذف هذا المفتاح؟",
"fr": "Êtes-vous sûr de vouloir supprimer cette clé ?",
"tr": "Bu anahtarı silmek istediğinizden emin misiniz?",
"de": "Sind Sie sicher, dass Sie diesen Schlüssel löschen möchten?"
},
"SETTINGS$MCP_TITLE": {
"en": "Model Context Protocol (MCP)",
"ja": "モデルコンテキストプロトコル (MCP)",
@ -959,6 +1049,21 @@
"de": "Guthaben",
"uk": "Кредити"
},
"SETTINGS$NAV_SECRETS": {
"en": "Secrets",
"ja": "シークレット",
"zh-CN": "机密",
"zh-TW": "機密",
"ko-KR": "비밀",
"no": "Hemmeligheter",
"it": "Segreti",
"pt": "Segredos",
"es": "Secretos",
"ar": "أسرار",
"fr": "Secrets",
"tr": "Sırları",
"de": "Geheimnisse"
},
"SETTINGS$NAV_API_KEYS": {
"en": "API Keys",
"ja": "APIキー",

View File

@ -10,6 +10,7 @@ import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
@ -118,6 +119,7 @@ export const handlers = [
...STRIPE_BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
@ -164,7 +166,7 @@ export const handlers = [
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
STRIPE_PUBLISHABLE_KEY: "",
FEATURE_FLAGS: {
ENABLE_BILLING: mockSaas,
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
},
};
@ -210,8 +212,6 @@ export const handlers = [
HttpResponse.json({ message: "Authenticated" }),
),
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
http.get("/api/conversations", async () => {
const values = Array.from(CONVERSATIONS.values());
const results: ResultSet<Conversation> = {

View File

@ -0,0 +1,72 @@
import { http, HttpResponse } from "msw";
import { CustomSecret, GetSecretsResponse } from "#/api/secrets-service.types";
const DEFAULT_SECRETS: CustomSecret[] = [
{
name: "OpenAI_API_Key",
value: "test-123",
description: "OpenAI API Key",
},
{
name: "Google_Maps_API_Key",
value: "test-123",
description: "Google Maps API Key",
},
];
const secrets = new Map<string, CustomSecret>(
DEFAULT_SECRETS.map((secret) => [secret.name, secret]),
);
export const SECRETS_HANDLERS = [
http.get("/api/secrets", async () => {
const secretsArray = Array.from(secrets.values());
const secretsWithoutValue: Omit<CustomSecret, "value">[] = secretsArray.map(
({ value, ...rest }) => rest,
);
const data: GetSecretsResponse = {
custom_secrets: secretsWithoutValue,
};
return HttpResponse.json(data);
}),
http.post("/api/secrets", async ({ request }) => {
const body = (await request.json()) as CustomSecret;
if (typeof body === "object" && body && body.name) {
secrets.set(body.name, body);
return HttpResponse.json(true);
}
return HttpResponse.json(false, { status: 400 });
}),
http.put("/api/secrets/:id", async ({ params, request }) => {
const { id } = params;
const body = (await request.json()) as Omit<CustomSecret, "value">;
if (typeof id === "string" && typeof body === "object") {
const secret = secrets.get(id);
if (secret && body && body.name) {
const newSecret: CustomSecret = { ...secret, ...body };
secrets.delete(id);
secrets.set(body.name, newSecret);
return HttpResponse.json(true);
}
}
return HttpResponse.json(false, { status: 400 });
}),
http.delete("/api/secrets/:id", async ({ params }) => {
const { id } = params;
if (typeof id === "string") {
secrets.delete(id);
return HttpResponse.json(true);
}
return HttpResponse.json(false, { status: 400 });
}),
];

View File

@ -15,6 +15,7 @@ export default [
route("git", "routes/git-settings.tsx"),
route("app", "routes/app-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("secrets", "routes/secrets-settings.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx", [

View File

@ -0,0 +1,151 @@
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { useDeleteSecret } from "#/hooks/mutation/use-delete-secret";
import { SecretForm } from "#/components/features/settings/secrets-settings/secret-form";
import {
SecretListItem,
SecretListItemSkeleton,
} from "#/components/features/settings/secrets-settings/secret-list-item";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useConfig } from "#/hooks/query/use-config";
function SecretsSettingsScreen() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: config } = useConfig();
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
const { mutate: deleteSecret } = useDeleteSecret();
const { providers } = useUserProviders();
const isSaas = config?.APP_MODE === "saas";
const hasProviderSet = providers.length > 0;
const [view, setView] = React.useState<
"list" | "add-secret-form" | "edit-secret-form"
>("list");
const [selectedSecret, setSelectedSecret] = React.useState<string | null>(
null,
);
const [confirmationModalIsVisible, setConfirmationModalIsVisible] =
React.useState(false);
const deleteSecretOptimistically = (secret: string) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.filter((s) => s.name !== secret);
},
);
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
};
const handleDeleteSecret = (secret: string) => {
deleteSecretOptimistically(secret);
deleteSecret(secret, {
onSettled: () => {
setConfirmationModalIsVisible(false);
},
onError: revertOptimisticUpdate,
});
};
const onConfirmDeleteSecret = () => {
if (selectedSecret) handleDeleteSecret(selectedSecret);
};
const onCancelDeleteSecret = () => {
setConfirmationModalIsVisible(false);
};
const shouldRenderConnectToGitButton = isSaas && !hasProviderSet;
return (
<div
data-testid="secrets-settings-screen"
className="px-11 py-9 flex flex-col gap-5"
>
{isLoadingSecrets && view === "list" && (
<ul>
<SecretListItemSkeleton />
<SecretListItemSkeleton />
<SecretListItemSkeleton />
</ul>
)}
{shouldRenderConnectToGitButton && (
<Link to="/settings/git" data-testid="connect-git-button" type="button">
<BrandButton type="button" variant="secondary">
Connect a Git provider to manage secrets
</BrandButton>
</Link>
)}
{secrets?.length === 0 && view === "list" && (
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
)}
{view === "list" && (
<table className="w-full">
<tbody>
{secrets?.map((secret) => (
<SecretListItem
key={secret.name}
title={secret.name}
description={secret.description}
onEdit={() => {
setView("edit-secret-form");
setSelectedSecret(secret.name);
}}
onDelete={() => {
setConfirmationModalIsVisible(true);
setSelectedSecret(secret.name);
}}
/>
))}
</tbody>
</table>
)}
{!shouldRenderConnectToGitButton && view === "list" && (
<BrandButton
testId="add-secret-button"
type="button"
variant="primary"
onClick={() => setView("add-secret-form")}
isDisabled={isLoadingSecrets}
>
{t("SECRETS$ADD_NEW_SECRET")}
</BrandButton>
)}
{(view === "add-secret-form" || view === "edit-secret-form") && (
<SecretForm
mode={view === "add-secret-form" ? "add" : "edit"}
selectedSecret={selectedSecret}
onCancel={() => setView("list")}
/>
)}
{confirmationModalIsVisible && (
<ConfirmationModal
text={t("SECRETS$CONFIRM_DELETE_KEY")}
onConfirm={onConfirmDeleteSecret}
onCancel={onCancelDeleteSecret}
/>
)}
</div>
);
}
export default SecretsSettingsScreen;

View File

@ -18,6 +18,7 @@ function SettingsScreen() {
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
];
@ -26,6 +27,7 @@ function SettingsScreen() {
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
];
React.useEffect(() => {

View File

@ -57,8 +57,31 @@ class ProviderToken(BaseModel):
raise ValueError('Unsupported Provider token type')
class CustomSecret(BaseModel):
secret: SecretStr = Field(default_factory=lambda: SecretStr(''))
description: str = Field(default='')
model_config = {
'frozen': True, # Makes the entire model immutable
'validate_assignment': True,
}
@classmethod
def from_value(cls, secret_value: CustomSecret | dict[str, str]) -> CustomSecret:
"""Factory method to create a ProviderToken from various input types"""
if isinstance(secret_value, CustomSecret):
return secret_value
elif isinstance(secret_value, dict):
secret = secret_value.get('secret')
description = secret_value.get('description')
return cls(secret=SecretStr(secret), description=description)
else:
raise ValueError('Unsupport Provider token type')
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
CUSTOM_SECRETS_TYPE = MappingProxyType[str, SecretStr]
CUSTOM_SECRETS_TYPE = MappingProxyType[str, CustomSecret]
PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
PROVIDER_TOKEN_TYPE,
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),

View File

@ -2,12 +2,14 @@ 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 CustomSecret
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.integrations.utils import validate_provider_token
from openhands.server.settings import (
CustomSecretModel,
CustomSecretWithoutValueModel,
GETCustomSecrets,
POSTCustomSecrets,
POSTProviderModel,
)
from openhands.server.user_auth import (
@ -188,7 +190,15 @@ async def load_custom_secrets_names(
content={'error': 'User secrets not found'},
)
custom_secrets = list(user_secrets.custom_secrets.keys())
custom_secrets: list[CustomSecretWithoutValueModel] = []
if user_secrets.custom_secrets:
for secret_name, secret_value in user_secrets.custom_secrets.items():
custom_secret = CustomSecretWithoutValueModel(
name=secret_name,
description=secret_value.description,
)
custom_secrets.append(custom_secret)
return GETCustomSecrets(custom_secrets=custom_secrets)
except Exception as e:
@ -201,7 +211,7 @@ async def load_custom_secrets_names(
@app.post('/secrets', response_model=dict[str, str])
async def create_custom_secret(
incoming_secret: POSTCustomSecrets,
incoming_secret: CustomSecretModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
@ -209,14 +219,20 @@ async def create_custom_secret(
if existing_secrets:
custom_secrets = dict(existing_secrets.custom_secrets)
for secret_name, secret_value in incoming_secret.custom_secrets.items():
if secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
secret_name = incoming_secret.name
secret_value = incoming_secret.value
secret_description = incoming_secret.description
custom_secrets[secret_name] = secret_value
if secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
custom_secrets[secret_name] = CustomSecret(
secret=secret_value,
description=secret_description or '',
)
# Create a new UserSecrets that preserves provider tokens
updated_user_secrets = UserSecrets(
@ -227,7 +243,7 @@ async def create_custom_secret(
await secrets_store.store(updated_user_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
status_code=status.HTTP_201_CREATED,
content={'message': 'Secret created successfully'},
)
except Exception as e:
@ -241,7 +257,7 @@ async def create_custom_secret(
@app.put('/secrets/{secret_id}', response_model=dict[str, str])
async def update_custom_secret(
secret_id: str,
incoming_secret: POSTCustomSecrets,
incoming_secret: CustomSecretWithoutValueModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
@ -254,13 +270,23 @@ async def update_custom_secret(
content={'error': f'Secret with ID {secret_id} not found'},
)
secret_name = incoming_secret.name
secret_description = incoming_secret.description
custom_secrets = dict(existing_secrets.custom_secrets)
custom_secrets.pop(secret_id)
existing_secret = custom_secrets.pop(secret_id)
for secret_name, secret_value in incoming_secret.custom_secrets.items():
custom_secrets[secret_name] = secret_value
if secret_name != secret_id and secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
custom_secrets[secret_name] = CustomSecret(
secret=existing_secret.secret,
description=secret_description or '',
)
# Create a new UserSecrets that preserves provider tokens
updated_secrets = UserSecrets(
custom_secrets=custom_secrets,
provider_tokens=existing_secrets.provider_tokens,

View File

@ -6,7 +6,7 @@ from pydantic import (
)
from openhands.core.config.mcp_config import MCPConfig
from openhands.integrations.provider import ProviderToken
from openhands.integrations.provider import CustomSecret, ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.storage.data_models.settings import Settings
@ -25,7 +25,7 @@ class POSTCustomSecrets(BaseModel):
Adding new custom secret
"""
custom_secrets: dict[str, str | SecretStr] = {}
custom_secrets: dict[str, CustomSecret] = {}
class GETSettingsModel(Settings):
@ -41,9 +41,26 @@ class GETSettingsModel(Settings):
model_config = {'use_enum_values': True}
class CustomSecretWithoutValueModel(BaseModel):
"""
Custom secret model without value
"""
name: str
description: str | None = None
class CustomSecretModel(CustomSecretWithoutValueModel):
"""
Custom secret model with value
"""
value: SecretStr
class GETCustomSecrets(BaseModel):
"""
Custom secrets names
"""
custom_secrets: list[str] | None = None
custom_secrets: list[CustomSecretWithoutValueModel] | None = None

View File

@ -3,9 +3,7 @@ from typing import Any
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
SerializationInfo,
field_serializer,
model_validator,
@ -17,6 +15,7 @@ from openhands.integrations.provider import (
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
PROVIDER_TOKEN_TYPE,
PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA,
CustomSecret,
ProviderToken,
)
from openhands.integrations.service_types import ProviderType
@ -31,11 +30,11 @@ class UserSecrets(BaseModel):
default_factory=lambda: MappingProxyType({})
)
model_config = ConfigDict(
frozen=True,
validate_assignment=True,
arbitrary_types_allowed=True,
)
model_config = {
'frozen': True,
'validate_assignment': True,
'arbitrary_types_allowed': True,
}
@field_serializer('provider_tokens')
def provider_tokens_serializer(
@ -78,12 +77,14 @@ class UserSecrets(BaseModel):
expose_secrets = info.context and info.context.get('expose_secrets', False)
if custom_secrets:
for secret_name, secret_key in custom_secrets.items():
secrets[secret_name] = (
secret_key.get_secret_value()
for secret_name, secret_value in custom_secrets.items():
secrets[secret_name] = {
'secret': secret_value.secret.get_secret_value()
if expose_secrets
else pydantic_encoder(secret_key)
)
else pydantic_encoder(secret_value.secret),
'description': secret_value.description,
}
return secrets
@model_validator(mode='before')
@ -125,10 +126,10 @@ class UserSecrets(BaseModel):
if isinstance(secrets, dict):
converted_secrets = {}
for key, value in secrets.items():
if isinstance(value, str):
converted_secrets[key] = SecretStr(value)
elif isinstance(value, SecretStr):
converted_secrets[key] = value
try:
converted_secrets[key] = CustomSecret.from_value(value)
except ValueError:
continue
new_data['custom_secrets'] = MappingProxyType(converted_secrets)
elif isinstance(secrets, MappingProxyType):

View File

@ -5,7 +5,11 @@ from typing import Any
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.provider import (
CustomSecret,
ProviderToken,
ProviderType,
)
from openhands.storage.data_models.user_secrets import UserSecrets
@ -51,8 +55,12 @@ class TestUserSecrets:
"""Test adding only custom secrets to the UserSecrets."""
# Create custom secrets
custom_secrets = {
'API_KEY': 'api-key-123',
'DATABASE_PASSWORD': 'db-pass-456',
'API_KEY': CustomSecret(
secret=SecretStr('api-key-123'), description='API key'
),
'DATABASE_PASSWORD': CustomSecret(
secret=SecretStr('db-pass-456'), description='Database password'
),
}
# Initialize the store with custom secrets
@ -61,9 +69,11 @@ class TestUserSecrets:
# Verify the custom secrets were added correctly
assert isinstance(store.custom_secrets, MappingProxyType)
assert len(store.custom_secrets) == 2
assert store.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
assert (
store.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
store.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
)
assert (
store.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
== 'db-pass-456'
)
@ -79,10 +89,10 @@ class TestUserSecrets:
}
# Create custom secrets as a MappingProxyType
custom_secrets_dict = {'API_KEY': 'api-key-123'}
custom_secrets_proxy = MappingProxyType(
{key: SecretStr(value) for key, value in custom_secrets_dict.items()}
custom_secret = CustomSecret(
secret=SecretStr('api-key-123'), description='API key'
)
custom_secrets_proxy = MappingProxyType({'API_KEY': custom_secret})
# Test with dict for provider_tokens and MappingProxyType for custom_secrets
store1 = UserSecrets(
@ -95,7 +105,9 @@ class TestUserSecrets:
store1.provider_tokens[ProviderType.GITHUB].token.get_secret_value()
== 'github-token-123'
)
assert store1.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
assert (
store1.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
)
# Test with MappingProxyType for provider_tokens and dict for custom_secrets
provider_token = ProviderToken(
@ -103,6 +115,11 @@ class TestUserSecrets:
)
provider_tokens_proxy = MappingProxyType({ProviderType.GITLAB: provider_token})
# Create custom secrets as a dict
custom_secrets_dict = {
'API_KEY': {'secret': 'api-key-123', 'description': 'API key'}
}
store2 = UserSecrets(
provider_tokens=provider_tokens_proxy, custom_secrets=custom_secrets_dict
)
@ -113,7 +130,9 @@ class TestUserSecrets:
store2.provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== 'gitlab-token-456'
)
assert store2.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
assert (
store2.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
)
def test_model_copy_update_fields(self):
"""Test using model_copy to update fields without affecting other fields."""
@ -121,7 +140,11 @@ class TestUserSecrets:
github_token = ProviderToken(
token=SecretStr('github-token-123'), user_id='user1'
)
custom_secret = {'API_KEY': SecretStr('api-key-123')}
custom_secret = {
'API_KEY': CustomSecret(
secret=SecretStr('api-key-123'), description='API key'
)
}
initial_store = UserSecrets(
provider_tokens=MappingProxyType({ProviderType.GITHUB: github_token}),
@ -152,14 +175,19 @@ class TestUserSecrets:
)
assert len(updated_store1.custom_secrets) == 1
assert (
updated_store1.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
updated_store1.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-123'
)
# Update only custom_secrets
updated_custom_secrets = MappingProxyType(
{
'API_KEY': SecretStr('api-key-123'),
'DATABASE_PASSWORD': SecretStr('db-pass-456'),
'API_KEY': CustomSecret(
secret=SecretStr('api-key-123'), description='API key'
),
'DATABASE_PASSWORD': CustomSecret(
secret=SecretStr('db-pass-456'), description='DB password'
),
}
)
@ -175,10 +203,11 @@ class TestUserSecrets:
)
assert len(updated_store2.custom_secrets) == 2
assert (
updated_store2.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
updated_store2.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-123'
)
assert (
updated_store2.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
updated_store2.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
== 'db-pass-456'
)
@ -188,7 +217,11 @@ class TestUserSecrets:
github_token = ProviderToken(
token=SecretStr('github-token-123'), user_id='user1'
)
custom_secrets = {'API_KEY': SecretStr('api-key-123')}
custom_secrets = {
'API_KEY': CustomSecret(
secret=SecretStr('api-key-123'), description='API key'
)
}
store = UserSecrets(
provider_tokens=MappingProxyType({ProviderType.GITHUB: github_token}),
@ -209,7 +242,8 @@ class TestUserSecrets:
assert serialized_provider_tokens['github']['user_id'] == 'user1'
# Verify custom secrets are exposed
assert serialized_custom_secrets['API_KEY'] == 'api-key-123'
assert serialized_custom_secrets['API_KEY']['secret'] == 'api-key-123'
assert serialized_custom_secrets['API_KEY']['description'] == 'API key'
# Test serialization with expose_secrets=False (default)
hidden_provider_tokens = store.provider_tokens_serializer(
@ -225,8 +259,8 @@ class TestUserSecrets:
assert '**' in hidden_provider_tokens['github']['token']
# Verify custom secrets are hidden
assert hidden_custom_secrets['API_KEY'] != 'api-key-123'
assert '**' in hidden_custom_secrets['API_KEY']
assert hidden_custom_secrets['API_KEY']['secret'] != 'api-key-123'
assert '**' in hidden_custom_secrets['API_KEY']['secret']
def test_initializing_provider_tokens_with_mixed_value_types(self):
"""Test initializing provider tokens with both plain strings and SecretStr objects."""
@ -278,27 +312,36 @@ class TestUserSecrets:
"""Test initializing custom secrets with both plain strings and SecretStr objects."""
# Create custom secrets with mixed value types
custom_secrets_dict = {
'API_KEY': 'api-key-123', # Plain string
'DATABASE_PASSWORD': SecretStr('db-pass-456'), # SecretStr
'API_KEY': {
'secret': 'api-key-123',
'description': 'API key',
}, # Dict format
'DATABASE_PASSWORD': CustomSecret(
secret=SecretStr('db-pass-456'), description='DB password'
), # CustomSecret object
}
# Initialize the store
store = UserSecrets(custom_secrets=custom_secrets_dict)
# Verify all secrets are converted to SecretStr
# Verify all secrets are converted to CustomSecret objects
assert isinstance(store.custom_secrets, MappingProxyType)
assert len(store.custom_secrets) == 2
# Check API_KEY (was plain string)
assert isinstance(store.custom_secrets['API_KEY'], SecretStr)
assert store.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
# Check DATABASE_PASSWORD (was SecretStr)
assert isinstance(store.custom_secrets['DATABASE_PASSWORD'], SecretStr)
# Check API_KEY (was dict)
assert isinstance(store.custom_secrets['API_KEY'], CustomSecret)
assert (
store.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
store.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
)
assert store.custom_secrets['API_KEY'].description == 'API key'
# Check DATABASE_PASSWORD (was CustomSecret)
assert isinstance(store.custom_secrets['DATABASE_PASSWORD'], CustomSecret)
assert (
store.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
== 'db-pass-456'
)
assert store.custom_secrets['DATABASE_PASSWORD'].description == 'DB password'
# Mock class for SerializationInfo since it's not directly importable

View File

@ -8,7 +8,11 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.provider import (
CustomSecret,
ProviderToken,
ProviderType,
)
from openhands.server.routes.secrets import app as secrets_app
from openhands.storage import get_file_store
from openhands.storage.data_models.user_secrets import UserSecrets
@ -45,8 +49,8 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
# Create initial settings with custom secrets
custom_secrets = {
'API_KEY': SecretStr('api-key-value'),
'DB_PASSWORD': SecretStr('db-password-value'),
'API_KEY': CustomSecret(secret=SecretStr('api-key-value')),
'DB_PASSWORD': CustomSecret(secret=SecretStr('db-password-value')),
}
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
@ -60,20 +64,24 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
# Make the GET request
response = test_client.get('/api/secrets')
print(response)
assert response.status_code == 200
# Check the response
data = response.json()
assert 'custom_secrets' in data
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
# Extract just the names from the list of custom secrets
secret_names = [secret['name'] for secret in data['custom_secrets']]
assert sorted(secret_names) == ['API_KEY', 'DB_PASSWORD']
# Verify that the original settings were not modified
stored_settings = await file_secrets_store.load()
assert (
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-value'
)
assert (
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
== 'db-password-value'
)
assert ProviderType.GITHUB in stored_settings.provider_tokens
@ -86,7 +94,7 @@ async def test_load_custom_secrets_names_empty(test_client, file_secrets_store):
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
}
user_secrets = UserSecrets(provider_tokens=provider_tokens)
user_secrets = UserSecrets(provider_tokens=provider_tokens, custom_secrets={})
# Store the initial settings
await file_secrets_store.store(user_secrets)
@ -115,9 +123,9 @@ async def test_add_custom_secret(test_client, file_secrets_store):
await file_secrets_store.store(user_secrets)
# Make the POST request to add a custom secret
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
add_secret_data = {'name': 'API_KEY', 'value': 'api-key-value', 'description': None}
response = test_client.post('/api/secrets', json=add_secret_data)
assert response.status_code == 200
assert response.status_code == 201
# Verify that the settings were stored with the new secret
stored_settings = await file_secrets_store.load()
@ -125,16 +133,17 @@ async def test_add_custom_secret(test_client, file_secrets_store):
# Check that the secret was added
assert 'API_KEY' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-value'
)
@pytest.mark.asyncio
async def test_update_existing_custom_secret(test_client, file_secrets_store):
"""Test updating an existing custom secret."""
"""Test updating an existing custom secret's name and description (cannot change value once set)."""
# Create initial settings with a custom secret
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
custom_secrets = {'API_KEY': CustomSecret(secret=SecretStr('old-api-key'))}
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
}
@ -145,8 +154,11 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
# Store the initial settings
await file_secrets_store.store(user_secrets)
# Make the POST request to update the custom secret
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
# Make the PUT request to update the custom secret
update_secret_data = {
'name': 'API_KEY',
'description': None,
}
response = test_client.put('/api/secrets/API_KEY', json=update_secret_data)
assert response.status_code == 200
@ -155,7 +167,10 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
# Check that the secret was updated
assert 'API_KEY' in stored_settings.custom_secrets
assert stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'new-api-key'
assert (
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
== 'old-api-key'
)
# Check that other settings were preserved
assert ProviderType.GITHUB in stored_settings.provider_tokens
@ -166,7 +181,9 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
"""Test adding multiple custom secrets at once."""
# Create initial settings with one custom secret
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
custom_secrets = {
'EXISTING_SECRET': CustomSecret(secret=SecretStr('existing-value'))
}
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
}
@ -177,15 +194,23 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
# Store the initial settings
await file_secrets_store.store(user_secrets)
# Make the POST request to add multiple custom secrets
add_secrets_data = {
'custom_secrets': {
'API_KEY': 'api-key-value',
'DB_PASSWORD': 'db-password-value',
}
# Make the POST request to add first custom secret
add_secret_data1 = {
'name': 'API_KEY',
'value': 'api-key-value',
'description': None,
}
response = test_client.post('/api/secrets', json=add_secrets_data)
assert response.status_code == 200
response1 = test_client.post('/api/secrets', json=add_secret_data1)
assert response1.status_code == 201
# Make the POST request to add second custom secret
add_secret_data2 = {
'name': 'DB_PASSWORD',
'value': 'db-password-value',
'description': None,
}
response = test_client.post('/api/secrets', json=add_secret_data2)
assert response.status_code == 201
# Verify that the settings were stored with the new secrets
stored_settings = await file_secrets_store.load()
@ -193,18 +218,19 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
# Check that the new secrets were added
assert 'API_KEY' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-value'
)
assert 'DB_PASSWORD' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
== 'db-password-value'
)
# Check that existing secrets were preserved
assert 'EXISTING_SECRET' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['EXISTING_SECRET'].get_secret_value()
stored_settings.custom_secrets['EXISTING_SECRET'].secret.get_secret_value()
== 'existing-value'
)
@ -218,8 +244,8 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
# Create initial settings with multiple custom secrets
custom_secrets = {
'API_KEY': SecretStr('api-key-value'),
'DB_PASSWORD': SecretStr('db-password-value'),
'API_KEY': CustomSecret(secret=SecretStr('api-key-value')),
'DB_PASSWORD': CustomSecret(secret=SecretStr('db-password-value')),
}
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
@ -244,7 +270,7 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
# Check that other secrets were preserved
assert 'DB_PASSWORD' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
== 'db-password-value'
)
@ -257,7 +283,9 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
"""Test deleting a custom secret that doesn't exist."""
# Create initial settings with a custom secret
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
custom_secrets = {
'API_KEY': CustomSecret(secret=SecretStr('api-key-value'), description='')
}
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
}
@ -278,7 +306,8 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
# Check that the existing secret was preserved
assert 'API_KEY' in stored_settings.custom_secrets
assert (
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
== 'api-key-value'
)
# Check that other settings were preserved