From 887fc030a412c8c2d2f43745a29934769726293f Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Sun, 1 Jun 2025 18:30:29 -0700 Subject: [PATCH] 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 --- README.md | 54 +++-- apps/open-swe/.env.example | 3 - apps/open-swe/src/constants.ts | 6 +- apps/open-swe/src/nodes/generate-message.ts | 2 +- apps/open-swe/src/nodes/initialize.ts | 61 ++---- apps/open-swe/src/nodes/open-pr.ts | 5 +- apps/open-swe/src/nodes/take-action.ts | 2 +- apps/open-swe/src/types.ts | 22 ++ .../src/utils/{git/index.ts => git.ts} | 191 ++++++++++++------ apps/open-swe/src/utils/github-tokens.ts | 21 ++ apps/web/.env.example | 22 +- apps/web/package.json | 2 + .../src/app/api/auth/github/callback/route.ts | 133 ++++++++++++ .../src/app/api/auth/github/login/route.ts | 45 +++++ apps/web/src/app/api/auth/logout/route.ts | 19 ++ apps/web/src/app/api/auth/status/route.ts | 18 ++ .../api/github/installation-callback/route.ts | 73 +++++++ .../api/github/installation/callback/route.ts | 40 ++++ .../src/app/api/github/installation/route.ts | 84 ++++++++ .../src/app/api/github/repositories/route.ts | 92 +++++++++ apps/web/src/app/api/github/token/route.ts | 66 ++++++ apps/web/src/app/github/page.tsx | 156 ++++++++++++++ .../src/components/github-oauth-button.tsx | 93 +++++++++ .../components/github/AgentTokenProvider.tsx | 151 ++++++++++++++ .../components/github/InstallAppButton.tsx | 54 +++++ .../src/components/github/RepositoryList.tsx | 148 ++++++++++++++ apps/web/src/components/icons/github.tsx | 1 + apps/web/src/components/thread/index.tsx | 37 +--- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/hooks/useGitHubApp.ts | 61 ++++++ apps/web/src/hooks/useGitHubToken.ts | 59 ++++++ apps/web/src/lib/auth.ts | 114 +++++++++++ apps/web/src/providers/Stream.tsx | 171 +++++++++++++++- apps/web/src/utils/github.ts | 97 +++++++++ yarn.lock | 118 ++++++++++- 35 files changed, 2037 insertions(+), 186 deletions(-) rename apps/open-swe/src/utils/{git/index.ts => git.ts} (71%) create mode 100644 apps/open-swe/src/utils/github-tokens.ts create mode 100644 apps/web/src/app/api/auth/github/callback/route.ts create mode 100644 apps/web/src/app/api/auth/github/login/route.ts create mode 100644 apps/web/src/app/api/auth/logout/route.ts create mode 100644 apps/web/src/app/api/auth/status/route.ts create mode 100644 apps/web/src/app/api/github/installation-callback/route.ts create mode 100644 apps/web/src/app/api/github/installation/callback/route.ts create mode 100644 apps/web/src/app/api/github/installation/route.ts create mode 100644 apps/web/src/app/api/github/repositories/route.ts create mode 100644 apps/web/src/app/api/github/token/route.ts create mode 100644 apps/web/src/app/github/page.tsx create mode 100644 apps/web/src/components/github-oauth-button.tsx create mode 100644 apps/web/src/components/github/AgentTokenProvider.tsx create mode 100644 apps/web/src/components/github/InstallAppButton.tsx create mode 100644 apps/web/src/components/github/RepositoryList.tsx create mode 100644 apps/web/src/hooks/useGitHubApp.ts create mode 100644 apps/web/src/hooks/useGitHubToken.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/utils/github.ts diff --git a/README.md b/README.md index 5956cb5a..d2e02fc9 100644 --- a/README.md +++ b/README.md @@ -45,30 +45,53 @@ GOOGLE_API_KEY="" # Daytona API key for accessing and modifying the code in the cloud sandbox. 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: ```bash 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) -2. Click on `Generate new token` to generate a new fine grained token. -3. Give the token a name & description. -4. Choose `Only select repositories`, and select the repositories you want to give Open SWE access to. -5. Under `Permissions`, give it `Repository permission`: - - `Contents` - `Read and write` - - `Metadata` - `Read-only` (should be auto enabled after selecting `Contents`) - - `Pull requests` - `Read and write` -6. Click `Generate token` & copy the token. -7. Paste the token into the `GITHUB_PAT` variable in the agent `.env` file. +Give the app a name and 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`. + +Under `Post installation`, check `Redirect on update`. + +Under `Webhook`, uncheck `Active`. + +Under `Repository permissions`, give the app the following permissions: + +- `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 @@ -114,3 +137,4 @@ Once you've accepted the plan, it will begin the execution flow. When the agent ## Accessing Changes Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/`. Every time a file is created, modified, or deleted, the changes will be committed to this branch. You can access the changes in the repository by checking out this branch. + diff --git a/apps/open-swe/.env.example b/apps/open-swe/.env.example index 001dd51b..5bf4425c 100644 --- a/apps/open-swe/.env.example +++ b/apps/open-swe/.env.example @@ -11,6 +11,3 @@ GOOGLE_API_KEY="" # Daytona API key for accessing and modifying the code in the cloud sandbox. DAYTONA_API_KEY="" - -# Your GitHub PAT with access to the repositories you want to modify. -GITHUB_PAT="" diff --git a/apps/open-swe/src/constants.ts b/apps/open-swe/src/constants.ts index 6a43f088..3bf99aef 100644 --- a/apps/open-swe/src/constants.ts +++ b/apps/open-swe/src/constants.ts @@ -1,7 +1,3 @@ -export const TIMEOUT_MS = 300_000; // 5 minutes -// Always extend timeouts by 5 min after each command execution. -export const TIMEOUT_EXTENSION_OPT = { - timeoutMs: TIMEOUT_MS, -}; +export const TIMEOUT_SEC = 60; // 1 minute export const SANDBOX_ROOT_DIR = "/home/daytona"; export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0"; diff --git a/apps/open-swe/src/nodes/generate-message.ts b/apps/open-swe/src/nodes/generate-message.ts index 9bbef5e3..5810d1f6 100644 --- a/apps/open-swe/src/nodes/generate-message.ts +++ b/apps/open-swe/src/nodes/generate-message.ts @@ -5,7 +5,7 @@ import { applyPatchTool, requestHumanHelpTool, } 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 { stopSandbox } from "../utils/sandbox.js"; import { createLogger, LogLevel } from "../utils/logger.js"; diff --git a/apps/open-swe/src/nodes/initialize.ts b/apps/open-swe/src/nodes/initialize.ts index 8eab41a5..cba56ac6 100644 --- a/apps/open-swe/src/nodes/initialize.ts +++ b/apps/open-swe/src/nodes/initialize.ts @@ -1,51 +1,19 @@ -import { Sandbox } from "@daytonaio/sdk"; import { createLogger, LogLevel } from "../utils/logger.js"; -import { - GraphState, - GraphConfig, - GraphUpdate, - TargetRepository, -} from "../types.js"; +import { GraphState, GraphConfig, GraphUpdate } from "../types.js"; import { checkoutBranch, + cloneRepo, configureGitUserInRepo, getBranchName, getRepoAbsolutePath, pullLatestChanges, -} from "../utils/git/index.js"; -import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js"; +} from "../utils/git.js"; import { daytonaClient } from "../utils/sandbox.js"; import { SNAPSHOT_NAME } from "../constants.js"; +import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; 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 * the proper credentials are provided for taking actions on GitHub. @@ -56,16 +24,8 @@ export async function initialize( state: GraphState, config: GraphConfig, ): Promise { - if (!config.configurable) { - throw new Error("Configuration object not found."); - } - 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 { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config); + const { sandboxSessionId, targetRepository } = state; const absoluteRepoDir = getRepoAbsolutePath(targetRepository); if (sandboxSessionId) { @@ -90,7 +50,7 @@ export async function initialize( image: SNAPSHOT_NAME, }); - const res = await cloneRepo(sandbox, targetRepository); + const res = await cloneRepo(sandbox, targetRepository, { githubToken }); if (res.exitCode !== 0) { // TODO: This should probably be an interrupt. logger.error("Failed to clone repository", res.result); @@ -99,7 +59,12 @@ export async function initialize( logger.info("Repository cloned successfully."); 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."); const checkoutBranchRes = await checkoutBranch( diff --git a/apps/open-swe/src/nodes/open-pr.ts b/apps/open-swe/src/nodes/open-pr.ts index 88870d33..3cf72b43 100644 --- a/apps/open-swe/src/nodes/open-pr.ts +++ b/apps/open-swe/src/nodes/open-pr.ts @@ -5,7 +5,7 @@ import { getBranchName, getChangedFilesStatus, getRepoAbsolutePath, -} from "../utils/git/index.js"; +} from "../utils/git.js"; import { createLogger, LogLevel } from "../utils/logger.js"; import { z } from "zod"; 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 { getMessageContentString } from "../utils/message/content.js"; import { daytonaClient } from "../utils/sandbox.js"; +import { getGitHubTokensFromConfig } from "../utils/github-tokens.js"; 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.", ); } + const { githubToken } = getGitHubTokensFromConfig(config); const sandbox = await daytonaClient().get(sandboxSessionId); @@ -133,6 +135,7 @@ export async function openPullRequest( headBranch: branchName ?? getBranchName(config), title, body, + githubToken, }); return { diff --git a/apps/open-swe/src/nodes/take-action.ts b/apps/open-swe/src/nodes/take-action.ts index a19b0963..3703d1d3 100644 --- a/apps/open-swe/src/nodes/take-action.ts +++ b/apps/open-swe/src/nodes/take-action.ts @@ -10,7 +10,7 @@ import { checkoutBranchAndCommit, getChangedFilesStatus, getRepoAbsolutePath, -} from "../utils/git/index.js"; +} from "../utils/git.js"; import { formatBadArgsError, zodSchemaToString, diff --git a/apps/open-swe/src/types.ts b/apps/open-swe/src/types.ts index f39e00a2..2521d693 100644 --- a/apps/open-swe/src/types.ts +++ b/apps/open-swe/src/types.ts @@ -361,6 +361,28 @@ export const GraphConfiguration = z.object({ "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< diff --git a/apps/open-swe/src/utils/git/index.ts b/apps/open-swe/src/utils/git.ts similarity index 71% rename from apps/open-swe/src/utils/git/index.ts rename to apps/open-swe/src/utils/git.ts index b7564a82..9c8f1fd1 100644 --- a/apps/open-swe/src/utils/git/index.ts +++ b/apps/open-swe/src/utils/git.ts @@ -1,9 +1,9 @@ import { Octokit } from "@octokit/rest"; import { Sandbox } from "@daytonaio/sdk"; -import { createLogger, LogLevel } from "../logger.js"; -import { GraphConfig, TargetRepository } from "../../types.js"; -import { TIMEOUT_MS, SANDBOX_ROOT_DIR } from "../../constants.js"; -import { getSandboxErrorFields } from "../sandbox-error-fields.js"; +import { createLogger, LogLevel } from "./logger.js"; +import { GraphConfig, TargetRepository } from "../types.js"; +import { TIMEOUT_SEC, SANDBOX_ROOT_DIR } from "../constants.js"; +import { getSandboxErrorFields } from "./sandbox-error-fields.js"; import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js"; const logger = createLogger(LogLevel.INFO, "GitUtil"); @@ -40,7 +40,7 @@ export async function checkoutBranch( "git branch --show-current", absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (getCurrentBranchOutput.exitCode !== 0) { @@ -80,7 +80,7 @@ export async function checkoutBranch( `git rev-parse --verify --quiet "refs/heads/${branchName}"`, absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (checkBranchExistsOutput.exitCode === 0) { @@ -116,7 +116,7 @@ export async function checkoutBranch( checkoutCommand, absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (gitCheckoutOutput.exitCode !== 0) { @@ -152,29 +152,24 @@ interface GitHubUserResponse { email: string | null; } -async function getGitUserDetailsFromGitHub(): Promise<{ +async function getGitUserDetailsFromGitHub(githubToken: string): Promise<{ userName?: 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 with Bearer token first (for GitHub App installation tokens) const response = await fetch("https://api.github.com/user", { headers: { - Authorization: `token ${githubToken}`, + Authorization: `Bearer ${githubToken}`, Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenSWE", }, }); if (!response.ok) { logger.error(`Failed to fetch GitHub user info`, { - response, + status: response.status, + statusText: response.statusText, }); return {}; } @@ -198,6 +193,10 @@ async function getGitUserDetailsFromGitHub(): Promise<{ if (!finalUserEmail) { 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 }; } catch (e) { logger.error(`Error fetching GitHub user info`, { @@ -214,20 +213,27 @@ async function getGitUserDetailsFromGitHub(): Promise<{ export async function configureGitUserInRepo( absoluteRepoDir: string, sandbox: Sandbox, + args: { + githubToken: string; + githubAccessToken: string; + owner: string; + repo: string; + }, ): Promise { + const { githubToken, githubAccessToken, owner, repo } = args; let needsGitConfig = false; try { const nameCheck = await sandbox.process.executeCommand( "git config user.name", absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); const emailCheck = await sandbox.process.executeCommand( "git config user.email", absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if ( @@ -249,39 +255,70 @@ export async function configureGitUserInRepo( needsGitConfig = true; } - if (needsGitConfig) { - const { userName, userEmail } = await getGitUserDetailsFromGitHub(); + // Configure git to use the token for authentication with GitHub by updating the remote URL + 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) { - const configUserNameOutput = await sandbox.process.executeCommand( - `git config user.name "${userName}"`, - absoluteRepoDir, - undefined, - TIMEOUT_MS, - ); - if (configUserNameOutput.exitCode !== 0) { - logger.error(`Failed to set git user.name`, { - configUserNameOutput, - }); - } else { - logger.info(`Set git user.name to '${userName}' successfully.`); - } + if (setRemoteOutput.exitCode !== 0) { + logger.error(`Failed to set remote URL with token`, { + setRemoteOutput, + }); + } else { + logger.info("Git remote URL updated with token successfully."); + } + } catch (authError) { + logger.error(`Error configuring git authentication for GitHub`, { + ...(authError instanceof Error && { + name: authError.name, + message: authError.message, + 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) { - const configUserEmailOutput = await sandbox.process.executeCommand( - `git config user.email "${userEmail}"`, - absoluteRepoDir, - undefined, - TIMEOUT_MS, - ); - if (configUserEmailOutput.exitCode !== 0) { - logger.error(`Failed to set git user.email`, { - configUserEmailOutput, - }); - } else { - logger.info(`Set git user.email to '${userEmail}' successfully.`); - } + // Set user email - use fetched email or fallback to a generic noreply address + const emailToUse = userEmail || "open-swe-bot@noreply.github.com"; + const configUserEmailOutput = await sandbox.process.executeCommand( + `git config user.email "${emailToUse}"`, + absoluteRepoDir, + undefined, + TIMEOUT_SEC, + ); + if (configUserEmailOutput.exitCode !== 0) { + logger.error(`Failed to set git user.email`, { + configUserEmailOutput, + }); + } else { + logger.info(`Set git user.email to '${emailToUse}' successfully.`); } } else { logger.info( @@ -300,7 +337,7 @@ export async function commitAll( `git add -A && git commit -m "${message}"`, absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (gitAddOutput.exitCode !== 0) { @@ -330,7 +367,9 @@ export async function commitAllAndPush( ): Promise { try { const commitOutput = await commitAll(absoluteRepoDir, message, sandbox); - + logger.info( + "Committed changes to git repository successfully. Now pushing...", + ); const pushCurrentBranchCmd = "git push -u origin $(git rev-parse --abbrev-ref HEAD)"; @@ -342,7 +381,7 @@ export async function commitAllAndPush( pushCurrentBranchCmd, absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (gitPushOutput.exitCode !== 0) { @@ -375,7 +414,7 @@ export async function getChangedFilesStatus( "git status --porcelain", absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); if (gitStatusOutput.exitCode !== 0) { @@ -418,21 +457,17 @@ export async function createPullRequest({ headBranch, title, body = "", + githubToken, }: { owner: string; repo: string; headBranch: string; title: 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({ - auth: token, + auth: githubToken, }); try { @@ -476,7 +511,7 @@ export async function pullLatestChanges( "git pull", absoluteRepoDir, undefined, - TIMEOUT_MS, + TIMEOUT_SEC, ); return gitPullOutput; } catch (e) { @@ -492,3 +527,35 @@ export async function pullLatestChanges( 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; + } +} diff --git a/apps/open-swe/src/utils/github-tokens.ts b/apps/open-swe/src/utils/github-tokens.ts new file mode 100644 index 00000000..d81bb6a2 --- /dev/null +++ b/apps/open-swe/src/utils/github-tokens.ts @@ -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 }; +} diff --git a/apps/web/.env.example b/apps/web/.env.example index a38138d3..343bb21b 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,13 +1,11 @@ -# LangGraph Configuration -NEXT_PUBLIC_API_URL=http://localhost:2024 -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= +NEXT_PUBLIC_API_URL="http://localhost:2024" +NEXT_PUBLIC_ASSISTANT_ID="open-swe" -# Production LangGraph Configuration (quickstart) - Uncomment to use -# NEXT_PUBLIC_ASSISTANT_ID="agent" -# This should be the deployment URL of your LangGraph server -# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app" -# 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" -# LANGSMITH_API_KEY="lsv2_..." +# 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="" diff --git a/apps/web/package.json b/apps/web/package.json index 6e5cc41e..ffb8824d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "esbuild": "^0.25.0", "esbuild-plugin-tailwindcss": "^2.0.1", "framer-motion": "^12.4.9", + "jsonwebtoken": "^9.0.2", "katex": "^0.16.21", "langgraph-nextjs-api-passthrough": "^0.0.4", "lodash": "^4.17.21", @@ -62,6 +63,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@tailwindcss/postcss": "^4.0.13", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", "@types/node": "^22.13.5", "@types/react": "^19.0.8", diff --git a/apps/web/src/app/api/auth/github/callback/route.ts b/apps/web/src/app/api/auth/github/callback/route.ts new file mode 100644 index 00000000..f84db72a --- /dev/null +++ b/apps/web/src/app/api/auth/github/callback/route.ts @@ -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), + ); + } +} diff --git a/apps/web/src/app/api/auth/github/login/route.ts b/apps/web/src/app/api/auth/github/login/route.ts new file mode 100644 index 00000000..0fb0e35a --- /dev/null +++ b/apps/web/src/app/api/auth/github/login/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/auth/logout/route.ts b/apps/web/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..ef37819c --- /dev/null +++ b/apps/web/src/app/api/auth/logout/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/auth/status/route.ts b/apps/web/src/app/api/auth/status/route.ts new file mode 100644 index 00000000..b3778416 --- /dev/null +++ b/apps/web/src/app/api/auth/status/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/github/installation-callback/route.ts b/apps/web/src/app/api/github/installation-callback/route.ts new file mode 100644 index 00000000..5ed0ed92 --- /dev/null +++ b/apps/web/src/app/api/github/installation-callback/route.ts @@ -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), + ); + } +} diff --git a/apps/web/src/app/api/github/installation/callback/route.ts b/apps/web/src/app/api/github/installation/callback/route.ts new file mode 100644 index 00000000..63d66023 --- /dev/null +++ b/apps/web/src/app/api/github/installation/callback/route.ts @@ -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), + ); + } +} diff --git a/apps/web/src/app/api/github/installation/route.ts b/apps/web/src/app/api/github/installation/route.ts new file mode 100644 index 00000000..53cedfe0 --- /dev/null +++ b/apps/web/src/app/api/github/installation/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/github/repositories/route.ts b/apps/web/src/app/api/github/repositories/route.ts new file mode 100644 index 00000000..d4a4f063 --- /dev/null +++ b/apps/web/src/app/api/github/repositories/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/api/github/token/route.ts b/apps/web/src/app/api/github/token/route.ts new file mode 100644 index 00000000..64307ff7 --- /dev/null +++ b/apps/web/src/app/api/github/token/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/app/github/page.tsx b/apps/web/src/app/github/page.tsx new file mode 100644 index 00000000..b28a5f17 --- /dev/null +++ b/apps/web/src/app/github/page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Repository } from "@/utils/github"; + +export default function GitHubPage() { + const [repositories, setRepositories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+

