diff --git a/README.md b/README.md index 9bb64eee..19ab2e1e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,9 @@ DAYTONA_API_KEY="" And the web `.env` file should contain the following variables: ```bash -NEXT_PUBLIC_API_URL=http://localhost:2024 # Change to production URL when deployed +# Change to production URLs when deployed +NEXT_PUBLIC_API_URL="http://localhost:3000/api" +LANGGRAPH_API_URL="http://localhost:2024" NEXT_PUBLIC_ASSISTANT_ID="open-swe" # For the GitHub OAuth flow @@ -137,14 +139,3 @@ Once you've accepted the plan, it will begin the execution flow. When the agent ## Accessing Changes Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/`. Every time a file is created, modified, or deleted, the changes will be committed to this branch. You can access the changes in the repository by checking out this branch. - - - -## Install Daytona CLI -```bash -# Mac os -brew install daytonaio/cli/daytona -# Windows -powershell -Command "irm https://get.daytona.io/windows | iex" - -``` \ No newline at end of file diff --git a/apps/open-swe/src/nodes/initialize.ts b/apps/open-swe/src/nodes/initialize.ts index aa9e0809..568faabf 100644 --- a/apps/open-swe/src/nodes/initialize.ts +++ b/apps/open-swe/src/nodes/initialize.ts @@ -29,7 +29,7 @@ export async function initialize( state: GraphState, config: GraphConfig, ): Promise { - const { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config); + const { githubAccessToken } = getGitHubTokensFromConfig(config); const { sandboxSessionId, targetRepository } = state; const absoluteRepoDir = getRepoAbsolutePath(targetRepository); @@ -58,7 +58,7 @@ export async function initialize( }); const res = await cloneRepo(sandbox, targetRepository, { - githubToken, + githubAccessToken, stateBranchName: state.branchName, }); if (res.exitCode !== 0) { @@ -70,7 +70,6 @@ export async function initialize( logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`); await configureGitUserInRepo(absoluteRepoDir, sandbox, { - githubToken, githubAccessToken, owner: targetRepository.owner, repo: targetRepository.repo, diff --git a/apps/open-swe/src/nodes/open-pr.ts b/apps/open-swe/src/nodes/open-pr.ts index 9d02a4bc..388cf4ea 100644 --- a/apps/open-swe/src/nodes/open-pr.ts +++ b/apps/open-swe/src/nodes/open-pr.ts @@ -72,7 +72,7 @@ export async function openPullRequest( "Failed to open pull request: No sandbox session ID found in state.", ); } - const { githubToken } = getGitHubTokensFromConfig(config); + const { githubAccessToken } = getGitHubTokensFromConfig(config); const sandbox = await daytonaClient().get(sandboxSessionId); @@ -132,7 +132,7 @@ export async function openPullRequest( headBranch: branchName ?? getBranchName(config), title, body, - githubToken, + githubAccessToken, }); let sandboxDeleted = false; diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts new file mode 100644 index 00000000..ece32fe2 --- /dev/null +++ b/apps/open-swe/src/security/auth.ts @@ -0,0 +1,115 @@ +import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth"; +import { verifyGithubUser, GithubUser } from "./github-auth.js"; +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; + +const STUDIO_USER_ID = "langgraph-studio-user"; + +// Helper function to check if user is studio user +const isStudioUser = (userIdentity: string): boolean => { + return userIdentity === STUDIO_USER_ID; +}; + +// Helper function for operations that only need owner filtering +const createOwnerFilter = (user: { identity: string }) => { + if (isStudioUser(user.identity)) { + return; + } + return { owner: user.identity }; +}; + +// Helper function for create operations that set metadata +const createWithOwnerMetadata = (value: any, user: { identity: string }) => { + if (isStudioUser(user.identity)) { + return; + } + + value.metadata ??= {}; + value.metadata.owner = user.identity; + return { owner: user.identity }; +}; + +export const auth = new Auth() + .authenticate(async (request: Request) => { + if (request.method === "OPTIONS") { + return { + identity: "anonymous", + permissions: [], + is_authenticated: false, + display_name: "CORS Preflight", + }; + } + // Parse Authorization header + const accessToken = request.headers.get(GITHUB_TOKEN_COOKIE); + if (!accessToken) { + throw new HTTPException(401, { + message: "GitHub access token header missing", + }); + } + + // Validate GitHub access token + let user: GithubUser | undefined; + try { + user = await verifyGithubUser(accessToken); + if (!user) { + throw new HTTPException(401, { + message: + "Invalid GitHub token or user is not a member of the required organization.", + }); + } + } catch (e: any) { + throw new HTTPException(401, { + message: `Authentication error: ${e.message}`, + }); + } + return { + identity: user.id.toString(), + is_authenticated: true, + display_name: user.login, + permissions: [ + "threads:create", + "threads:create_run", + "threads:read", + "threads:delete", + "threads:update", + "threads:search", + "assistants:create", + "assistants:read", + "assistants:delete", + "assistants:update", + "assistants:search", + "deployments:read", + "deployments:search", + "store:access", + ], + }; + }) + + // THREADS: create operations with metadata + .on("threads:create", ({ value, user }) => + createWithOwnerMetadata(value, user), + ) + .on("threads:create_run", ({ value, user }) => + createWithOwnerMetadata(value, user), + ) + + // THREADS: read, update, delete, search operations + .on("threads:read", ({ user }) => createOwnerFilter(user)) + .on("threads:update", ({ user }) => createOwnerFilter(user)) + .on("threads:delete", ({ user }) => createOwnerFilter(user)) + .on("threads:search", ({ user }) => createOwnerFilter(user)) + + // ASSISTANTS: create operation with metadata + .on("assistants:create", ({ value, user }) => + createWithOwnerMetadata(value, user), + ) + + // ASSISTANTS: read, update, delete, search operations + .on("assistants:read", ({ user }) => createOwnerFilter(user)) + .on("assistants:update", ({ user }) => createOwnerFilter(user)) + .on("assistants:delete", ({ user }) => createOwnerFilter(user)) + .on("assistants:search", ({ user }) => createOwnerFilter(user)) + + // STORE: permission-based access + .on("store", ({ user }) => { + return { owner: user.identity }; + }); diff --git a/apps/open-swe/src/security/github-auth.ts b/apps/open-swe/src/security/github-auth.ts new file mode 100644 index 00000000..6025eda1 --- /dev/null +++ b/apps/open-swe/src/security/github-auth.ts @@ -0,0 +1,57 @@ +import { Octokit } from "@octokit/rest"; +import { Endpoints } from "@octokit/types"; +import { createLogger, LogLevel } from "../utils/logger.js"; + +const logger = createLogger(LogLevel.INFO, "GithubAuth"); + +export type GithubUser = Endpoints["GET /user"]["response"]["data"]; + +/** + * Verifies a GitHub user access token and checks for membership in the 'langchain-ai' organization. + * + * @param accessToken The GitHub user access token. + * @returns A promise that resolves with the user object if valid and a member, otherwise undefined. + */ +export async function verifyGithubUser( + accessToken: string, +): Promise { + if (!accessToken) { + return undefined; + } + + try { + const octokit = new Octokit({ auth: accessToken }); + + // 1. Fetch user information to validate the token + const { data: user } = await octokit.users.getAuthenticated(); + + if (!user || !user.login) { + logger.error( + "GitHub token is invalid or user information could not be retrieved.", + ); + return undefined; + } + + const username = user.login; + + // 2. List organizations for the user + const { data: orgs } = await octokit.orgs.listForUser({ + username, + }); + + // 3. Check for 'langchain-ai' organization membership + const isMember = orgs.some((org) => org.login === "langchain-ai"); + + if (!isMember) { + logger.info( + `User ${username} is not a member of the 'langchain-ai' organization.`, + ); + return undefined; + } + + return user; + } catch (error) { + logger.error("An error occurred during GitHub user verification:", error); + return undefined; + } +} diff --git a/apps/open-swe/src/utils/git.ts b/apps/open-swe/src/utils/git.ts index 7e72070d..7ce68c6d 100644 --- a/apps/open-swe/src/utils/git.ts +++ b/apps/open-swe/src/utils/git.ts @@ -214,13 +214,12 @@ export async function configureGitUserInRepo( absoluteRepoDir: string, sandbox: Sandbox, args: { - githubToken: string; githubAccessToken: string; owner: string; repo: string; }, ): Promise { - const { githubToken, githubAccessToken, owner, repo } = args; + const { githubAccessToken, owner, repo } = args; let needsGitConfig = false; try { const nameCheck = await sandbox.process.executeCommand( @@ -262,7 +261,7 @@ export async function configureGitUserInRepo( try { // Set the remote URL with the token using the provided owner and repo const setRemoteOutput = await sandbox.process.executeCommand( - `git remote set-url origin https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`, + `git remote set-url origin https://x-access-token:${githubAccessToken}@github.com/${owner}/${repo}.git`, absoluteRepoDir, undefined, TIMEOUT_SEC, @@ -493,17 +492,17 @@ export async function createPullRequest({ headBranch, title, body = "", - githubToken, + githubAccessToken, }: { owner: string; repo: string; headBranch: string; title: string; body?: string; - githubToken: string; + githubAccessToken: string; }) { const octokit = new Octokit({ - auth: githubToken, + auth: githubAccessToken, }); try { @@ -535,7 +534,7 @@ export async function createPullRequest({ logger.info( "Pull request already exists. Getting existing pull request...", ); - return getExistingPullRequest(owner, repo, headBranch, githubToken); + return getExistingPullRequest(owner, repo, headBranch, githubAccessToken); } logger.error(`Failed to create pull request`, { @@ -575,7 +574,7 @@ export async function cloneRepo( sandbox: Sandbox, targetRepository: TargetRepository, args: { - githubToken: string; + githubAccessToken: string; stateBranchName?: string; }, ) { @@ -583,7 +582,7 @@ export async function cloneRepo( const gitCloneCommand = ["git", "clone"]; // Use x-access-token format for better GitHub authentication - const repoUrlWithToken = `https://x-access-token:${args.githubToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`; + const repoUrlWithToken = `https://x-access-token:${args.githubAccessToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`; const branchName = args.stateBranchName || targetRepository.branch; if (branchName) { diff --git a/apps/open-swe/src/utils/github-tokens.ts b/apps/open-swe/src/utils/github-tokens.ts index 87a91f4e..31e6c0f6 100644 --- a/apps/open-swe/src/utils/github-tokens.ts +++ b/apps/open-swe/src/utils/github-tokens.ts @@ -1,21 +1,15 @@ +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; export function getGitHubTokensFromConfig(config: GraphConfig): { - githubToken: string; githubAccessToken: string; } { if (!config.configurable) { throw new Error("No configurable object found in graph config."); } - const githubToken = config.configurable["x-github-installation-token"]; - const githubAccessToken = config.configurable["x-github-access-token"]; - if (!githubToken) { - throw new Error( - "Missing required x-github-installation-token in configuration.", - ); - } + const githubAccessToken = config.configurable[GITHUB_TOKEN_COOKIE]; if (!githubAccessToken) { throw new Error("Missing required x-github-access-token in configuration."); } - return { githubToken, githubAccessToken }; + return { githubAccessToken }; } diff --git a/apps/web/.env.example b/apps/web/.env.example index 7c638e6d..8a496674 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,5 @@ -NEXT_PUBLIC_API_URL="http://localhost:2024" +NEXT_PUBLIC_API_URL="http://localhost:3000/api" +LANGGRAPH_API_URL="http://localhost:2024" NEXT_PUBLIC_ASSISTANT_ID="open-swe" # For the GitHub OAuth flow diff --git a/apps/web/package.json b/apps/web/package.json index 056e124e..a9f58e68 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,7 +47,7 @@ "framer-motion": "^12.4.9", "jsonwebtoken": "^9.0.2", "katex": "^0.16.21", - "langgraph-nextjs-api-passthrough": "^0.0.4", + "langgraph-nextjs-api-passthrough": "^0.1.2", "lodash": "^4.17.21", "lucide-react": "^0.476.0", "next-themes": "^0.4.4", diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts index 5524d1f0..0a203fe7 100644 --- a/apps/web/src/app/api/[..._path]/route.ts +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -1,11 +1,18 @@ import { initApiPassthrough } from "langgraph-nextjs-api-passthrough"; +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; // 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. export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } = initApiPassthrough({ - apiUrl: process.env.LANGGRAPH_API_URL ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGGRAPH_API_URL - apiKey: process.env.LANGSMITH_API_KEY ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGSMITH_API_KEY + apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", runtime: "edge", // default + disableWarningLog: true, + headers: (req) => { + return { + [GITHUB_TOKEN_COOKIE]: + req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "", + }; + }, }); diff --git a/apps/web/src/app/api/auth/github/callback/route.ts b/apps/web/src/app/api/auth/github/callback/route.ts index f84db72a..fa064790 100644 --- a/apps/web/src/app/api/auth/github/callback/route.ts +++ b/apps/web/src/app/api/auth/github/callback/route.ts @@ -1,10 +1,10 @@ import { GITHUB_AUTH_STATE_COOKIE, GITHUB_INSTALLATION_ID_COOKIE, - GITHUB_TOKEN_COOKIE, GITHUB_TOKEN_TYPE_COOKIE, } from "@/lib/auth"; import { NextRequest, NextResponse } from "next/server"; +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; export async function GET(request: NextRequest) { try { @@ -93,7 +93,7 @@ export async function GET(request: NextRequest) { // Set token cookies directly on the response response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, { - // httpOnly: true, + httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24 * 30, // 30 days diff --git a/apps/web/src/app/api/github/installation/route.ts b/apps/web/src/app/api/github/installation/route.ts index 53cedfe0..0bce2236 100644 --- a/apps/web/src/app/api/github/installation/route.ts +++ b/apps/web/src/app/api/github/installation/route.ts @@ -1,10 +1,10 @@ import { GITHUB_INSTALLATION_RETURN_TO_COOKIE, GITHUB_INSTALLATION_STATE_COOKIE, - GITHUB_TOKEN_COOKIE, } from "@/lib/auth"; import { NextRequest, NextResponse } from "next/server"; import { randomBytes } from "crypto"; +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; /** * Initiates the GitHub App installation flow diff --git a/apps/web/src/app/api/github/proxy/[..._path]/route.ts b/apps/web/src/app/api/github/proxy/[..._path]/route.ts new file mode 100644 index 00000000..6dbf8e9a --- /dev/null +++ b/apps/web/src/app/api/github/proxy/[..._path]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getInstallationToken } from "../../../../../utils/github"; // Adjusted path +import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth"; + +const GITHUB_API_URL = "https://api.github.com"; + +async function handler(req: NextRequest) { + const path = req.nextUrl.pathname.replace(/^\/api\/github\/proxy\//, ""); + const installationIdCookie = req.cookies.get( + GITHUB_INSTALLATION_ID_COOKIE, + )?.value; + + if (!installationIdCookie) { + return NextResponse.json( + { error: `"${GITHUB_INSTALLATION_ID_COOKIE}" cookie is required` }, + { status: 400 }, + ); + } + + const appId = process.env.GITHUB_APP_ID; + const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY; + + if (!appId || !privateAppKey) { + console.error("GitHub App ID or Private App Key is not configured."); + return NextResponse.json( + { error: `Missing required environment variables.` }, + { status: 500 }, + ); + } + + try { + const token = await getInstallationToken( + installationIdCookie, + appId, + privateAppKey, + ); + + const targetUrl = new URL(`${GITHUB_API_URL}/${path}`); + + const headers = new Headers(); + headers.set("Authorization", `Bearer ${token}`); + headers.set("Accept", "application/vnd.github.v3+json"); + headers.set("User-Agent", "OpenSWE-Proxy"); + + if (req.headers.has("Content-Type")) { + headers.set("Content-Type", req.headers.get("Content-Type")!); + } + + const response = await fetch(targetUrl.toString(), { + method: req.method, + headers: headers, + body: + req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, + }); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("Content-Encoding"); // Prevent ERR_CONTENT_DECODING_FAILED error. + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + console.error("Error in GitHub proxy:", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: "Failed to proxy request to GitHub", details: errorMessage }, + { status: 500 }, + ); + } +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const DELETE = handler; +export const PATCH = handler; diff --git a/apps/web/src/components/github/agent-token-provider.tsx b/apps/web/src/components/github/agent-token-provider.tsx deleted file mode 100644 index 4571c21d..00000000 --- a/apps/web/src/components/github/agent-token-provider.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { CopyIcon, CheckIcon, RefreshCwIcon } from "lucide-react"; -import { InstallAppButton } from "./install-app-button"; - -interface AgentTokenProviderProps { - className?: string; -} - -/** - * Component to fetch and display a GitHub installation token for use with the AI agent - * This token can be passed to your agent service to perform Git operations on behalf of the user - */ -export function AgentTokenProvider({ - className = "", -}: AgentTokenProviderProps) { - const [token, setToken] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [copied, setCopied] = useState(false); - const [installationId, setInstallationId] = useState(null); - - const fetchToken = async () => { - setIsLoading(true); - setError(null); - setCopied(false); - - try { - const response = await fetch("/api/github/token"); - - if (!response.ok) { - const errorData = await response.json(); - setError(errorData.error || "Failed to fetch token"); - setIsLoading(false); - return; - } - - const data = await response.json(); - setToken(data.token); - setInstallationId(data.installation_id); - setIsLoading(false); - } catch { - setError("Network error when fetching token"); - setIsLoading(false); - } - }; - - useEffect(() => { - fetchToken(); - }, []); - - const copyToken = () => { - if (token) { - navigator.clipboard.writeText(token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - if (error && error.includes("installation")) { - return ( -
-

