From dda277456a8a4d5f14be502a89f5ff1192697bb7 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Thu, 17 Jul 2025 18:10:48 -0700 Subject: [PATCH] feat: Require API keys from non langchain users (#448) * feat: Require API keys from non langchain users * cr * cr --- .../src/routes/github/issue-webhook.ts | 8 + apps/open-swe/src/security/auth.ts | 85 +++++++++- apps/open-swe/src/utils/config.ts | 5 +- .../src/utils/github/allowed-users.ts | 66 ++++++++ apps/open-swe/src/utils/load-model.ts | 44 ++++++ apps/web/src/app/api/[..._path]/route.ts | 28 ++++ apps/web/src/components/ui/sonner.tsx | 16 +- apps/web/src/components/v2/terminal-input.tsx | 31 +++- .../src/features/settings-page/api-keys.tsx | 147 +++++++++--------- packages/shared/src/constants.ts | 3 + packages/shared/src/open-swe/types.ts | 11 ++ 11 files changed, 361 insertions(+), 83 deletions(-) create mode 100644 apps/open-swe/src/utils/github/allowed-users.ts diff --git a/apps/open-swe/src/routes/github/issue-webhook.ts b/apps/open-swe/src/routes/github/issue-webhook.ts index e629505b..c1dfc9e2 100644 --- a/apps/open-swe/src/routes/github/issue-webhook.ts +++ b/apps/open-swe/src/routes/github/issue-webhook.ts @@ -20,6 +20,7 @@ import { } from "../../utils/github/label.js"; import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; import { RequestSource } from "../../constants.js"; +import { isAllowedUser } from "../../utils/github/allowed-users.js"; const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook"); @@ -129,6 +130,13 @@ webhooks.on("issues.labeled", async ({ payload }) => { userLogin: payload.sender.login, }; + if (!isAllowedUser(issueData.userLogin)) { + logger.error("User is not a member of allowed orgs", { + username: issueData.userLogin, + }); + return; + } + const langGraphClient = createLangGraphClient({ defaultHeaders: { [GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret( diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts index 279dbbf3..68e1dace 100644 --- a/apps/open-swe/src/security/auth.ts +++ b/apps/open-swe/src/security/auth.ts @@ -5,6 +5,7 @@ import { verifyGithubUserId, } from "@open-swe/shared/github/verify-user"; import { + API_KEY_REQUIRED_MESSAGE, GITHUB_INSTALLATION_NAME, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_TOKEN_COOKIE, @@ -16,6 +17,8 @@ import { verifyGitHubWebhookOrThrow } from "./github.js"; import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js"; import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js"; import { getGitHubPatFromRequest } from "../utils/github-pat.js"; +import { isAllowedUser } from "../utils/github/allowed-users.js"; +import { validate } from "uuid"; // TODO: Export from LangGraph SDK export interface BaseAuthReturn { @@ -31,6 +34,58 @@ interface AuthenticateReturn extends BaseAuthReturn { }; } +function apiKeysInRequestBody( + bodyStr: string | Record, +): boolean { + try { + const body = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr; + if ( + body.config?.configurable && + ("anthropicApiKey" in body.config.configurable.apiKeys || + "openaiApiKey" in body.config.configurable.apiKeys || + "googleApiKey" in body.config.configurable.apiKeys) + ) { + return true; + } + return false; + } catch { + // no-op + return false; + } +} + +function isRunReq(reqUrl: string): boolean { + try { + const url = new URL(reqUrl); + const pathnameParts = url.pathname.split("/"); + const isCreateAndWait = !!( + pathnameParts[1] === "threads" && + validate(pathnameParts[2]) && + pathnameParts[3] === "runs" && + pathnameParts[4] === "wait" && + pathnameParts.length === 5 + ); + const isCreateBackground = !!( + pathnameParts[1] === "threads" && + validate(pathnameParts[2]) && + pathnameParts[3] === "runs" && + pathnameParts.length === 4 + ); + const isCreateStream = !!( + pathnameParts[1] === "threads" && + validate(pathnameParts[2]) && + pathnameParts[3] === "runs" && + pathnameParts[4] === "stream" && + pathnameParts.length === 5 + ); + + return !!isCreateAndWait || !!isCreateBackground || !!isCreateStream; + } catch { + // no-op + return false; + } +} + export const auth = new Auth() .authenticate(async (request: Request) => { if (request.method === "OPTIONS") { @@ -100,10 +155,18 @@ export const auth = new Auth() }); } + const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE); + const decryptedAccessToken = encryptedAccessToken + ? decryptSecret(encryptedAccessToken, encryptionKey) + : undefined; + const decryptedInstallationToken = decryptSecret( + encryptedInstallationToken, + encryptionKey, + ); + let user: GithubUser | undefined; - const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE); - if (!encryptedAccessToken) { + if (!decryptedAccessToken) { // If there isn't a user access token, check to see if the user info is in headers. // This would indicate a bot created the request. const userIdHeader = request.headers.get(GITHUB_USER_ID_HEADER); @@ -114,15 +177,13 @@ export const auth = new Auth() }); } user = await verifyGithubUserId( - decryptSecret(encryptedInstallationToken, encryptionKey), + decryptedInstallationToken, Number(userIdHeader), userLoginHeader, ); } else { // Ensure we decrypt the token before passing to the verification function. - user = await verifyGithubUser( - decryptSecret(encryptedAccessToken, encryptionKey), - ); + user = await verifyGithubUser(decryptedAccessToken); } if (!user) { @@ -131,6 +192,18 @@ export const auth = new Auth() }); } + const reqCopy = request.clone(); + const reqBody = await reqCopy.text(); + if (!isAllowedUser(user.login)) { + if (isRunReq(request.url)) { + if (!apiKeysInRequestBody(reqBody)) { + throw new HTTPException(401, { + message: API_KEY_REQUIRED_MESSAGE, + }); + } + } + } + return { identity: user.id.toString(), is_authenticated: true, diff --git a/apps/open-swe/src/utils/config.ts b/apps/open-swe/src/utils/config.ts index 9d6d988b..835cea88 100644 --- a/apps/open-swe/src/utils/config.ts +++ b/apps/open-swe/src/utils/config.ts @@ -14,7 +14,10 @@ export function getCustomConfigurableFields( GraphConfigurationMetadata, )) { if (key in config.configurable) { - if (metadataValue.x_open_swe_ui_config.type !== "hidden") { + if ( + metadataValue.x_open_swe_ui_config.type !== "hidden" || + key === "apiKeys" + ) { result[key as keyof GraphConfig["configurable"]] = config.configurable[key as keyof GraphConfig["configurable"]]; } diff --git a/apps/open-swe/src/utils/github/allowed-users.ts b/apps/open-swe/src/utils/github/allowed-users.ts new file mode 100644 index 00000000..8b3ada66 --- /dev/null +++ b/apps/open-swe/src/utils/github/allowed-users.ts @@ -0,0 +1,66 @@ +export const ALLOWED_USERS = [ + "agola11", + "akira", + "aliyanishfaq", + "andrewnguonly", + "angus-langchain", + "bracesproul", + "ArthurLangChain", + "baskaryan", + "bvs-langchain", + "catherine-langchain", + "ccurme", + "crystalro0", + "dqbd", + "emily-langchain", + "eric-langchain", + "EugeneJinXin", + "eyurtsev", + "gladwig2", + "hari-dhanushkodi", + "hinthornw", + "hntrl", + "hwchase17", + "iakshay", + "isahers1", + "j-broekhuizen", + "jacoblee93", + "jdrogers940", + "joaquin-borggio-lc", + "katmayb", + "keshivtandon", + "langchain-infra", + "lc-arjun", + "lc-chad", + "lnhsingh", + "madams0013", + "mdrxy", + "mhk197", + "nfcampos", + "nhuang-lc", + "nitboss", + "PeriniM", + "phvash", + "QuentinBrosse", + "rlancemartin", + "romain-priour-lc", + "samecrowder", + "samnoyes", + "starmorph", + "suraj-langchain", + "sydney-runkle", + "tanushree-sharma", + "victorm-lc", + "xornivore", + "xuro-langchain", +]; + +// HACK: Until we setup proper support for API credits, we will only allow users to self host Open SWE +export function isAllowedUser(username: string): boolean { + const restrictToLangChainAuth = + process.env.RESTRICT_TO_LANGCHAIN_AUTH === "true"; + if (!restrictToLangChainAuth) { + return true; + } + return ALLOWED_USERS.some((u) => u === username); +} diff --git a/apps/open-swe/src/utils/load-model.ts b/apps/open-swe/src/utils/load-model.ts index 9bc09b2b..7bb404ea 100644 --- a/apps/open-swe/src/utils/load-model.ts +++ b/apps/open-swe/src/utils/load-model.ts @@ -1,5 +1,7 @@ import { initChatModel } from "langchain/chat_models/universal"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { isAllowedUser } from "./github/allowed-users.js"; +import { decryptSecret } from "@open-swe/shared/crypto"; export enum Task { /** @@ -35,6 +37,22 @@ const TASK_TO_CONFIG_DEFAULTS_MAP = { }, }; +const providerToApiKey = ( + providerName: string, + apiKeys: Record, +): string => { + switch (providerName) { + case "openai": + return apiKeys.openaiApiKey; + case "anthropic": + return apiKeys.anthropicApiKey; + case "google-genai": + return apiKeys.googleApiKey; + default: + throw new Error(`Unknown provider: ${providerName}`); + } +}; + export async function loadModel(config: GraphConfig, task: Task) { const modelStr = config.configurable?.[`${task}ModelName`] ?? @@ -66,9 +84,35 @@ export async function loadModel(config: GraphConfig, task: Task) { maxTokens = maxTokens > 8_192 ? 8_192 : maxTokens; } + // TODO: Fix types + const userLogin = (config.configurable as any)?.langgraph_auth_user + ?.display_name; + const secretsEncryptionKey = process.env.SECRETS_ENCRYPTION_KEY; + if (!secretsEncryptionKey) { + throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required"); + } + if (!userLogin) { + throw new Error("User login not found in config"); + } + const apiKeys = config.configurable?.apiKeys; + let apiKey: string | null = null; + if (!isAllowedUser(userLogin)) { + if (!apiKeys) { + throw new Error("API keys not found in config"); + } + apiKey = decryptSecret( + providerToApiKey(modelProvider, apiKeys), + secretsEncryptionKey, + ); + if (!apiKey) { + throw new Error("No API key found for provider: " + modelProvider); + } + } + const model = await initChatModel(modelName, { modelProvider, temperature: thinkingModel ? undefined : temperature, + ...(apiKey ? { apiKey } : {}), ...(thinkingModel && modelProvider === "anthropic" ? { thinking: { budget_tokens: thinkingBudgetTokens, type: "enabled" }, diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts index d22e3bee..11024ccf 100644 --- a/apps/web/src/app/api/[..._path]/route.ts +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -10,6 +10,7 @@ import { getInstallationNameFromReq, getGitHubAccessTokenOrThrow, } from "./utils"; +import { encryptSecret } from "@open-swe/shared/crypto"; // This file acts as a proxy for requests to your LangGraph server. // Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information. @@ -19,6 +20,33 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } = apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", runtime: "edge", // default disableWarningLog: true, + bodyParameters: (req, body) => { + if (body.config?.configurable && "apiKeys" in body.config.configurable) { + const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error( + "SECRETS_ENCRYPTION_KEY environment variable is required", + ); + } + + const apiKeys = body.config.configurable.apiKeys; + const encryptedApiKeys: Record = {}; + + // Encrypt each field in the apiKeys object + for (const [key, value] of Object.entries(apiKeys)) { + if (typeof value === "string" && value.trim() !== "") { + encryptedApiKeys[key] = encryptSecret(value, encryptionKey); + } else { + encryptedApiKeys[key] = value; + } + } + + // Update the body with encrypted apiKeys + body.config.configurable.apiKeys = encryptedApiKeys; + return body; + } + return body; + }, headers: async (req) => { const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY; if (!encryptionKey) { diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 787ca96a..9e6b9b8a 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -11,12 +11,20 @@ const Toaster = ({ ...props }: ToasterProps) => { toastOptions={{ classNames: { toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", - description: "group-[.toast]:text-muted-foreground", + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-slate-950 dark:group-[.toaster]:border-slate-800 dark:group-[.toaster]:shadow-2xl dark:group-[.toaster]:shadow-black/20", + description: + "group-[.toast]:text-muted-foreground dark:group-[.toast]:text-slate-400", actionButton: - "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium", + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium hover:group-[.toast]:bg-primary/90 dark:group-[.toast]:bg-slate-200 dark:group-[.toast]:text-slate-900 dark:hover:group-[.toast]:bg-slate-100", cancelButton: - "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium", + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium hover:group-[.toast]:bg-muted/80 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-300 dark:hover:group-[.toast]:bg-slate-700", + success: + "group-[.toast]:bg-green-50 group-[.toast]:text-green-900 group-[.toast]:border-green-200 dark:group-[.toast]:bg-green-950/50 dark:group-[.toast]:text-green-100 dark:group-[.toast]:border-green-800/50", + error: + "group-[.toast]:bg-red-50 group-[.toast]:text-red-900 group-[.toast]:border-red-200 dark:group-[.toast]:bg-red-950/50 dark:group-[.toast]:text-red-100 dark:group-[.toast]:border-red-800/50", + warning: + "group-[.toast]:bg-yellow-50 group-[.toast]:text-yellow-900 group-[.toast]:border-yellow-200 dark:group-[.toast]:bg-yellow-950/50 dark:group-[.toast]:text-yellow-100 dark:group-[.toast]:border-yellow-800/50", + info: "group-[.toast]:bg-blue-50 group-[.toast]:text-blue-900 group-[.toast]:border-blue-200 dark:group-[.toast]:bg-blue-950/50 dark:group-[.toast]:text-blue-100 dark:group-[.toast]:border-blue-800/50", }, }} {...props} diff --git a/apps/web/src/components/v2/terminal-input.tsx b/apps/web/src/components/v2/terminal-input.tsx index 02b1e559..ef0b27d2 100644 --- a/apps/web/src/components/v2/terminal-input.tsx +++ b/apps/web/src/components/v2/terminal-input.tsx @@ -14,7 +14,10 @@ import { GraphState } from "@open-swe/shared/open-swe/types"; import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages"; import { toast } from "sonner"; import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore"; -import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; +import { + API_KEY_REQUIRED_MESSAGE, + MANAGER_GRAPH_ID, +} from "@open-swe/shared/constants"; import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; import { useDraftStorage } from "@/hooks/useDraftStorage"; @@ -113,7 +116,31 @@ export function TerminalInput({ setContentBlocks([]); setAutoAcceptPlan(false); } catch (e) { - console.error(e); + if ( + typeof e === "object" && + e !== null && + "message" in e && + e.message !== null && + typeof e.message === "string" && + e.message.includes(API_KEY_REQUIRED_MESSAGE) + ) { + toast.error( +

+ {API_KEY_REQUIRED_MESSAGE} Please add your API key(s) in{" "} + + settings + +

, + { + richColors: true, + duration: 30_000, + closeButton: true, + }, + ); + } } finally { setLoading(false); } diff --git a/apps/web/src/features/settings-page/api-keys.tsx b/apps/web/src/features/settings-page/api-keys.tsx index ddfd9497..e083d0a9 100644 --- a/apps/web/src/features/settings-page/api-keys.tsx +++ b/apps/web/src/features/settings-page/api-keys.tsx @@ -12,10 +12,12 @@ import { Eye, EyeOff, Key, Trash2 } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore"; interface ApiKey { id: string; name: string; + description?: string; value: string; isVisible: boolean; lastUsed?: string; @@ -26,72 +28,82 @@ interface ApiKeySection { keys: ApiKey[]; } +const API_KEY_SECTIONS: Record> = { + llms: { + title: "LLMs", + }, + infrastructure: { + title: "Infrastructure", + }, +}; + +const API_KEY_DEFINITIONS = { + llms: [ + { id: "anthropicApiKey", name: "Anthropic" }, + { id: "openaiApiKey", name: "OpenAI" }, + { id: "googleApiKey", name: "Google Gen AI" }, + ], + infrastructure: [ + { + id: "daytonaApiKey", + name: "Daytona", + description: "Users not required to set this if using the demo", + }, + ], +}; + export function APIKeysTab() { - const [apiKeySections, setApiKeySections] = useState< - Record - >({ - llms: { - title: "LLMs", - keys: [ - { - id: "anthropicApiKey", - name: "Anthropic", - value: "", - isVisible: false, - }, - { id: "openaiApiKey", name: "OpenAI", value: "", isVisible: false }, - { - id: "googleApiKey", - name: "Google Gen AI", - value: "", - isVisible: false, - }, - ], - }, - infrastructure: { - title: "Infrastructure", - keys: [ - { id: "daytonaApiKey", name: "Daytona", value: "", isVisible: false }, - ], - }, - }); + const { getConfig, updateConfig } = useConfigStore(); + const config = getConfig(DEFAULT_CONFIG_KEY); - const toggleKeyVisibility = (sectionKey: string, keyId: string) => { - setApiKeySections((prev) => ({ + const [visibilityState, setVisibilityState] = useState< + Record + >({}); + + const toggleKeyVisibility = (keyId: string) => { + setVisibilityState((prev) => ({ ...prev, - [sectionKey]: { - ...prev[sectionKey], - keys: prev[sectionKey].keys.map((key) => - key.id === keyId ? { ...key, isVisible: !key.isVisible } : key, - ), - }, + [keyId]: !prev[keyId], })); }; - const updateApiKey = (sectionKey: string, keyId: string, value: string) => { - setApiKeySections((prev) => ({ - ...prev, - [sectionKey]: { - ...prev[sectionKey], - keys: prev[sectionKey].keys.map((key) => - key.id === keyId ? { ...key, value } : key, - ), - }, - })); + const updateApiKey = (keyId: string, value: string) => { + const currentApiKeys = config.apiKeys || {}; + updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", { + ...currentApiKeys, + [keyId]: value, + }); }; - const deleteApiKey = (sectionKey: string, keyId: string) => { - setApiKeySections((prev) => ({ - ...prev, - [sectionKey]: { - ...prev[sectionKey], - keys: prev[sectionKey].keys.map((key) => - key.id === keyId ? { ...key, value: "" } : key, - ), - }, - })); + const deleteApiKey = (keyId: string) => { + const currentApiKeys = config.apiKeys || {}; + const updatedApiKeys = { ...currentApiKeys }; + delete updatedApiKeys[keyId]; + updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", updatedApiKeys); }; + const getApiKeySections = (): Record => { + const sections: Record = {}; + const apiKeys = config.apiKeys || {}; + + Object.entries(API_KEY_SECTIONS).forEach(([sectionKey, sectionInfo]) => { + sections[sectionKey] = { + ...sectionInfo, + keys: API_KEY_DEFINITIONS[ + sectionKey as keyof typeof API_KEY_DEFINITIONS + ].map((keyDef) => ({ + ...keyDef, + value: apiKeys[keyDef.id] || "", + isVisible: visibilityState[keyDef.id] || false, + })), + }; + }); + + return sections; + }; + + const apiKeySections = getApiKeySections(); + return (
{Object.entries(apiKeySections).map(([sectionKey, section]) => ( @@ -148,13 +160,18 @@ export function APIKeysTab() { > API Key + {apiKey.description && ( +

+ {apiKey.description} +

+ )}
- updateApiKey(sectionKey, apiKey.id, e.target.value) + updateApiKey(apiKey.id, e.target.value) } placeholder={`Enter your ${apiKey.name} API key`} className="font-mono text-sm" @@ -162,9 +179,7 @@ export function APIKeysTab() { - )}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 166ce2f1..de59bbc7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -38,3 +38,6 @@ export const DEFAULT_MCP_SERVERS = { stderr: "inherit" as const, }, }; + +export const API_KEY_REQUIRED_MESSAGE = + "Unknown users must provide API keys to use the Open SWE demo application"; diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 7ecf7c1a..202b0258 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -372,6 +372,11 @@ export const GraphConfigurationMetadata: { "JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).", }, }, + apiKeys: { + x_open_swe_ui_config: { + type: "hidden", + }, + }, [GITHUB_TOKEN_COOKIE]: { x_open_swe_ui_config: { type: "hidden", @@ -496,6 +501,12 @@ export const GraphConfiguration = z.object({ maxTokens: withLangGraph(z.number().optional(), { metadata: GraphConfigurationMetadata.maxTokens, }), + /** + * User defined API keys to use + */ + apiKeys: withLangGraph(z.record(z.string(), z.string()).optional(), { + metadata: GraphConfigurationMetadata.apiKeys, + }), /** * The user's GitHub access token. To be used in requests to get information about the user. */