GitHub Repository Access

+ + {error && ( +
+ {error} +
+ )} + + {!isInstalled ? ( +
+

Install GitHub App

+

+ To access your GitHub repositories, you need to install our GitHub + App and grant it access to the repositories you want to use. +

+ +
+ ) : ( +
+
+

Your Repositories

+ +
+ + {repositories.length === 0 ? ( +

+ No repositories found. Make sure you've granted access to at least + one repository. +

+ ) : ( +
    + {repositories.map((repo) => ( +
  • +
    +
    +

    + + {repo.full_name} + +

    +

    + {repo.description || "No description"} +

    +
    + + {repo.private ? "Private" : "Public"} + + Default branch: {repo.default_branch} +
    +
    +
    +
  • + ))} +
+ )} +
+ )} + +
+

Manage GitHub App

+

+ You can manage your GitHub App installation, including adding or + removing repositories, through GitHub. +

+ + Manage on GitHub + +
+
+ ); +} diff --git a/apps/web/src/components/github-oauth-button.tsx b/apps/web/src/components/github-oauth-button.tsx new file mode 100644 index 00000000..a55c60aa --- /dev/null +++ b/apps/web/src/components/github-oauth-button.tsx @@ -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(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 ( + + ); + } + + if (isAuth) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/github/AgentTokenProvider.tsx b/apps/web/src/components/github/AgentTokenProvider.tsx new file mode 100644 index 00000000..a4f529c8 --- /dev/null +++ b/apps/web/src/components/github/AgentTokenProvider.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [installationId, setInstallationId] = useState(null); + + const fetchToken = async () => { + setIsLoading(true); + setError(null); + setCopied(false); + + try { + const response = await fetch("/api/github/token"); + + if (!response.ok) { + const errorData = await response.json(); + setError(errorData.error || "Failed to fetch token"); + setIsLoading(false); + return; + } + + const data = await response.json(); + setToken(data.token); + setInstallationId(data.installation_id); + setIsLoading(false); + } catch (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 ( +
+

GitHub App Not Installed

+

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

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

{error}

+
+ +
+ ); + } + + return ( +
+
+

GitHub Token for AI Agent

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

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

+
+
+ {token} +
+ +
+
+

How to use this token:

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

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

+
+ + ) : ( +

Loading token...

+ )} +
+ ); +} diff --git a/apps/web/src/components/github/InstallAppButton.tsx b/apps/web/src/components/github/InstallAppButton.tsx new file mode 100644 index 00000000..c6047e3a --- /dev/null +++ b/apps/web/src/components/github/InstallAppButton.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/github/RepositoryList.tsx b/apps/web/src/components/github/RepositoryList.tsx new file mode 100644 index 00000000..7bd096c8 --- /dev/null +++ b/apps/web/src/components/github/RepositoryList.tsx @@ -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 ( +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+ +
+ ); + } + + if (!isInstalled) { + return ( +
+
+

GitHub App Not Installed

+

+ You need to install our GitHub App to grant access to your + repositories. +

+ Install GitHub App +
+
+ ); + } + + return ( +
+
+

Your GitHub Repositories

+ +
+ + {repositories.length === 0 ? ( +
+

+ No repositories found. You may need to grant access to repositories + in GitHub. +

+ + Manage GitHub App permissions + +
+ ) : ( +
+ {repositories.map((repo) => ( +
+
+
+

+ + {repo.full_name} + +

+

+ {repo.description || "No description"} +

+
+ + {repo.private ? "Private" : "Public"} + + + Default branch: {repo.default_branch} + +
+
+
+
+ ))} +
+ )} + +
+

+ Need to add or remove repositories?{" "} + + Manage on GitHub + +

+
+
+ ); +} diff --git a/apps/web/src/components/icons/github.tsx b/apps/web/src/components/icons/github.tsx index 168f15c4..04aeda8c 100644 --- a/apps/web/src/components/icons/github.tsx +++ b/apps/web/src/components/icons/github.tsx @@ -5,6 +5,7 @@ export const GitHubSVG = ({ width = "100%", height = "100%" }) => ( width={width} height={height} xmlns="http://www.w3.org/2000/svg" + fill="currentColor" > GitHub diff --git a/apps/web/src/components/thread/index.tsx b/apps/web/src/components/thread/index.tsx index 37ff1846..f43a5b8f 100644 --- a/apps/web/src/components/thread/index.tsx +++ b/apps/web/src/components/thread/index.tsx @@ -22,7 +22,6 @@ import { SquarePen, XIcon, Plus, - CircleX, } from "lucide-react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -31,13 +30,6 @@ import { toast } from "sonner"; import { useMediaQuery } from "@/hooks/useMediaQuery"; import { Label } from "../ui/label"; 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 { ContentBlocksPreview } from "./ContentBlocksPreview"; import { @@ -46,6 +38,7 @@ import { ArtifactTitle, useArtifactContext, } from "./artifact"; +import { GitHubOAuthButton } from "../github-oauth-button"; function StickyToBottomContent(props: { content: ReactNode; @@ -88,30 +81,6 @@ function ScrollToBottom(props: { className?: string }) { ); } -function OpenGitHubRepo() { - return ( - - - - - - - - -

Open GitHub repo

-
-
-
- ); -} - export function Thread() { const [artifactContext, setArtifactContext] = useArtifactContext(); const [artifactOpen, closeArtifact] = useArtifactOpen(); @@ -337,7 +306,7 @@ export function Thread() { )}
- +
)} @@ -383,7 +352,7 @@ export function Thread() {
- +
Promise; +} + +export function useGitHubApp(): UseGitHubAppReturn { + const [isInstalled, setIsInstalled] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [repositories, setRepositories] = useState([]); + + 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, + }; +} diff --git a/apps/web/src/hooks/useGitHubToken.ts b/apps/web/src/hooks/useGitHubToken.ts new file mode 100644 index 00000000..43a61402 --- /dev/null +++ b/apps/web/src/hooks/useGitHubToken.ts @@ -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; +} + +/** + * 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(null); + const [installationId, setInstallationId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchToken = useCallback(async (): Promise => { + 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, + }; +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 00000000..a022b75c --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -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; +} diff --git a/apps/web/src/providers/Stream.tsx b/apps/web/src/providers/Stream.tsx index edc7af27..35bee5c6 100644 --- a/apps/web/src/providers/Stream.tsx +++ b/apps/web/src/providers/Stream.tsx @@ -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 { type Message } from "@langchain/langgraph-sdk"; import { @@ -12,9 +18,13 @@ import { useQueryState } from "nuqs"; import { LangGraphLogoSVG } from "@/components/icons/langgraph"; import { useThreads } from "./Thread"; 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 { Button } from "@/components/ui/button"; +import { GitHubSVG } from "@/components/icons/github"; +import { useGitHubToken } from "@/hooks/useGitHubToken"; + type TargetRepository = { owner: string; repo: string }; export type StateType = { messages: Message[]; @@ -46,17 +56,28 @@ const StreamSession = ({ children, apiUrl, assistantId, + githubToken, }: { children: ReactNode; apiUrl: string; assistantId: string; + githubToken: string; }) => { const [threadId, setThreadId] = useQueryState("threadId"); const { getThreads, setThreads } = useThreads(); + const githubAccessToken = + document.cookie + .split("; ") + .find((row) => row.startsWith("x-github_access_token=")) + ?.split("=")[1] || ""; const streamValue = useTypedStream({ apiUrl, assistantId, 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) => { @@ -90,6 +111,71 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ const assistantId: string | undefined = process.env.NEXT_PUBLIC_ASSISTANT_ID ?? ""; + const [isAuth, setIsAuth] = useState(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) { return (
@@ -154,10 +240,91 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ ); } + if (!isAuth) { + return ( +
+
+
+
+ +

+ Get started +

+
+

+ Connect your GitHub account to get started with Open SWE. +

+ +
+
+
+ ); + } + + // Step 2: GitHub App Installation (only show if authenticated but app not installed) + if ((isAuth && hasGitHubAppInstalled === false) || !githubToken) { + return ( +
+
+
+
+ +

+ One more step +

+
+
+ + 1. GitHub Login ✓ + + + + 2. Repository Access + +
+

+ Great! Now we need access to your GitHub repositories. Install our + GitHub App to grant access to specific repositories. +

+
+

+ You'll be redirected to GitHub where you can select which + repositories to grant access to. +

+
+ +
+
+
+ ); + } + return ( {children} diff --git a/apps/web/src/utils/github.ts b/apps/web/src/utils/github.ts new file mode 100644 index 00000000..7a6eab25 --- /dev/null +++ b/apps/web/src/utils/github.ts @@ -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 { + 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 { + 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; + }; +} diff --git a/yarn.lock b/yarn.lock index f654c5c6..3cf230ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1958,6 +1958,7 @@ __metadata: "@radix-ui/react-switch": ^1.1.3 "@radix-ui/react-tooltip": ^1.1.8 "@tailwindcss/postcss": ^4.0.13 + "@types/jsonwebtoken": ^9.0.9 "@types/lodash": ^4.17.16 "@types/node": ^22.13.5 "@types/react": ^19.0.8 @@ -1978,6 +1979,7 @@ __metadata: eslint-plugin-react-refresh: ^0.4.18 framer-motion: ^12.4.9 globals: ^15.14.0 + jsonwebtoken: ^9.0.2 katex: ^0.16.21 langgraph-nextjs-api-passthrough: ^0.0.4 lodash: ^4.17.21 @@ -3288,6 +3290,16 @@ __metadata: languageName: node 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": version: 0.16.7 resolution: "@types/katex@npm:0.16.7" @@ -4364,6 +4376,13 @@ __metadata: languageName: node 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": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -5218,6 +5237,15 @@ __metadata: languageName: node 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": version: 0.4.15 resolution: "eciesjs@npm:0.4.15" @@ -8181,6 +8209,24 @@ __metadata: languageName: node 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": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -8193,6 +8239,27 @@ __metadata: languageName: node 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": version: 0.16.22 resolution: "katex@npm:0.16.22" @@ -8531,6 +8598,48 @@ __metadata: languageName: node 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": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -8545,6 +8654,13 @@ __metadata: languageName: node 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": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -10917,7 +11033,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.2.0": +"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491