mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
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:
parent
3dbb2a576d
commit
bf72e1faf7
22 changed files with 315 additions and 253 deletions
15
README.md
15
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/<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"
|
||||
|
||||
```
|
||||
|
|
@ -29,7 +29,7 @@ export async function initialize(
|
|||
state: GraphState,
|
||||
config: GraphConfig,
|
||||
): Promise<GraphUpdate> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
115
apps/open-swe/src/security/auth.ts
Normal file
115
apps/open-swe/src/security/auth.ts
Normal 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 };
|
||||
});
|
||||
57
apps/open-swe/src/security/github-auth.ts
Normal file
57
apps/open-swe/src/security/github-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -214,13 +214,12 @@ export async function configureGitUserInRepo(
|
|||
absoluteRepoDir: string,
|
||||
sandbox: Sandbox,
|
||||
args: {
|
||||
githubToken: string;
|
||||
githubAccessToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
apps/web/src/app/api/github/proxy/[..._path]/route.ts
Normal file
79
apps/web/src/app/api/github/proxy/[..._path]/route.ts
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<boolean | null>(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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Branch[]> {
|
||||
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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
10
yarn.lock
10
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue