feat(agent-generated): Add github oauth (#63)

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* format and lint

* fix ui

* Apply patch (#68)

Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com>

* fix: Github auth

* update readme

* cr

* cr

* cr

* cr

---------

Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com>
Co-authored-by: Harrison Chase <hw.chase.17@gmail.com>
This commit is contained in:
Brace Sproul 2025-06-01 18:30:29 -07:00 • committed by GitHub
parent afbedeec0d
commit 887fc030a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2037 additions and 186 deletions

View file

@ -45,30 +45,53 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox. # Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY="" DAYTONA_API_KEY=""
# Your GitHub PAT with access to the repositories you want to modify.
GITHUB_PAT=""
``` ```
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 NEXT_PUBLIC_API_URL=http://localhost:2024 # Change to production URL when deployed
NEXT_PUBLIC_ASSISTANT_ID=open-swe NEXT_PUBLIC_ASSISTANT_ID="open-swe"
# For the GitHub OAuth flow
GITHUB_APP_CLIENT_ID=""
GITHUB_APP_CLIENT_SECRET=""
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
GITHUB_APP_NAME="open-swe-dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""
``` ```
To generate the GitHub personal access token, you should: To get the GitHub App secrets, first create a new GitHub app (note: this is not the same as the OAuth app) in [the developer settings](https://github.com/settings/apps/new).
1. Go to [GitHub settings](https://github.com/settings/personal-access-tokens) Give the app a name and description.
2. Click on `Generate new token` to generate a new fine grained token.
3. Give the token a name & description. Under `Callback URL`, set it to: `http://localhost:3000/api/auth/github/callback` for local development. Then, uncheck `Expire user authorization tokens`, and check `Request user authorization (OAuth) during installation`.
4. Choose `Only select repositories`, and select the repositories you want to give Open SWE access to.
5. Under `Permissions`, give it `Repository permission`: Under `Post installation`, check `Redirect on update`.
- `Contents` - `Read and write`
- `Metadata` - `Read-only` (should be auto enabled after selecting `Contents`) Under `Webhook`, uncheck `Active`.
- `Pull requests` - `Read and write`
6. Click `Generate token` & copy the token. Under `Repository permissions`, give the app the following permissions:
7. Paste the token into the `GITHUB_PAT` variable in the agent `.env` file.
- `Contents` - `Read & Write`
- `Metadata` - `Read & Write`
- `Pull requests` - `Read & Write`
- `Issues` - `Read & Write`
Finally, under `Where can this GitHub App be installed?` ensure `Any account` is selected.
After creating the app, you will be taken to the app's settings page. Copy/generate the following fields for your environment variables:
`App ID` - `GITHUB_APP_ID`
`Client ID` - `GITHUB_APP_CLIENT_ID`
`Client secrets` - Generate a new secret key, and set it under `GITHUB_APP_CLIENT_SECRET`
Scroll down to `Private keys`, and generate a new private key. This will download a file. Set the contents of this file under `GITHUB_APP_PRIVATE_KEY`.
Set `GITHUB_APP_REDIRECT_URI` to `http://localhost:3000/api/auth/github/callback` for local development.
Set `GITHUB_APP_NAME` to the name of your app.
That's it! You can now authenticate users with GitHub, and generate tokens for them.
## Running the graph ## Running the graph
@ -114,3 +137,4 @@ 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.

View file

@ -11,6 +11,3 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox. # Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY="" DAYTONA_API_KEY=""
# Your GitHub PAT with access to the repositories you want to modify.
GITHUB_PAT=""

View file

@ -1,7 +1,3 @@
export const TIMEOUT_MS = 300_000; // 5 minutes export const TIMEOUT_SEC = 60; // 1 minute
// Always extend timeouts by 5 min after each command execution.
export const TIMEOUT_EXTENSION_OPT = {
timeoutMs: TIMEOUT_MS,
};
export const SANDBOX_ROOT_DIR = "/home/daytona"; 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";

View file

@ -5,7 +5,7 @@ import {
applyPatchTool, applyPatchTool,
requestHumanHelpTool, requestHumanHelpTool,
} from "../tools/index.js"; } from "../tools/index.js";
import { getRepoAbsolutePath } from "../utils/git/index.js"; import { getRepoAbsolutePath } from "../utils/git.js";
import { formatPlanPrompt } from "../utils/plan-prompt.js"; import { formatPlanPrompt } from "../utils/plan-prompt.js";
import { stopSandbox } from "../utils/sandbox.js"; import { stopSandbox } from "../utils/sandbox.js";
import { createLogger, LogLevel } from "../utils/logger.js"; import { createLogger, LogLevel } from "../utils/logger.js";

View file

@ -1,51 +1,19 @@
import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "../utils/logger.js"; import { createLogger, LogLevel } from "../utils/logger.js";
import { import { GraphState, GraphConfig, GraphUpdate } from "../types.js";
GraphState,
GraphConfig,
GraphUpdate,
TargetRepository,
} from "../types.js";
import { import {
checkoutBranch, checkoutBranch,
cloneRepo,
configureGitUserInRepo, configureGitUserInRepo,
getBranchName, getBranchName,
getRepoAbsolutePath, getRepoAbsolutePath,
pullLatestChanges, pullLatestChanges,
} from "../utils/git/index.js"; } from "../utils/git.js";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
import { daytonaClient } from "../utils/sandbox.js"; import { daytonaClient } from "../utils/sandbox.js";
import { SNAPSHOT_NAME } from "../constants.js"; import { SNAPSHOT_NAME } from "../constants.js";
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
const logger = createLogger(LogLevel.INFO, "Initialize"); const logger = createLogger(LogLevel.INFO, "Initialize");
async function cloneRepo(sandbox: Sandbox, targetRepository: TargetRepository) {
if (!process.env.GITHUB_PAT) {
throw new Error("GITHUB_PAT environment variable not set.");
}
try {
const gitCloneCommand = ["git", "clone"];
const repoUrlWithToken = `https://${process.env.GITHUB_PAT}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
if (targetRepository.branch) {
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
} else {
gitCloneCommand.push(repoUrlWithToken);
}
logger.info("Cloning repository", {
command: gitCloneCommand.join(" "),
});
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
} catch (e) {
const errorFields = getSandboxErrorFields(e);
logger.error("Failed to clone repository", errorFields ?? e);
throw e;
}
}
/** /**
* Initializes the session. This ensures there's an active VM session, and that * Initializes the session. This ensures there's an active VM session, and that
* the proper credentials are provided for taking actions on GitHub. * the proper credentials are provided for taking actions on GitHub.
@ -56,16 +24,8 @@ export async function initialize(
state: GraphState, state: GraphState,
config: GraphConfig, config: GraphConfig,
): Promise<GraphUpdate> { ): Promise<GraphUpdate> {
if (!config.configurable) { const { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config);
throw new Error("Configuration object not found."); const { sandboxSessionId, targetRepository } = state;
}
const { sandboxSessionId } = state;
const { targetRepository } = state;
if (!targetRepository) {
throw new Error(
"Missing required target repository. Please provide a git repository in state or configuration.",
);
}
const absoluteRepoDir = getRepoAbsolutePath(targetRepository); const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
if (sandboxSessionId) { if (sandboxSessionId) {
@ -90,7 +50,7 @@ export async function initialize(
image: SNAPSHOT_NAME, image: SNAPSHOT_NAME,
}); });
const res = await cloneRepo(sandbox, targetRepository); const res = await cloneRepo(sandbox, targetRepository, { githubToken });
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
// TODO: This should probably be an interrupt. // TODO: This should probably be an interrupt.
logger.error("Failed to clone repository", res.result); logger.error("Failed to clone repository", res.result);
@ -99,7 +59,12 @@ export async function initialize(
logger.info("Repository cloned successfully."); logger.info("Repository cloned successfully.");
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,
owner: targetRepository.owner,
repo: targetRepository.repo,
});
logger.info("Git user configured successfully."); logger.info("Git user configured successfully.");
const checkoutBranchRes = await checkoutBranch( const checkoutBranchRes = await checkoutBranch(

View file

@ -5,7 +5,7 @@ import {
getBranchName, getBranchName,
getChangedFilesStatus, getChangedFilesStatus,
getRepoAbsolutePath, getRepoAbsolutePath,
} from "../utils/git/index.js"; } from "../utils/git.js";
import { createLogger, LogLevel } from "../utils/logger.js"; import { createLogger, LogLevel } from "../utils/logger.js";
import { z } from "zod"; import { z } from "zod";
import { loadModel, Task } from "../utils/load-model.js"; import { loadModel, Task } from "../utils/load-model.js";
@ -13,6 +13,7 @@ import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js";
import { isHumanMessage, ToolMessage } from "@langchain/core/messages"; import { isHumanMessage, ToolMessage } from "@langchain/core/messages";
import { getMessageContentString } from "../utils/message/content.js"; import { getMessageContentString } from "../utils/message/content.js";
import { daytonaClient } from "../utils/sandbox.js"; import { daytonaClient } from "../utils/sandbox.js";
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
const logger = createLogger(LogLevel.INFO, "Open PR"); const logger = createLogger(LogLevel.INFO, "Open PR");
@ -65,6 +66,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 sandbox = await daytonaClient().get(sandboxSessionId); const sandbox = await daytonaClient().get(sandboxSessionId);
@ -133,6 +135,7 @@ export async function openPullRequest(
headBranch: branchName ?? getBranchName(config), headBranch: branchName ?? getBranchName(config),
title, title,
body, body,
githubToken,
}); });
return { return {

View file

@ -10,7 +10,7 @@ import {
checkoutBranchAndCommit, checkoutBranchAndCommit,
getChangedFilesStatus, getChangedFilesStatus,
getRepoAbsolutePath, getRepoAbsolutePath,
} from "../utils/git/index.js"; } from "../utils/git.js";
import { import {
formatBadArgsError, formatBadArgsError,
zodSchemaToString, zodSchemaToString,

View file

@ -361,6 +361,28 @@ export const GraphConfiguration = z.object({
"The maximum number of tokens to generate in an individual generation", "The maximum number of tokens to generate in an individual generation",
}, },
}), }),
/**
* 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({
x_oap_ui_config: {
type: "hidden",
},
}),
/**
* The user's GitHub access token. To be used in requests to get information about the user.
*/
"x-github-access-token": z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "hidden",
},
}),
}); });
export type GraphConfig = LangGraphRunnableConfig< export type GraphConfig = LangGraphRunnableConfig<

View file

@ -1,9 +1,9 @@
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { Sandbox } from "@daytonaio/sdk"; import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "../logger.js"; import { createLogger, LogLevel } from "./logger.js";
import { GraphConfig, TargetRepository } from "../../types.js"; import { GraphConfig, TargetRepository } from "../types.js";
import { TIMEOUT_MS, SANDBOX_ROOT_DIR } from "../../constants.js"; import { TIMEOUT_SEC, SANDBOX_ROOT_DIR } from "../constants.js";
import { getSandboxErrorFields } from "../sandbox-error-fields.js"; import { getSandboxErrorFields } from "./sandbox-error-fields.js";
import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js"; import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js";
const logger = createLogger(LogLevel.INFO, "GitUtil"); const logger = createLogger(LogLevel.INFO, "GitUtil");
@ -40,7 +40,7 @@ export async function checkoutBranch(
"git branch --show-current", "git branch --show-current",
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (getCurrentBranchOutput.exitCode !== 0) { if (getCurrentBranchOutput.exitCode !== 0) {
@ -80,7 +80,7 @@ export async function checkoutBranch(
`git rev-parse --verify --quiet "refs/heads/${branchName}"`, `git rev-parse --verify --quiet "refs/heads/${branchName}"`,
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (checkBranchExistsOutput.exitCode === 0) { if (checkBranchExistsOutput.exitCode === 0) {
@ -116,7 +116,7 @@ export async function checkoutBranch(
checkoutCommand, checkoutCommand,
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (gitCheckoutOutput.exitCode !== 0) { if (gitCheckoutOutput.exitCode !== 0) {
@ -152,29 +152,24 @@ interface GitHubUserResponse {
email: string | null; email: string | null;
} }
async function getGitUserDetailsFromGitHub(): Promise<{ async function getGitUserDetailsFromGitHub(githubToken: string): Promise<{
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
}> { }> {
const githubToken = process.env.GITHUB_PAT;
if (!githubToken) {
logger.warn(
"GITHUB_PAT environment variable is not set. Cannot fetch user details from GitHub.",
);
return {};
}
try { try {
// Try with Bearer token first (for GitHub App installation tokens)
const response = await fetch("https://api.github.com/user", { const response = await fetch("https://api.github.com/user", {
headers: { headers: {
Authorization: `token ${githubToken}`, Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE",
}, },
}); });
if (!response.ok) { if (!response.ok) {
logger.error(`Failed to fetch GitHub user info`, { logger.error(`Failed to fetch GitHub user info`, {
response, status: response.status,
statusText: response.statusText,
}); });
return {}; return {};
} }
@ -198,6 +193,10 @@ async function getGitUserDetailsFromGitHub(): Promise<{
if (!finalUserEmail) { if (!finalUserEmail) {
logger.warn("Could not determine GitHub user email from API response."); logger.warn("Could not determine GitHub user email from API response.");
} }
logger.info("Successfully fetched GitHub user info", {
userName: finalUserName,
userEmail: finalUserEmail,
});
return { userName: finalUserName, userEmail: finalUserEmail }; return { userName: finalUserName, userEmail: finalUserEmail };
} catch (e) { } catch (e) {
logger.error(`Error fetching GitHub user info`, { logger.error(`Error fetching GitHub user info`, {
@ -214,20 +213,27 @@ async function getGitUserDetailsFromGitHub(): Promise<{
export async function configureGitUserInRepo( export async function configureGitUserInRepo(
absoluteRepoDir: string, absoluteRepoDir: string,
sandbox: Sandbox, sandbox: Sandbox,
args: {
githubToken: string;
githubAccessToken: string;
owner: string;
repo: string;
},
): Promise<void> { ): Promise<void> {
const { githubToken, githubAccessToken, owner, repo } = args;
let needsGitConfig = false; let needsGitConfig = false;
try { try {
const nameCheck = await sandbox.process.executeCommand( const nameCheck = await sandbox.process.executeCommand(
"git config user.name", "git config user.name",
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
const emailCheck = await sandbox.process.executeCommand( const emailCheck = await sandbox.process.executeCommand(
"git config user.email", "git config user.email",
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if ( if (
@ -249,39 +255,70 @@ export async function configureGitUserInRepo(
needsGitConfig = true; needsGitConfig = true;
} }
if (needsGitConfig) { // Configure git to use the token for authentication with GitHub by updating the remote URL
const { userName, userEmail } = await getGitUserDetailsFromGitHub(); logger.info(
"Configuring git to use token for GitHub authentication via remote URL...",
);
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`,
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
if (userName) { if (setRemoteOutput.exitCode !== 0) {
const configUserNameOutput = await sandbox.process.executeCommand( logger.error(`Failed to set remote URL with token`, {
`git config user.name "${userName}"`, setRemoteOutput,
absoluteRepoDir, });
undefined, } else {
TIMEOUT_MS, logger.info("Git remote URL updated with token successfully.");
); }
if (configUserNameOutput.exitCode !== 0) { } catch (authError) {
logger.error(`Failed to set git user.name`, { logger.error(`Error configuring git authentication for GitHub`, {
configUserNameOutput, ...(authError instanceof Error && {
}); name: authError.name,
} else { message: authError.message,
logger.info(`Set git user.name to '${userName}' successfully.`); stack: authError.stack,
} }),
});
}
if (needsGitConfig) {
const { userName, userEmail } =
await getGitUserDetailsFromGitHub(githubAccessToken);
// Set user name - use fetched name or fallback to "GitHub App User"
const nameToUse = userName || "GitHub App User";
const configUserNameOutput = await sandbox.process.executeCommand(
`git config user.name "${nameToUse}"`,
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
if (configUserNameOutput.exitCode !== 0) {
logger.error(`Failed to set git user.name`, {
configUserNameOutput,
});
} else {
logger.info(`Set git user.name to '${nameToUse}' successfully.`);
} }
if (userEmail) { // Set user email - use fetched email or fallback to a generic noreply address
const configUserEmailOutput = await sandbox.process.executeCommand( const emailToUse = userEmail || "open-swe-bot@noreply.github.com";
`git config user.email "${userEmail}"`, const configUserEmailOutput = await sandbox.process.executeCommand(
absoluteRepoDir, `git config user.email "${emailToUse}"`,
undefined, absoluteRepoDir,
TIMEOUT_MS, undefined,
); TIMEOUT_SEC,
if (configUserEmailOutput.exitCode !== 0) { );
logger.error(`Failed to set git user.email`, { if (configUserEmailOutput.exitCode !== 0) {
configUserEmailOutput, logger.error(`Failed to set git user.email`, {
}); configUserEmailOutput,
} else { });
logger.info(`Set git user.email to '${userEmail}' successfully.`); } else {
} logger.info(`Set git user.email to '${emailToUse}' successfully.`);
} }
} else { } else {
logger.info( logger.info(
@ -300,7 +337,7 @@ export async function commitAll(
`git add -A && git commit -m "${message}"`, `git add -A && git commit -m "${message}"`,
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (gitAddOutput.exitCode !== 0) { if (gitAddOutput.exitCode !== 0) {
@ -330,7 +367,9 @@ export async function commitAllAndPush(
): Promise<ExecuteResponse | false> { ): Promise<ExecuteResponse | false> {
try { try {
const commitOutput = await commitAll(absoluteRepoDir, message, sandbox); const commitOutput = await commitAll(absoluteRepoDir, message, sandbox);
logger.info(
"Committed changes to git repository successfully. Now pushing...",
);
const pushCurrentBranchCmd = const pushCurrentBranchCmd =
"git push -u origin $(git rev-parse --abbrev-ref HEAD)"; "git push -u origin $(git rev-parse --abbrev-ref HEAD)";
@ -342,7 +381,7 @@ export async function commitAllAndPush(
pushCurrentBranchCmd, pushCurrentBranchCmd,
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (gitPushOutput.exitCode !== 0) { if (gitPushOutput.exitCode !== 0) {
@ -375,7 +414,7 @@ export async function getChangedFilesStatus(
"git status --porcelain", "git status --porcelain",
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
if (gitStatusOutput.exitCode !== 0) { if (gitStatusOutput.exitCode !== 0) {
@ -418,21 +457,17 @@ export async function createPullRequest({
headBranch, headBranch,
title, title,
body = "", body = "",
githubToken,
}: { }: {
owner: string; owner: string;
repo: string; repo: string;
headBranch: string; headBranch: string;
title: string; title: string;
body?: string; body?: string;
githubToken: string;
}) { }) {
// Initialize Octokit with the personal access token
const token = process.env.GITHUB_PAT;
if (!token) {
throw new Error("GITHUB_PAT environment variable is not set");
}
const octokit = new Octokit({ const octokit = new Octokit({
auth: token, auth: githubToken,
}); });
try { try {
@ -476,7 +511,7 @@ export async function pullLatestChanges(
"git pull", "git pull",
absoluteRepoDir, absoluteRepoDir,
undefined, undefined,
TIMEOUT_MS, TIMEOUT_SEC,
); );
return gitPullOutput; return gitPullOutput;
} catch (e) { } catch (e) {
@ -492,3 +527,35 @@ export async function pullLatestChanges(
return false; return false;
} }
} }
export async function cloneRepo(
sandbox: Sandbox,
targetRepository: TargetRepository,
args: {
githubToken: string;
},
) {
try {
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`;
if (targetRepository.branch) {
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
} else {
gitCloneCommand.push(repoUrlWithToken);
}
logger.info("Cloning repository", {
// Don't log the full command with token for security reasons
repoPath: `${targetRepository.owner}/${targetRepository.repo}`,
branch: targetRepository.branch || "default",
});
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
} catch (e) {
const errorFields = getSandboxErrorFields(e);
logger.error("Failed to clone repository", errorFields ?? e);
throw e;
}
}

View file

@ -0,0 +1,21 @@
import { GraphConfig } from "../types.js";
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.",
);
}
if (!githubAccessToken) {
throw new Error("Missing required x-github-access-token in configuration.");
}
return { githubToken, githubAccessToken };
}

View file

@ -1,13 +1,11 @@
# LangGraph Configuration NEXT_PUBLIC_API_URL="http://localhost:2024"
NEXT_PUBLIC_API_URL=http://localhost:2024 NEXT_PUBLIC_ASSISTANT_ID="open-swe"
NEXT_PUBLIC_ASSISTANT_ID=agent
# Do NOT prefix this with "NEXT_PUBLIC_" as we do not want this exposed in the client.
LANGSMITH_API_KEY=
# Production LangGraph Configuration (quickstart) - Uncomment to use # For the GitHub OAuth flow
# NEXT_PUBLIC_ASSISTANT_ID="agent" GITHUB_APP_CLIENT_ID=""
# This should be the deployment URL of your LangGraph server GITHUB_APP_CLIENT_SECRET=""
# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app" GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
# This should be the URL of your website + "/api". This is how you connect to the API proxy
# NEXT_PUBLIC_API_URL="https://my-website.com/api" GITHUB_APP_NAME="Open SWE Dev"
# LANGSMITH_API_KEY="lsv2_..." GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""

View file

@ -38,6 +38,7 @@
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"esbuild-plugin-tailwindcss": "^2.0.1", "esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9", "framer-motion": "^12.4.9",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.21", "katex": "^0.16.21",
"langgraph-nextjs-api-passthrough": "^0.0.4", "langgraph-nextjs-api-passthrough": "^0.0.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -62,6 +63,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@tailwindcss/postcss": "^4.0.13", "@tailwindcss/postcss": "^4.0.13",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",

View file

@ -0,0 +1,133 @@
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";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const installationId = searchParams.get("installation_id");
// Handle GitHub App errors
if (error) {
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(error)}`, request.url),
);
}
// Validate required parameters
if (!code) {
return NextResponse.redirect(
new URL("/?error=missing_code_parameter", request.url),
);
}
// Verify state parameter to prevent CSRF attacks
const storedState = request.cookies.get(GITHUB_AUTH_STATE_COOKIE)?.value;
if (storedState && state !== storedState) {
return NextResponse.redirect(
new URL("/?error=invalid_state", request.url),
);
}
const clientId = process.env.GITHUB_APP_CLIENT_ID;
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
return NextResponse.redirect(
new URL("/?error=configuration_missing", request.url),
);
}
// Exchange authorization code for access token
const tokenResponse = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: redirectUri,
}),
},
);
if (!tokenResponse.ok) {
console.error("Token exchange failed:", await tokenResponse.text());
return NextResponse.redirect(
new URL("/?error=token_exchange_failed", request.url),
);
}
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(tokenData.error)}`, request.url),
);
}
// Create the success response
const response = NextResponse.redirect(
new URL("/?auth=success", request.url),
);
// Clear the state cookie as it's no longer needed
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, "", {
expires: new Date(0),
path: "/",
});
// Set token cookies directly on the response
response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, {
// httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
response.cookies.set(
GITHUB_TOKEN_TYPE_COOKIE,
tokenData.token_type || "bearer",
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
},
);
// If there's an installation_id, store that as well for future API calls
if (installationId) {
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
}
return response;
} catch (error) {
console.error("GitHub App callback error:", error);
return NextResponse.redirect(
new URL("/?error=callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,45 @@
import { GITHUB_AUTH_STATE_COOKIE } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
export async function GET(_request: NextRequest) {
try {
const clientId = process.env.GITHUB_APP_CLIENT_ID;
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
if (!clientId || !redirectUri) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Generate a random state parameter for security
const state = crypto.randomUUID();
// Build the GitHub App authorization URL
const authUrl = new URL("https://github.com/login/oauth/authorize");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("allow_signup", "true");
authUrl.searchParams.set("state", state);
// Create response with redirect and store state in a cookie
const response = NextResponse.redirect(authUrl.toString());
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10, // 10 minutes
path: "/",
});
return response;
} catch (error) {
console.error("GitHub App login error:", error);
return NextResponse.json(
{ error: "Failed to initiate GitHub App authentication flow" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { clearGitHubToken } from "@/lib/auth";
/**
* API route to handle GitHub logout
*/
export async function POST(request: NextRequest) {
try {
const response = NextResponse.json({ success: true });
clearGitHubToken(response);
return response;
} catch (error) {
console.error("Error during logout:", error);
return NextResponse.json(
{ success: false, error: "Failed to logout" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/lib/auth";
/**
* API route to check GitHub authentication status
*/
export async function GET(request: NextRequest) {
try {
const authenticated = isAuthenticated(request);
return NextResponse.json({ authenticated });
} catch (error) {
console.error("Error checking auth status:", error);
return NextResponse.json(
{ authenticated: false, error: "Failed to check authentication status" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,73 @@
import {
GITHUB_INSTALLATION_ID_COOKIE,
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
GITHUB_INSTALLATION_STATE_COOKIE,
} from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
/**
* Handles callbacks from GitHub App installations
* This endpoint is called by GitHub after a user installs or configures the GitHub App
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
// Get the return URL from cookies
const returnTo =
request.cookies.get(GITHUB_INSTALLATION_RETURN_TO_COOKIE)?.value || "/";
// Verify state parameter to prevent CSRF attacks
// GitHub App installation doesn't return the state directly, but we included it in our callback URL
const customState = searchParams.get("custom_state");
const storedState = request.cookies.get(
GITHUB_INSTALLATION_STATE_COOKIE,
)?.value;
// Validate state if it exists
if (storedState && customState && storedState !== customState) {
console.warn("Invalid installation state detected");
// We'll still proceed but log the warning
}
// Create the response that will redirect back to the app
const response = NextResponse.redirect(returnTo);
// Clear cookies as they're no longer needed
const expiredCookieOptions = {
expires: new Date(0),
path: "/",
};
response.cookies.set(
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
"",
expiredCookieOptions,
);
response.cookies.set(
GITHUB_INSTALLATION_STATE_COOKIE,
"",
expiredCookieOptions,
);
// If we have an installation ID, store it in a cookie
if (installationId) {
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
}
return response;
} catch (error) {
console.error("GitHub App installation callback error:", error);
return NextResponse.redirect(
new URL("/?error=installation_callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,40 @@
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
/**
* Handles the callback from GitHub App installation
* After a user installs the app and selects repositories, GitHub redirects here
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
if (!installationId) {
return NextResponse.redirect(
new URL("/?error=missing_installation_id", request.url),
);
}
// Store the installation ID in a cookie for future API calls
const response = NextResponse.redirect(
new URL("/?installation=success", request.url),
);
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
return response;
} catch (error) {
console.error("GitHub App installation callback error:", error);
return NextResponse.redirect(
new URL("/?error=installation_callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,84 @@
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";
/**
* Initiates the GitHub App installation flow
* This redirects users to the GitHub App installation page where they can
* select which repositories to grant access to
*/
export async function GET(request: NextRequest) {
try {
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
if (!accessToken) {
return NextResponse.json(
{ error: "GitHub access token not found" },
{ status: 401 },
);
}
// Get GitHub App name from environment variables
const githubAppName = process.env.GITHUB_APP_NAME;
if (!githubAppName) {
return NextResponse.json(
{ error: "GitHub App name not configured" },
{ status: 500 },
);
}
// Check for existing state or generate a new one
let state = request.cookies.get(GITHUB_INSTALLATION_STATE_COOKIE)?.value;
// If no state exists or we want to ensure a fresh state, generate a new one
if (!state) {
state = randomBytes(16).toString("hex");
}
// Create a response that will redirect to the GitHub App installation page
// Include the callback URL as a parameter to ensure GitHub redirects back to our app
// Add the state as a custom parameter in the callback URL
const baseCallbackUrl = `${request.nextUrl.origin}/api/github/installation-callback`;
const callbackUrl = `${baseCallbackUrl}?custom_state=${encodeURIComponent(state)}`;
const response = NextResponse.redirect(
`https://github.com/apps/${githubAppName}/installations/new?redirect_uri=${encodeURIComponent(callbackUrl)}`,
);
// Cookie options for security and proper expiration
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 60 * 10, // 10 minutes
path: "/",
};
// Store the state in a cookie for validation when GitHub redirects back
response.cookies.set(
GITHUB_INSTALLATION_STATE_COOKIE,
state,
cookieOptions,
);
// Store the current URL as the return_to URL so we can redirect back after installation
const returnTo = request.headers.get("referer") || "/";
response.cookies.set(
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
returnTo,
cookieOptions,
);
return response;
} catch (error) {
console.error("Error initiating GitHub App installation:", error);
return NextResponse.json(
{ error: "Failed to initiate GitHub App installation" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import {
getInstallationToken,
getInstallationRepositories,
Repository,
} from "@/utils/github";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
/**
* Fetches repositories accessible to the GitHub App installation
* Requires a valid GitHub installation ID in the cookies
*/
export async function GET(request: NextRequest) {
try {
// Get the installation ID from cookies
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!installationId) {
return NextResponse.json(
{
error:
"GitHub installation ID not found. Please install the app first.",
},
{ status: 401 },
);
}
// Get GitHub App credentials from environment variables
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
/\\n/g,
"\n",
);
if (!appId || !privateKey) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Get an installation access token
let installationToken: string;
try {
installationToken = await getInstallationToken(
installationId,
appId,
privateKey,
);
} catch (error) {
console.error("Failed to get installation token:", error);
return NextResponse.json(
{ error: "Failed to get installation token" },
{ status: 401 },
);
}
// Fetch repositories accessible to this installation
let repositories: Repository[];
try {
repositories = await getInstallationRepositories(installationToken);
} catch (error) {
console.error("Failed to fetch repositories:", error);
return NextResponse.json(
{ error: "Failed to fetch repositories" },
{ status: 500 },
);
}
// Transform the response to include only the data we need
const transformedRepos = repositories.map((repo) => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description,
private: repo.private,
html_url: repo.html_url,
default_branch: repo.default_branch,
permissions: repo.permissions,
}));
return NextResponse.json({ repositories: transformedRepos });
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
return NextResponse.json(
{ error: "Failed to fetch repositories" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { getInstallationToken } from "@/utils/github";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
/**
* Returns a GitHub installation token that can be used for Git operations
* This endpoint is intended for internal use by the AI coding agent
*/
export async function GET(request: NextRequest) {
try {
// Get the installation ID from cookies
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!installationId) {
return NextResponse.json(
{
error:
"GitHub installation ID not found. Please install the app first.",
},
{ status: 401 },
);
}
// Get GitHub App credentials from environment variables
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
/\\n/g,
"\n",
);
if (!appId || !privateKey) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Get an installation access token
try {
const installationToken = await getInstallationToken(
installationId,
appId,
privateKey,
);
return NextResponse.json({
token: installationToken,
installation_id: installationId,
});
} catch (error) {
console.error("Failed to get installation token:", error);
return NextResponse.json(
{ error: "Failed to get installation token" },
{ status: 401 },
);
}
} catch (error) {
console.error("Error generating GitHub token:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,156 @@
"use client";
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
export default function GitHubPage() {
const [repositories, setRepositories] = useState<Repository[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const checkInstallation = async () => {
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
setIsInstalled(true);
const data = await response.json();
setRepositories(data.repositories || []);
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setIsInstalled(false);
} else {
setError(errorData.error);
}
}
} catch (err) {
setError("Failed to check installation status");
}
};
checkInstallation();
}, []);
const handleInstall = async () => {
window.location.href = "/api/github/installation";
};
const handleRefreshRepositories = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
const data = await response.json();
setRepositories(data.repositories || []);
} else {
const errorData = await response.json();
setError(errorData.error);
}
} catch (err) {
setError("Failed to fetch repositories");
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">GitHub Repository Access</h1>
{error && (
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
{error}
</div>
)}
{!isInstalled ? (
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-xl font-semibold">Install GitHub App</h2>
<p className="mb-4">
To access your GitHub repositories, you need to install our GitHub
App and grant it access to the repositories you want to use.
</p>
<button
onClick={handleInstall}
className="rounded bg-black px-4 py-2 text-white hover:bg-gray-800"
>
Install GitHub App
</button>
</div>
) : (
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Your Repositories</h2>
<button
onClick={handleRefreshRepositories}
disabled={loading}
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300 disabled:opacity-50"
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
{repositories.length === 0 ? (
<p>
No repositories found. Make sure you've granted access to at least
one repository.
</p>
) : (
<ul className="divide-y">
{repositories.map((repo) => (
<li
key={repo.id}
className="py-4"
>
<div className="flex items-start">
<div>
<h3 className="font-medium">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{repo.full_name}
</a>
</h3>
<p className="mt-1 text-sm text-gray-600">
{repo.description || "No description"}
</p>
<div className="mt-2 flex items-center text-sm">
<span className="mr-4">
{repo.private ? "Private" : "Public"}
</span>
<span>Default branch: {repo.default_branch}</span>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)}
<div className="rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-xl font-semibold">Manage GitHub App</h2>
<p className="mb-4">
You can manage your GitHub App installation, including adding or
removing repositories, through GitHub.
</p>
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
>
Manage on GitHub
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
export function GitHubOAuthButton() {
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
setIsAuth(data.authenticated);
} catch (error) {
console.error("Error checking auth status:", error);
setIsAuth(false);
}
};
const handleLogin = () => {
setIsLoading(true);
window.location.href = "/api/auth/github/login";
};
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
setIsAuth(false);
} else {
console.error("Logout failed");
}
} catch (error) {
console.error("Error during logout:", error);
} finally {
setIsLoading(false);
}
};
if (isAuth === null) {
return (
<Button
variant="outline"
disabled
>
<GitHubSVG
width="16"
height="16"
/>
Checking...
</Button>
);
}
if (isAuth) {
return (
<Button
variant="outline"
onClick={handleLogout}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Disconnecting..." : "Disconnect GitHub"}
</Button>
);
}
return (
<Button
variant="outline"
onClick={handleLogin}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Connecting..." : "Connect GitHub"}
</Button>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CopyIcon, CheckIcon, RefreshCwIcon } from "lucide-react";
import { InstallAppButton } from "./InstallAppButton";
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 (err) {
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

@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
interface InstallAppButtonProps {
variant?:
| "default"
| "outline"
| "secondary"
| "ghost"
| "link"
| "destructive";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
children?: React.ReactNode;
}
export function InstallAppButton({
variant = "default",
size = "default",
className = "",
children,
}: InstallAppButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleInstall = () => {
setIsLoading(true);
window.location.href = "/api/github/installation";
};
return (
<Button
variant={variant}
size={size}
className={className}
onClick={handleInstall}
disabled={isLoading}
>
{isLoading ? (
"Installing..."
) : (
<>
<GitHubSVG
width="16"
height="16"
/>
{children || "Install GitHub App"}
</>
)}
</Button>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { InstallAppButton } from "./InstallAppButton";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
interface RepositoryListProps {
className?: string;
}
export function RepositoryList({ className = "" }: RepositoryListProps) {
const { isInstalled, isLoading, error, repositories, refreshRepositories } =
useGitHubApp();
if (isLoading) {
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 h-6 w-24 animate-pulse rounded bg-gray-200"></div>
{[1, 2, 3].map((i) => (
<div
key={i}
className="mb-4 rounded-md border p-4"
>
<div className="mb-2 h-5 w-48 animate-pulse rounded bg-gray-200"></div>
<div className="h-4 w-64 animate-pulse rounded bg-gray-100"></div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className={`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={refreshRepositories}
>
Try Again
</Button>
</div>
);
}
if (!isInstalled) {
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
<h3 className="mb-2 font-medium">GitHub App Not Installed</h3>
<p className="mb-4 text-sm text-amber-800">
You need to install our GitHub App to grant access to your
repositories.
</p>
<InstallAppButton>Install GitHub App</InstallAppButton>
</div>
</div>
);
}
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-medium">Your GitHub Repositories</h2>
<Button
variant="outline"
size="sm"
onClick={refreshRepositories}
disabled={isLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{repositories.length === 0 ? (
<div className="rounded-md border bg-gray-50 p-4 text-center">
<p className="mb-4 text-gray-600">
No repositories found. You may need to grant access to repositories
in GitHub.
</p>
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Manage GitHub App permissions
</a>
</div>
) : (
<div className="space-y-3">
{repositories.map((repo) => (
<div
key={repo.id}
className="rounded-md border p-4 transition-colors hover:bg-gray-50"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{repo.full_name}
</a>
</h3>
<p className="mt-1 text-sm text-gray-600">
{repo.description || "No description"}
</p>
<div className="mt-2 flex items-center text-xs">
<span
className={`rounded-full px-2 py-0.5 ${repo.private ? "bg-gray-100" : "bg-green-100 text-green-800"} mr-2`}
>
{repo.private ? "Private" : "Public"}
</span>
<span className="text-gray-500">
Default branch: {repo.default_branch}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4 border-t pt-4 text-sm text-gray-500">
<p>
Need to add or remove repositories?{" "}
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Manage on GitHub
</a>
</p>
</div>
</div>
);
}

View file

@ -5,6 +5,7 @@ export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
width={width} width={width}
height={height} height={height}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
> >
<title>GitHub</title> <title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />

View file

@ -22,7 +22,6 @@ import {
SquarePen, SquarePen,
XIcon, XIcon,
Plus, Plus,
CircleX,
} from "lucide-react"; } from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs"; import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@ -31,13 +30,6 @@ import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery"; import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { useFileUpload } from "@/hooks/use-file-upload"; import { useFileUpload } from "@/hooks/use-file-upload";
import { ContentBlocksPreview } from "./ContentBlocksPreview"; import { ContentBlocksPreview } from "./ContentBlocksPreview";
import { import {
@ -46,6 +38,7 @@ import {
ArtifactTitle, ArtifactTitle,
useArtifactContext, useArtifactContext,
} from "./artifact"; } from "./artifact";
import { GitHubOAuthButton } from "../github-oauth-button";
function StickyToBottomContent(props: { function StickyToBottomContent(props: {
content: ReactNode; content: ReactNode;
@ -88,30 +81,6 @@ function ScrollToBottom(props: { className?: string }) {
); );
} }
function OpenGitHubRepo() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/langchain-ai/open-swe"
target="_blank"
className="flex items-center justify-center"
>
<GitHubSVG
width="24"
height="24"
/>
</a>
</TooltipTrigger>
<TooltipContent side="left">
<p>Open GitHub repo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function Thread() { export function Thread() {
const [artifactContext, setArtifactContext] = useArtifactContext(); const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen(); const [artifactOpen, closeArtifact] = useArtifactOpen();
@ -337,7 +306,7 @@ export function Thread() {
)} )}
</div> </div>
<div className="absolute top-2 right-4 flex items-center"> <div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo /> <GitHubOAuthButton />
</div> </div>
</div> </div>
)} )}
@ -383,7 +352,7 @@ export function Thread() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center"> <div className="flex items-center">
<OpenGitHubRepo /> <GitHubOAuthButton />
</div> </div>
<TooltipIconButton <TooltipIconButton
size="lg" size="lg"

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
{ {
variants: { variants: {
variant: { variant: {

View file

@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
interface UseGitHubAppReturn {
isInstalled: boolean | null;
isLoading: boolean;
error: string | null;
repositories: Repository[];
refreshRepositories: () => Promise<void>;
}
export function useGitHubApp(): UseGitHubAppReturn {
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [repositories, setRepositories] = useState<Repository[]>([]);
const checkInstallation = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
const data = await response.json();
setRepositories(data.repositories || []);
setIsInstalled(true);
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setIsInstalled(false);
} else {
setError(errorData.error);
setIsInstalled(false);
}
}
} catch (err) {
setError("Failed to check GitHub App installation status");
setIsInstalled(false);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
checkInstallation();
}, []);
const refreshRepositories = async () => {
await checkInstallation();
};
return {
isInstalled,
isLoading,
error,
repositories,
refreshRepositories,
};
}

View file

@ -0,0 +1,59 @@
import { useState, useCallback } from "react";
interface TokenResponse {
token: string;
installation_id: string;
}
interface UseGitHubTokenReturn {
token: string | null;
installationId: string | null;
isLoading: boolean;
error: string | null;
fetchToken: () => Promise<string | null>;
}
/**
* Hook to fetch a GitHub installation token that can be used for Git operations
* This token can be passed to the agent service to perform Git operations on behalf of the user
*/
export function useGitHubToken(): UseGitHubTokenReturn {
const [token, setToken] = useState<string | null>(null);
const [installationId, setInstallationId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fetchToken = useCallback(async (): Promise<string | null> => {
setIsLoading(true);
setError(null);
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 null;
}
const data: TokenResponse = await response.json();
setToken(data.token);
setInstallationId(data.installation_id);
setIsLoading(false);
return data.token;
} catch (err) {
setError("Network error when fetching token");
setIsLoading(false);
return null;
}
}, []);
return {
token,
installationId,
isLoading,
error,
fetchToken,
};
}

114
apps/web/src/lib/auth.ts Normal file
View file

@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
// 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";
export const GITHUB_INSTALLATION_STATE_COOKIE = "github_installation_state";
export const GITHUB_INSTALLATION_RETURN_TO_COOKIE = "installation_return_to";
export interface GitHubTokenData {
access_token: string;
token_type: string;
installation_id?: string;
}
/**
* Cookie options for GitHub token cookies
*/
function getCookieOptions(expires?: Date) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: expires ? undefined : 60 * 60 * 24 * 30, // 30 days
expires,
path: "/",
};
}
/**
* Stores GitHub OAuth token data in secure HTTP-only cookies
*
* @param tokenData The GitHub token data to store
* @param response NextResponse to set cookies on
*/
export function storeGitHubToken(
tokenData: GitHubTokenData,
response: NextResponse,
): void {
const cookieOptions = getCookieOptions();
// Store token components in separate cookies for better security
response.cookies.set(
GITHUB_TOKEN_COOKIE,
tokenData.access_token,
cookieOptions,
);
response.cookies.set(
GITHUB_TOKEN_TYPE_COOKIE,
tokenData.token_type || "bearer",
cookieOptions,
);
// Store installation ID if provided
if (tokenData.installation_id) {
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
tokenData.installation_id,
cookieOptions,
);
}
}
/**
* Retrieves GitHub OAuth token data from cookies
*
* @param request NextRequest to get cookies from
*/
export function getGitHubToken(request: NextRequest): GitHubTokenData | null {
try {
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
const tokenType = request.cookies.get(GITHUB_TOKEN_TYPE_COOKIE)?.value;
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!accessToken) {
return null;
}
return {
access_token: accessToken,
token_type: tokenType || "bearer",
installation_id: installationId,
};
} catch (error) {
console.error("Error retrieving GitHub token:", error);
return null;
}
}
/**
* Removes GitHub OAuth token data from cookies (logout)
*
* @param response NextResponse to set cookies on
*/
export function clearGitHubToken(response: NextResponse): void {
const cookieOptions = getCookieOptions(new Date(0)); // Expire immediately
response.cookies.set(GITHUB_TOKEN_COOKIE, "", cookieOptions);
response.cookies.set(GITHUB_TOKEN_TYPE_COOKIE, "", cookieOptions);
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, "", cookieOptions);
}
/**
* Checks if user has a valid GitHub token
*
* @param request NextRequest to get cookies from
*/
export function isAuthenticated(request: NextRequest): boolean {
const token = getGitHubToken(request);
return token !== null && token.access_token.length > 0;
}

View file

@ -1,4 +1,10 @@
import React, { createContext, useContext, ReactNode, useState } from "react"; import React, {
createContext,
useContext,
ReactNode,
useState,
useEffect,
} from "react";
import { useStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk"; import { type Message } from "@langchain/langgraph-sdk";
import { import {
@ -12,9 +18,13 @@ import { useQueryState } from "nuqs";
import { LangGraphLogoSVG } from "@/components/icons/langgraph"; import { LangGraphLogoSVG } from "@/components/icons/langgraph";
import { useThreads } from "./Thread"; import { useThreads } from "./Thread";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button"; import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { Copy, CopyCheck } from "lucide-react"; import { Copy, CopyCheck, ArrowRight } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
import { useGitHubToken } from "@/hooks/useGitHubToken";
type TargetRepository = { owner: string; repo: string }; type TargetRepository = { owner: string; repo: string };
export type StateType = { export type StateType = {
messages: Message[]; messages: Message[];
@ -46,17 +56,28 @@ const StreamSession = ({
children, children,
apiUrl, apiUrl,
assistantId, assistantId,
githubToken,
}: { }: {
children: ReactNode; children: ReactNode;
apiUrl: string; apiUrl: string;
assistantId: string; assistantId: string;
githubToken: string;
}) => { }) => {
const [threadId, setThreadId] = useQueryState("threadId"); const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads(); const { getThreads, setThreads } = 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,
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) => {
@ -90,6 +111,71 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const assistantId: string | undefined = const assistantId: string | undefined =
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? ""; process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
boolean | null
>(null);
const [isCheckingAppInstallation, setIsCheckingAppInstallation] =
useState(false);
const { token: githubToken, fetchToken: fetchGitHubToken } = useGitHubToken();
useEffect(() => {
checkAuthStatus();
}, []);
useEffect(() => {
if (isAuth) {
checkGitHubAppInstallation();
}
}, [isAuth]);
const checkAuthStatus = async () => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
setIsAuth(data.authenticated);
} catch (error) {
console.error("Error checking auth status:", error);
setIsAuth(false);
}
};
const checkGitHubAppInstallation = async () => {
setIsCheckingAppInstallation(true);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
setHasGitHubAppInstalled(true);
// If the app is installed, fetch a token
await fetchGitHubToken();
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setHasGitHubAppInstalled(false);
} else {
// If there's a different error, we'll assume the app is not installed
setHasGitHubAppInstalled(false);
}
}
} catch (error) {
console.error("Error checking GitHub App installation:", error);
setHasGitHubAppInstalled(false);
} finally {
setIsCheckingAppInstallation(false);
}
};
const handleLogin = () => {
setIsLoading(true);
window.location.href = "/api/auth/github/login";
};
const handleInstallGitHubApp = () => {
setIsLoading(true);
window.location.href = "/api/github/installation";
};
if (!apiUrl || !assistantId) { if (!apiUrl || !assistantId) {
return ( return (
<div className="flex min-h-screen w-full items-center justify-center p-4"> <div className="flex min-h-screen w-full items-center justify-center p-4">
@ -154,10 +240,91 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
); );
} }
if (!isAuth) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="flex flex-col gap-4 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
Get started
</h1>
</div>
<p className="text-muted-foreground">
Connect your GitHub account to get started with Open SWE.
</p>
<Button
onClick={handleLogin}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Connecting..." : "Connect GitHub"}
</Button>
</div>
</div>
</div>
);
}
// Step 2: GitHub App Installation (only show if authenticated but app not installed)
if ((isAuth && hasGitHubAppInstalled === false) || !githubToken) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="flex flex-col gap-4 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
One more step
</h1>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
1. GitHub Login ✓
</span>
<ArrowRight className="h-3 w-3" />
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
2. Repository Access
</span>
</div>
<p className="text-muted-foreground">
Great! Now we need access to your GitHub repositories. Install our
GitHub App to grant access to specific repositories.
</p>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p>
You'll be redirected to GitHub where you can select which
repositories to grant access to.
</p>
</div>
<Button
onClick={handleInstallGitHubApp}
disabled={isLoading || isCheckingAppInstallation}
className="bg-black hover:bg-gray-800"
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading || isCheckingAppInstallation
? "Loading..."
: "Install GitHub App"}
</Button>
</div>
</div>
</div>
);
}
return ( return (
<StreamSession <StreamSession
apiUrl={apiUrl} apiUrl={apiUrl}
assistantId={assistantId} assistantId={assistantId}
githubToken={githubToken}
> >
{children} {children}
</StreamSession> </StreamSession>

View file

@ -0,0 +1,97 @@
import * as jwt from "jsonwebtoken";
/**
* Generates a JWT for GitHub App authentication
*/
export function generateJWT(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 10 * 60,
iss: appId,
};
return jwt.sign(payload, privateKey, { algorithm: "RS256" });
}
/**
* Gets an installation access token for a GitHub App installation
*/
export async function getInstallationToken(
installationId: string,
appId: string,
privateKey: string,
): Promise<string> {
const jwtToken = generateJWT(appId, privateKey);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${jwtToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "YourAppName",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to get installation token: ${JSON.stringify(errorData)}`,
);
}
const data = await response.json();
return data.token;
}
/**
* Fetches repositories accessible to a GitHub App installation
*/
export async function getInstallationRepositories(
installationToken: string,
): Promise<Repository[]> {
const response = await fetch(
"https://api.github.com/installation/repositories",
{
headers: {
Authorization: `Bearer ${installationToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "YourAppName",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to fetch repositories: ${JSON.stringify(errorData)}`,
);
}
const data = await response.json();
return data.repositories;
}
/**
* Repository interface representing GitHub repository data
*/
export interface Repository {
id: number;
name: string;
full_name: string;
description: string | null;
private: boolean;
html_url: string;
default_branch: string;
permissions: {
admin: boolean;
maintain: boolean;
push: boolean;
triage: boolean;
pull: boolean;
};
}

118
yarn.lock
View file

@ -1958,6 +1958,7 @@ __metadata:
"@radix-ui/react-switch": ^1.1.3 "@radix-ui/react-switch": ^1.1.3
"@radix-ui/react-tooltip": ^1.1.8 "@radix-ui/react-tooltip": ^1.1.8
"@tailwindcss/postcss": ^4.0.13 "@tailwindcss/postcss": ^4.0.13
"@types/jsonwebtoken": ^9.0.9
"@types/lodash": ^4.17.16 "@types/lodash": ^4.17.16
"@types/node": ^22.13.5 "@types/node": ^22.13.5
"@types/react": ^19.0.8 "@types/react": ^19.0.8
@ -1978,6 +1979,7 @@ __metadata:
eslint-plugin-react-refresh: ^0.4.18 eslint-plugin-react-refresh: ^0.4.18
framer-motion: ^12.4.9 framer-motion: ^12.4.9
globals: ^15.14.0 globals: ^15.14.0
jsonwebtoken: ^9.0.2
katex: ^0.16.21 katex: ^0.16.21
langgraph-nextjs-api-passthrough: ^0.0.4 langgraph-nextjs-api-passthrough: ^0.0.4
lodash: ^4.17.21 lodash: ^4.17.21
@ -3288,6 +3290,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/jsonwebtoken@npm:^9.0.9":
version: 9.0.9
resolution: "@types/jsonwebtoken@npm:9.0.9"
dependencies:
"@types/ms": "*"
"@types/node": "*"
checksum: 9d564fc09fc83f66e319754dee0b039ae23f28fc38a9227c35283e69311ddf5a7493a060c7f956df8cd856416747c515b51fec20b8656865eacebf23b3c6bc62
languageName: node
linkType: hard
"@types/katex@npm:^0.16.0": "@types/katex@npm:^0.16.0":
version: 0.16.7 version: 0.16.7
resolution: "@types/katex@npm:0.16.7" resolution: "@types/katex@npm:0.16.7"
@ -4364,6 +4376,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"buffer-equal-constant-time@npm:^1.0.1":
version: 1.0.1
resolution: "buffer-equal-constant-time@npm:1.0.1"
checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
languageName: node
linkType: hard
"buffer-from@npm:^1.0.0": "buffer-from@npm:^1.0.0":
version: 1.1.2 version: 1.1.2
resolution: "buffer-from@npm:1.1.2" resolution: "buffer-from@npm:1.1.2"
@ -5218,6 +5237,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ecdsa-sig-formatter@npm:1.0.11":
version: 1.0.11
resolution: "ecdsa-sig-formatter@npm:1.0.11"
dependencies:
safe-buffer: ^5.0.1
checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
languageName: node
linkType: hard
"eciesjs@npm:^0.4.10": "eciesjs@npm:^0.4.10":
version: 0.4.15 version: 0.4.15
resolution: "eciesjs@npm:0.4.15" resolution: "eciesjs@npm:0.4.15"
@ -8181,6 +8209,24 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jsonwebtoken@npm:^9.0.2":
version: 9.0.2
resolution: "jsonwebtoken@npm:9.0.2"
dependencies:
jws: ^3.2.2
lodash.includes: ^4.3.0
lodash.isboolean: ^3.0.3
lodash.isinteger: ^4.0.4
lodash.isnumber: ^3.0.3
lodash.isplainobject: ^4.0.6
lodash.isstring: ^4.0.1
lodash.once: ^4.0.0
ms: ^2.1.1
semver: ^7.5.4
checksum: fc739a6a8b33f1974f9772dca7f8493ca8df4cc31c5a09dcfdb7cff77447dcf22f4236fb2774ef3fe50df0abeb8e1c6f4c41eba82f500a804ab101e2fbc9d61a
languageName: node
linkType: hard
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5": "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5":
version: 3.3.5 version: 3.3.5
resolution: "jsx-ast-utils@npm:3.3.5" resolution: "jsx-ast-utils@npm:3.3.5"
@ -8193,6 +8239,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jwa@npm:^1.4.1":
version: 1.4.2
resolution: "jwa@npm:1.4.2"
dependencies:
buffer-equal-constant-time: ^1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: ^5.0.1
checksum: fd1a6de6c649a4b16f0775439ac9173e4bc9aa0162c7f3836699af47736ae000fafe89f232a2345170de6c14021029cb94b488f7882c6caf61e6afef5fce6494
languageName: node
linkType: hard
"jws@npm:^3.2.2":
version: 3.2.2
resolution: "jws@npm:3.2.2"
dependencies:
jwa: ^1.4.1
safe-buffer: ^5.0.1
checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
languageName: node
linkType: hard
"katex@npm:^0.16.0, katex@npm:^0.16.21": "katex@npm:^0.16.0, katex@npm:^0.16.21":
version: 0.16.22 version: 0.16.22
resolution: "katex@npm:0.16.22" resolution: "katex@npm:0.16.22"
@ -8531,6 +8598,48 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash.includes@npm:^4.3.0":
version: 4.3.0
resolution: "lodash.includes@npm:4.3.0"
checksum: 71092c130515a67ab3bd928f57f6018434797c94def7f46aafa417771e455ce3a4834889f4267b17887d7f75297dfabd96231bf704fd2b8c5096dc4a913568b6
languageName: node
linkType: hard
"lodash.isboolean@npm:^3.0.3":
version: 3.0.3
resolution: "lodash.isboolean@npm:3.0.3"
checksum: b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250
languageName: node
linkType: hard
"lodash.isinteger@npm:^4.0.4":
version: 4.0.4
resolution: "lodash.isinteger@npm:4.0.4"
checksum: 6034821b3fc61a2ffc34e7d5644bb50c5fd8f1c0121c554c21ac271911ee0c0502274852845005f8651d51e199ee2e0cfebfe40aaa49c7fe617f603a8a0b1691
languageName: node
linkType: hard
"lodash.isnumber@npm:^3.0.3":
version: 3.0.3
resolution: "lodash.isnumber@npm:3.0.3"
checksum: 913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2
languageName: node
linkType: hard
"lodash.isplainobject@npm:^4.0.6":
version: 4.0.6
resolution: "lodash.isplainobject@npm:4.0.6"
checksum: 29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337
languageName: node
linkType: hard
"lodash.isstring@npm:^4.0.1":
version: 4.0.1
resolution: "lodash.isstring@npm:4.0.1"
checksum: eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0
languageName: node
linkType: hard
"lodash.memoize@npm:^4.1.2": "lodash.memoize@npm:^4.1.2":
version: 4.1.2 version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2" resolution: "lodash.memoize@npm:4.1.2"
@ -8545,6 +8654,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash.once@npm:^4.0.0":
version: 4.1.1
resolution: "lodash.once@npm:4.1.1"
checksum: d768fa9f9b4e1dc6453be99b753906f58990e0c45e7b2ca5a3b40a33111e5d17f6edf2f768786e2716af90a8e78f8f91431ab8435f761fef00f9b0c256f6d245
languageName: node
linkType: hard
"lodash@npm:^4.17.21": "lodash@npm:^4.17.21":
version: 4.17.21 version: 4.17.21
resolution: "lodash@npm:4.17.21" resolution: "lodash@npm:4.17.21"
@ -10917,7 +11033,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"safe-buffer@npm:~5.2.0": "safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
version: 5.2.1 version: 5.2.1
resolution: "safe-buffer@npm:5.2.1" resolution: "safe-buffer@npm:5.2.1"
checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491