feat: GitHub e2e auth (#106)

* feat: GitHub e2e auth

* cr

* reimplement proxy route

* fix github auth

* cr

* cr

* cr

* cr

* cr
This commit is contained in:
Brace Sproul 2025-06-11 14:00:08 -07:00 • committed by GitHub
parent 3dbb2a576d
commit bf72e1faf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 315 additions and 253 deletions

View file

@ -50,7 +50,9 @@ DAYTONA_API_KEY=""
And the web `.env` file should contain the following variables: And the web `.env` file should contain the following variables:
```bash ```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" NEXT_PUBLIC_ASSISTANT_ID="open-swe"
# For the GitHub OAuth flow # 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 ## Accessing Changes
Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/<threadId>`. 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. Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/<threadId>`. 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"
```

View file

@ -29,7 +29,7 @@ export async function initialize(
state: GraphState, state: GraphState,
config: GraphConfig, config: GraphConfig,
): Promise<GraphUpdate> { ): Promise<GraphUpdate> {
const { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config); const { githubAccessToken } = getGitHubTokensFromConfig(config);
const { sandboxSessionId, targetRepository } = state; const { sandboxSessionId, targetRepository } = state;
const absoluteRepoDir = getRepoAbsolutePath(targetRepository); const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
@ -58,7 +58,7 @@ export async function initialize(
}); });
const res = await cloneRepo(sandbox, targetRepository, { const res = await cloneRepo(sandbox, targetRepository, {
githubToken, githubAccessToken,
stateBranchName: state.branchName, stateBranchName: state.branchName,
}); });
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
@ -70,7 +70,6 @@ export async function initialize(
logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`); logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`);
await configureGitUserInRepo(absoluteRepoDir, sandbox, { await configureGitUserInRepo(absoluteRepoDir, sandbox, {
githubToken,
githubAccessToken, githubAccessToken,
owner: targetRepository.owner, owner: targetRepository.owner,
repo: targetRepository.repo, repo: targetRepository.repo,

View file

@ -72,7 +72,7 @@ export async function openPullRequest(
"Failed to open pull request: No sandbox session ID found in state.", "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); const sandbox = await daytonaClient().get(sandboxSessionId);
@ -132,7 +132,7 @@ export async function openPullRequest(
headBranch: branchName ?? getBranchName(config), headBranch: branchName ?? getBranchName(config),
title, title,
body, body,
githubToken, githubAccessToken,
}); });
let sandboxDeleted = false; let sandboxDeleted = false;

View file

@ -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 };
});

View file

@ -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<GithubUser | undefined> {
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;
}
}

View file

@ -214,13 +214,12 @@ export async function configureGitUserInRepo(
absoluteRepoDir: string, absoluteRepoDir: string,
sandbox: Sandbox, sandbox: Sandbox,
args: { args: {
githubToken: string;
githubAccessToken: string; githubAccessToken: string;
owner: string; owner: string;
repo: string; repo: string;
}, },
): Promise<void> { ): Promise<void> {
const { githubToken, githubAccessToken, owner, repo } = args; const { githubAccessToken, owner, repo } = args;
let needsGitConfig = false; let needsGitConfig = false;
try { try {
const nameCheck = await sandbox.process.executeCommand( const nameCheck = await sandbox.process.executeCommand(
@ -262,7 +261,7 @@ export async function configureGitUserInRepo(
try { try {
// Set the remote URL with the token using the provided owner and repo // Set the remote URL with the token using the provided owner and repo
const setRemoteOutput = await sandbox.process.executeCommand( 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, absoluteRepoDir,
undefined, undefined,
TIMEOUT_SEC, TIMEOUT_SEC,
@ -493,17 +492,17 @@ export async function createPullRequest({
headBranch, headBranch,
title, title,
body = "", body = "",
githubToken, githubAccessToken,
}: { }: {
owner: string; owner: string;
repo: string; repo: string;
headBranch: string; headBranch: string;
title: string; title: string;
body?: string; body?: string;
githubToken: string; githubAccessToken: string;
}) { }) {
const octokit = new Octokit({ const octokit = new Octokit({
auth: githubToken, auth: githubAccessToken,
}); });
try { try {
@ -535,7 +534,7 @@ export async function createPullRequest({
logger.info( logger.info(
"Pull request already exists. Getting existing pull request...", "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`, { logger.error(`Failed to create pull request`, {
@ -575,7 +574,7 @@ export async function cloneRepo(
sandbox: Sandbox, sandbox: Sandbox,
targetRepository: TargetRepository, targetRepository: TargetRepository,
args: { args: {
githubToken: string; githubAccessToken: string;
stateBranchName?: string; stateBranchName?: string;
}, },
) { ) {
@ -583,7 +582,7 @@ export async function cloneRepo(
const gitCloneCommand = ["git", "clone"]; const gitCloneCommand = ["git", "clone"];
// Use x-access-token format for better GitHub authentication // 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; const branchName = args.stateBranchName || targetRepository.branch;
if (branchName) { if (branchName) {

View file

@ -1,21 +1,15 @@
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
import { GraphConfig } from "@open-swe/shared/open-swe/types"; import { GraphConfig } from "@open-swe/shared/open-swe/types";
export function getGitHubTokensFromConfig(config: GraphConfig): { export function getGitHubTokensFromConfig(config: GraphConfig): {
githubToken: string;
githubAccessToken: string; githubAccessToken: string;
} { } {
if (!config.configurable) { if (!config.configurable) {
throw new Error("No configurable object found in graph config."); throw new Error("No configurable object found in graph config.");
} }
const githubToken = config.configurable["x-github-installation-token"]; const githubAccessToken = config.configurable[GITHUB_TOKEN_COOKIE];
const githubAccessToken = config.configurable["x-github-access-token"];
if (!githubToken) {
throw new Error(
"Missing required x-github-installation-token in configuration.",
);
}
if (!githubAccessToken) { if (!githubAccessToken) {
throw new Error("Missing required x-github-access-token in configuration."); throw new Error("Missing required x-github-access-token in configuration.");
} }
return { githubToken, githubAccessToken }; return { githubAccessToken };
} }

View file

@ -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" NEXT_PUBLIC_ASSISTANT_ID="open-swe"
# For the GitHub OAuth flow # For the GitHub OAuth flow

View file

@ -47,7 +47,7 @@
"framer-motion": "^12.4.9", "framer-motion": "^12.4.9",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"katex": "^0.16.21", "katex": "^0.16.21",
"langgraph-nextjs-api-passthrough": "^0.0.4", "langgraph-nextjs-api-passthrough": "^0.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",

View file

@ -1,11 +1,18 @@
import { initApiPassthrough } from "langgraph-nextjs-api-passthrough"; 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. // 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. // 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 } = export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
initApiPassthrough({ initApiPassthrough({
apiUrl: process.env.LANGGRAPH_API_URL ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGGRAPH_API_URL apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
apiKey: process.env.LANGSMITH_API_KEY ?? "remove-me", // default, if not defined it will attempt to read process.env.LANGSMITH_API_KEY
runtime: "edge", // default runtime: "edge", // default
disableWarningLog: true,
headers: (req) => {
return {
[GITHUB_TOKEN_COOKIE]:
req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "",
};
},
}); });

View file

@ -1,10 +1,10 @@
import { import {
GITHUB_AUTH_STATE_COOKIE, GITHUB_AUTH_STATE_COOKIE,
GITHUB_INSTALLATION_ID_COOKIE, GITHUB_INSTALLATION_ID_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_TOKEN_TYPE_COOKIE, GITHUB_TOKEN_TYPE_COOKIE,
} from "@/lib/auth"; } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@ -93,7 +93,7 @@ export async function GET(request: NextRequest) {
// Set token cookies directly on the response // Set token cookies directly on the response
response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, { response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, {
// httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days maxAge: 60 * 60 * 24 * 30, // 30 days

View file

@ -1,10 +1,10 @@
import { import {
GITHUB_INSTALLATION_RETURN_TO_COOKIE, GITHUB_INSTALLATION_RETURN_TO_COOKIE,
GITHUB_INSTALLATION_STATE_COOKIE, GITHUB_INSTALLATION_STATE_COOKIE,
GITHUB_TOKEN_COOKIE,
} from "@/lib/auth"; } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
/** /**
* Initiates the GitHub App installation flow * Initiates the GitHub App installation flow

View file

@ -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;

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [installationId, setInstallationId] = useState<string | null>(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 (
<div className={`rounded-md border p-4 ${className}`}>
<h3 className="mb-2 text-lg font-medium">GitHub App Not Installed</h3>
<p className="mb-4 text-sm text-gray-600">
You need to install our GitHub App to generate tokens for the AI
agent.
</p>
<InstallAppButton>Install GitHub App</InstallAppButton>
</div>
);
}
if (error) {
return (
<div className={`rounded-md border p-4 ${className}`}>
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
<Button
variant="outline"
onClick={fetchToken}
disabled={isLoading}
>
Try Again
</Button>
</div>
);
}
return (
<div className={`rounded-md border p-4 ${className}`}>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium">GitHub Token for AI Agent</h3>
<Button
variant="outline"
size="sm"
onClick={fetchToken}
disabled={isLoading}
>
<RefreshCwIcon className="mr-2 h-4 w-4" />
Refresh Token
</Button>
</div>
{isLoading ? (
<div className="animate-pulse space-y-3">
<div className="h-4 w-3/4 rounded bg-gray-200"></div>
<div className="h-10 rounded bg-gray-200"></div>
</div>
) : token ? (
<>
<p className="mb-2 text-sm text-gray-600">
This token expires in 1 hour. Use it to authenticate your AI agent
with GitHub.
</p>
<div className="relative">
<div className="mb-2 overflow-x-auto rounded-md border bg-gray-50 p-3 font-mono text-sm whitespace-nowrap">
{token}
</div>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={copyToken}
>
{copied ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
</Button>
</div>
<div className="mt-4 space-y-2">
<p className="text-sm font-medium">How to use this token:</p>
<div className="rounded-md border bg-gray-50 p-3 font-mono text-xs">
{`export GITHUB_TOKEN=${token}`}
</div>
<p className="text-xs text-gray-500">
Pass this token to your agent service to perform Git operations on
behalf of the user.
</p>
</div>
</>
) : (
<p className="text-gray-600">Loading token...</p>
)}
</div>
);
}

View file

@ -20,17 +20,6 @@ interface UseGitHubAppReturn {
defaultBranch: string | null; 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 { export function useGitHubApp(): UseGitHubAppReturn {
const [isInstalled, setIsInstalled] = useState<boolean | null>(null); const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -116,12 +105,6 @@ export function useGitHubApp(): UseGitHubAppReturn {
return; return;
} }
const accessToken = getGitHubAccessToken();
if (!accessToken) {
setBranchesError("GitHub access token not found");
return;
}
setBranchesLoading(true); setBranchesLoading(true);
setBranchesError(null); setBranchesError(null);
@ -129,7 +112,6 @@ export function useGitHubApp(): UseGitHubAppReturn {
const branchData = await getRepositoryBranches( const branchData = await getRepositoryBranches(
selectedRepository.owner, selectedRepository.owner,
selectedRepository.repo, selectedRepository.repo,
accessToken,
); );
setBranches(branchData || []); setBranches(branchData || []);
} catch (err) { } catch (err) {

View file

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; 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_TOKEN_TYPE_COOKIE = "github_token_type";
export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id"; export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id";
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state"; export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";

View file

@ -7,7 +7,6 @@ import React, {
useRef, useRef,
} from "react"; } from "react";
import { useStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk";
import { import {
uiMessageReducer, uiMessageReducer,
isUIMessage, isUIMessage,
@ -53,22 +52,13 @@ const StreamSession = ({
githubToken: string; githubToken: string;
}) => { }) => {
const [threadId, setThreadId] = useQueryState("threadId"); 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({ const streamValue = useTypedStream({
apiUrl, apiUrl,
assistantId, assistantId,
reconnectOnMount: true, reconnectOnMount: true,
threadId: threadId ?? null, threadId: threadId ?? null,
defaultHeaders: {
"x-github-installation-token": githubToken,
"x-github-access-token": githubAccessToken,
},
onCustomEvent: (event, options) => { onCustomEvent: (event, options) => {
if (isUIMessage(event) || isRemoveUIMessage(event)) { if (isUIMessage(event) || isRemoveUIMessage(event)) {
options.mutate((prev) => { options.mutate((prev) => {
@ -179,7 +169,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
checkGitHubAppInstallation(); checkGitHubAppInstallation();
} }
} }
}, [isAuth, githubToken, isTokenLoading]); }, [isAuth, githubToken]);
const checkAuthStatus = async () => { const checkAuthStatus = async () => {
try { try {

View file

@ -1,5 +1,14 @@
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
import * as jwt from "jsonwebtoken"; 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 * Generates a JWT for GitHub App authentication
*/ */
@ -82,18 +91,17 @@ export async function getInstallationRepositories(
export async function getRepositoryBranches( export async function getRepositoryBranches(
owner: string, owner: string,
repo: string, repo: string,
accessToken: string,
): Promise<Branch[]> { ): Promise<Branch[]> {
const allBranches: Branch[] = []; const allBranches: Branch[] = [];
let page = 1; let page = 1;
const perPage = 100; // Maximum allowed by GitHub API const perPage = 100; // Maximum allowed by GitHub API
// First, get repository info to ensure we have the default branch // First, get repository info to ensure we have the default branch
const repoResponse = await fetch( const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`, `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}`,
{ {
headers: { headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent", "User-Agent": "OpenSWE-Agent",
}, },
@ -109,10 +117,9 @@ export async function getRepositoryBranches(
// Fetch all branches with pagination // Fetch all branches with pagination
while (true) { while (true) {
const response = await fetch( 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: { headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent", "User-Agent": "OpenSWE-Agent",
}, },

View file

@ -4,6 +4,9 @@
"open_swe": "./apps/open-swe/src/index.ts:graph" "open_swe": "./apps/open-swe/src/index.ts:graph"
}, },
"env": "./apps/open-swe/.env", "env": "./apps/open-swe/.env",
"dependencies": ["./apps/open-swe"] "dependencies": ["./apps/open-swe"],
"auth": {
"path": "./apps/open-swe/src/security/auth.ts:auth"
}
} }

View file

@ -3,3 +3,6 @@ export const SANDBOX_ROOT_DIR = "/home/daytona";
export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0"; export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0";
export const PLAN_INTERRUPT_DELIMITER = ":::"; export const PLAN_INTERRUPT_DELIMITER = ":::";
export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan"; 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";

View file

@ -11,6 +11,7 @@ import {
type UIMessage, type UIMessage,
type RemoveUIMessage, type RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui"; } from "@langchain/langgraph-sdk/react-ui";
import { GITHUB_TOKEN_COOKIE } from "../constants.js";
export type PlanItem = { export type PlanItem = {
/** /**
@ -285,12 +286,7 @@ export const GraphConfigurationMetadata: {
"The maximum number of tokens to generate in an individual generation", "The maximum number of tokens to generate in an individual generation",
}, },
}, },
"x-github-installation-token": { [GITHUB_TOKEN_COOKIE]: {
x_open_swe_ui_config: {
type: "hidden",
},
},
"x-github-access-token": {
x_open_swe_ui_config: { x_open_swe_ui_config: {
type: "hidden", type: "hidden",
}, },
@ -414,22 +410,13 @@ export const GraphConfiguration = z.object({
.optional() .optional()
.default(() => 10_000) .default(() => 10_000)
.langgraph.metadata(GraphConfigurationMetadata.maxTokens), .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. * 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() .string()
.optional() .optional()
.langgraph.metadata(GraphConfigurationMetadata["x-github-access-token"]), .langgraph.metadata(GraphConfigurationMetadata[GITHUB_TOKEN_COOKIE]),
}); });
export type GraphConfig = LangGraphRunnableConfig< export type GraphConfig = LangGraphRunnableConfig<

View file

@ -2346,7 +2346,7 @@ __metadata:
globals: ^15.14.0 globals: ^15.14.0
jsonwebtoken: ^9.0.2 jsonwebtoken: ^9.0.2
katex: ^0.16.21 katex: ^0.16.21
langgraph-nextjs-api-passthrough: ^0.0.4 langgraph-nextjs-api-passthrough: ^0.1.2
lodash: ^4.17.21 lodash: ^4.17.21
lucide-react: ^0.476.0 lucide-react: ^0.476.0
next: ^15.2.3 next: ^15.2.3
@ -9446,12 +9446,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"langgraph-nextjs-api-passthrough@npm:^0.0.4": "langgraph-nextjs-api-passthrough@npm:^0.1.2":
version: 0.0.4 version: 0.1.2
resolution: "langgraph-nextjs-api-passthrough@npm:0.0.4" resolution: "langgraph-nextjs-api-passthrough@npm:0.1.2"
peerDependencies: peerDependencies:
next: "*" next: "*"
checksum: e9e0f501cd1495c55166ac5840605c75f4e61bdad2ab102ebbffc8f5ef3055c87cf23842d12e889e77d81e00920084d59e8dd4c596fd54494077f2b2cd9e6fb1 checksum: 3ed989353b376e8e7b36cf37181838a439246e19cca1e1f8ae3cc27852ce8dcc7ed86ee62acb6c34055c60aea50a248d7f098012e2993f2b2c8e0f650ce71d23
languageName: node languageName: node
linkType: hard linkType: hard