GitHub App Not Installed

-

- You need to install our GitHub App to generate tokens for the AI - agent. -

- Install GitHub App -
- ); - } - - if (error) { - return ( -
-
-

{error}

-
- -
- ); - } - - return ( -
-
-

GitHub Token for AI Agent

- -
- - {isLoading ? ( -
-
-
-
- ) : token ? ( - <> -

- This token expires in 1 hour. Use it to authenticate your AI agent - with GitHub. -

-
-
- {token} -
- -
-
-

How to use this token:

-
- {`export GITHUB_TOKEN=${token}`} -
-

- Pass this token to your agent service to perform Git operations on - behalf of the user. -

-
- - ) : ( -

Loading token...

- )} -
- ); -} diff --git a/apps/web/src/hooks/useGitHubApp.ts b/apps/web/src/hooks/useGitHubApp.ts index 6b265a84..a3040846 100644 --- a/apps/web/src/hooks/useGitHubApp.ts +++ b/apps/web/src/hooks/useGitHubApp.ts @@ -20,17 +20,6 @@ interface UseGitHubAppReturn { defaultBranch: string | null; } -// Helper function to get GitHub OAuth access token from cookies -function getGitHubAccessToken(): string | null { - if (typeof document === "undefined") return null; - - const cookies = document.cookie.split("; "); - const tokenCookie = cookies.find((row) => - row.startsWith("x-github_access_token="), - ); - return tokenCookie ? tokenCookie.split("=")[1] : null; -} - export function useGitHubApp(): UseGitHubAppReturn { const [isInstalled, setIsInstalled] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -116,12 +105,6 @@ export function useGitHubApp(): UseGitHubAppReturn { return; } - const accessToken = getGitHubAccessToken(); - if (!accessToken) { - setBranchesError("GitHub access token not found"); - return; - } - setBranchesLoading(true); setBranchesError(null); @@ -129,7 +112,6 @@ export function useGitHubApp(): UseGitHubAppReturn { const branchData = await getRepositoryBranches( selectedRepository.owner, selectedRepository.repo, - accessToken, ); setBranches(branchData || []); } catch (err) { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index a022b75c..986be09d 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; -// Prefix the access token with `x-` so that it's included in requests to the LangGraph server. -export const GITHUB_TOKEN_COOKIE = "x-github_access_token"; export const GITHUB_TOKEN_TYPE_COOKIE = "github_token_type"; export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id"; export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state"; diff --git a/apps/web/src/providers/Stream.tsx b/apps/web/src/providers/Stream.tsx index d0e87409..3a38eaa5 100644 --- a/apps/web/src/providers/Stream.tsx +++ b/apps/web/src/providers/Stream.tsx @@ -7,7 +7,6 @@ import React, { useRef, } from "react"; import { useStream } from "@langchain/langgraph-sdk/react"; -import { type Message } from "@langchain/langgraph-sdk"; import { uiMessageReducer, isUIMessage, @@ -53,22 +52,13 @@ const StreamSession = ({ githubToken: string; }) => { const [threadId, setThreadId] = useQueryState("threadId"); - const { refreshThreads, setThreads, updateThreadFromStream } = useThreads(); + const { refreshThreads, updateThreadFromStream } = useThreads(); - const githubAccessToken = - document.cookie - .split("; ") - .find((row) => row.startsWith("x-github_access_token=")) - ?.split("=")[1] || ""; const streamValue = useTypedStream({ apiUrl, assistantId, reconnectOnMount: true, threadId: threadId ?? null, - defaultHeaders: { - "x-github-installation-token": githubToken, - "x-github-access-token": githubAccessToken, - }, onCustomEvent: (event, options) => { if (isUIMessage(event) || isRemoveUIMessage(event)) { options.mutate((prev) => { @@ -179,7 +169,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ checkGitHubAppInstallation(); } } - }, [isAuth, githubToken, isTokenLoading]); + }, [isAuth, githubToken]); const checkAuthStatus = async () => { try { diff --git a/apps/web/src/utils/github.ts b/apps/web/src/utils/github.ts index b3648e1e..6f77f161 100644 --- a/apps/web/src/utils/github.ts +++ b/apps/web/src/utils/github.ts @@ -1,5 +1,14 @@ +import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; import * as jwt from "jsonwebtoken"; +function getBaseApiUrl(): string { + let baseApiUrl = new URL( + process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api", + ).href; + baseApiUrl = baseApiUrl.endsWith("/") ? baseApiUrl : `${baseApiUrl}/`; + return baseApiUrl; +} + /** * Generates a JWT for GitHub App authentication */ @@ -82,18 +91,17 @@ export async function getInstallationRepositories( export async function getRepositoryBranches( owner: string, repo: string, - accessToken: string, ): Promise { const allBranches: Branch[] = []; let page = 1; const perPage = 100; // Maximum allowed by GitHub API // First, get repository info to ensure we have the default branch + const repoResponse = await fetch( - `https://api.github.com/repos/${owner}/${repo}`, + `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}`, { headers: { - Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github.v3+json", "User-Agent": "OpenSWE-Agent", }, @@ -109,10 +117,9 @@ export async function getRepositoryBranches( // Fetch all branches with pagination while (true) { const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, + `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`, { headers: { - Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github.v3+json", "User-Agent": "OpenSWE-Agent", }, diff --git a/langgraph.json b/langgraph.json index 263f144e..e0abc97e 100644 --- a/langgraph.json +++ b/langgraph.json @@ -4,6 +4,9 @@ "open_swe": "./apps/open-swe/src/index.ts:graph" }, "env": "./apps/open-swe/.env", - "dependencies": ["./apps/open-swe"] + "dependencies": ["./apps/open-swe"], + "auth": { + "path": "./apps/open-swe/src/security/auth.ts:auth" + } } \ No newline at end of file diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 48da994a..68cf6b4b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -3,3 +3,6 @@ export const SANDBOX_ROOT_DIR = "/home/daytona"; export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0"; export const PLAN_INTERRUPT_DELIMITER = ":::"; export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan"; + +// Prefix the access token with `x-` so that it's included in requests to the LangGraph server. +export const GITHUB_TOKEN_COOKIE = "x-github-access-token"; diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 0eb5f815..7a0a3db9 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -11,6 +11,7 @@ import { type UIMessage, type RemoveUIMessage, } from "@langchain/langgraph-sdk/react-ui"; +import { GITHUB_TOKEN_COOKIE } from "../constants.js"; export type PlanItem = { /** @@ -285,12 +286,7 @@ export const GraphConfigurationMetadata: { "The maximum number of tokens to generate in an individual generation", }, }, - "x-github-installation-token": { - x_open_swe_ui_config: { - type: "hidden", - }, - }, - "x-github-access-token": { + [GITHUB_TOKEN_COOKIE]: { x_open_swe_ui_config: { type: "hidden", }, @@ -414,22 +410,13 @@ export const GraphConfiguration = z.object({ .optional() .default(() => 10_000) .langgraph.metadata(GraphConfigurationMetadata.maxTokens), - /** - * The user's GitHub installation token. To be used to take actions on behalf of the user. - */ - "x-github-installation-token": z - .string() - .optional() - .langgraph.metadata( - GraphConfigurationMetadata["x-github-installation-token"], - ), /** * The user's GitHub access token. To be used in requests to get information about the user. */ - "x-github-access-token": z + [GITHUB_TOKEN_COOKIE]: z .string() .optional() - .langgraph.metadata(GraphConfigurationMetadata["x-github-access-token"]), + .langgraph.metadata(GraphConfigurationMetadata[GITHUB_TOKEN_COOKIE]), }); export type GraphConfig = LangGraphRunnableConfig< diff --git a/yarn.lock b/yarn.lock index 5a947e63..76718e8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2346,7 +2346,7 @@ __metadata: globals: ^15.14.0 jsonwebtoken: ^9.0.2 katex: ^0.16.21 - langgraph-nextjs-api-passthrough: ^0.0.4 + langgraph-nextjs-api-passthrough: ^0.1.2 lodash: ^4.17.21 lucide-react: ^0.476.0 next: ^15.2.3 @@ -9446,12 +9446,12 @@ __metadata: languageName: node linkType: hard -"langgraph-nextjs-api-passthrough@npm:^0.0.4": - version: 0.0.4 - resolution: "langgraph-nextjs-api-passthrough@npm:0.0.4" +"langgraph-nextjs-api-passthrough@npm:^0.1.2": + version: 0.1.2 + resolution: "langgraph-nextjs-api-passthrough@npm:0.1.2" peerDependencies: next: "*" - checksum: e9e0f501cd1495c55166ac5840605c75f4e61bdad2ab102ebbffc8f5ef3055c87cf23842d12e889e77d81e00920084d59e8dd4c596fd54494077f2b2cd9e6fb1 + checksum: 3ed989353b376e8e7b36cf37181838a439246e19cca1e1f8ae3cc27852ce8dcc7ed86ee62acb6c34055c60aea50a248d7f098012e2993f2b2c8e0f650ce71d23 languageName: node linkType: hard