diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts index 6192baeb..1d447274 100644 --- a/apps/open-swe/src/security/auth.ts +++ b/apps/open-swe/src/security/auth.ts @@ -6,6 +6,7 @@ import { } from "@open-swe/shared/github/verify-user"; import { API_KEY_REQUIRED_MESSAGE, + GITHUB_INSTALLATION_ID, GITHUB_INSTALLATION_NAME, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_TOKEN_COOKIE, @@ -143,6 +144,12 @@ export const auth = new Auth() message: "GitHub installation name header missing", }); } + const installationIdHeader = request.headers.get(GITHUB_INSTALLATION_ID); + if (!installationIdHeader) { + throw new HTTPException(401, { + message: "GitHub installation ID header missing", + }); + } // We don't do anything with this token right now, but still confirm it // exists as it will cause issues later on if it's not present. diff --git a/apps/open-swe/src/utils/default-headers.ts b/apps/open-swe/src/utils/default-headers.ts index 3b809265..fc8e9845 100644 --- a/apps/open-swe/src/utils/default-headers.ts +++ b/apps/open-swe/src/utils/default-headers.ts @@ -6,6 +6,7 @@ import { GITHUB_USER_LOGIN_HEADER, GITHUB_INSTALLATION_NAME, GITHUB_PAT, + GITHUB_INSTALLATION_ID, } from "@open-swe/shared/constants"; export function getDefaultHeaders(config: GraphConfig): Record { @@ -22,8 +23,13 @@ export function getDefaultHeaders(config: GraphConfig): Record { config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE]; const githubInstallationName = config.configurable?.[GITHUB_INSTALLATION_NAME]; + const githubInstallationId = config.configurable?.[GITHUB_INSTALLATION_ID]; - if (!githubInstallationTokenCookie || !githubInstallationName) { + if ( + !githubInstallationTokenCookie || + !githubInstallationName || + !githubInstallationId + ) { throw new Error("Missing required headers"); } @@ -36,6 +42,7 @@ export function getDefaultHeaders(config: GraphConfig): Record { // Required headers [GITHUB_INSTALLATION_TOKEN_COOKIE]: githubInstallationTokenCookie, [GITHUB_INSTALLATION_NAME]: githubInstallationName, + [GITHUB_INSTALLATION_ID]: githubInstallationId, // Optional headers [GITHUB_TOKEN_COOKIE]: githubTokenCookie, diff --git a/apps/open-swe/src/utils/github-tokens.ts b/apps/open-swe/src/utils/github-tokens.ts index 50148d86..a0ac7ca0 100644 --- a/apps/open-swe/src/utils/github-tokens.ts +++ b/apps/open-swe/src/utils/github-tokens.ts @@ -1,6 +1,7 @@ import { GITHUB_TOKEN_COOKIE, GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_INSTALLATION_ID, } from "@open-swe/shared/constants"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; import { decryptSecret } from "@open-swe/shared/crypto"; @@ -9,6 +10,7 @@ import { getGitHubPatFromConfig } from "./github-pat.js"; export function getGitHubTokensFromConfig(config: GraphConfig): { githubAccessToken: string; githubInstallationToken: string; + installationId: string; } { if (!config.configurable) { throw new Error("No configurable object found in graph config."); @@ -22,12 +24,20 @@ export function getGitHubTokensFromConfig(config: GraphConfig): { const isProd = process.env.NODE_ENV === "production"; + const installationId = config.configurable[GITHUB_INSTALLATION_ID]; + if (!installationId) { + throw new Error( + `Missing required ${GITHUB_INSTALLATION_ID} in configuration.`, + ); + } + const githubPat = getGitHubPatFromConfig(config.configurable, encryptionKey); if (githubPat && !isProd) { // check for PAT-only mode return { githubAccessToken: githubPat, githubInstallationToken: githubPat, + installationId, }; } @@ -49,5 +59,5 @@ export function getGitHubTokensFromConfig(config: GraphConfig): { encryptionKey, ); - return { githubAccessToken, githubInstallationToken }; + return { githubAccessToken, githubInstallationToken, installationId }; } diff --git a/apps/open-swe/src/utils/github/api.ts b/apps/open-swe/src/utils/github/api.ts index 48680002..7c3f7d40 100644 --- a/apps/open-swe/src/utils/github/api.ts +++ b/apps/open-swe/src/utils/github/api.ts @@ -2,43 +2,114 @@ import { Octokit } from "@octokit/rest"; import { createLogger, LogLevel } from "../logger.js"; import { GitHubIssue, GitHubIssueComment, GitHubPullRequest } from "./types.js"; import { getOpenSWELabel } from "./label.js"; +import { getInstallationToken } from "@open-swe/shared/github/auth"; +import { getConfig } from "@langchain/langgraph"; +import { GITHUB_INSTALLATION_ID } from "@open-swe/shared/constants"; +import { updateConfig } from "../update-config.js"; +import { encryptSecret } from "@open-swe/shared/crypto"; const logger = createLogger(LogLevel.INFO, "GitHub-API"); +async function getInstallationTokenAndUpdateConfig() { + try { + const config = getConfig(); + const encryptionSecret = process.env.SECRETS_ENCRYPTION_KEY; + if (!encryptionSecret) { + throw new Error("Secrets encryption key not found"); + } + + const installationId = config.configurable?.[GITHUB_INSTALLATION_ID]; + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; + if (!installationId || !appId || !privateKey) { + throw new Error( + "GitHub installation ID, app ID, or private key not found", + ); + } + + const token = await getInstallationToken(installationId, appId, privateKey); + const encryptedToken = encryptSecret(token, encryptionSecret); + updateConfig(GITHUB_INSTALLATION_ID, encryptedToken); + return token; + } catch (e) { + logger.error("Failed to get installation token and update config", { + error: e, + }); + return null; + } +} + +/** + * Generic utility for handling GitHub API calls with automatic retry on 401 errors + */ +async function withGitHubRetry( + operation: (token: string) => Promise, + initialToken: string, + errorMessage: string, + additionalLogFields?: Record, + numRetries = 1, +): Promise { + try { + return await operation(initialToken); + } catch (error) { + const errorFields = + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : {}; + + // Retry with a max retries of 2 + if (errorFields && errorFields.message?.includes("401") && numRetries < 2) { + const token = await getInstallationTokenAndUpdateConfig(); + if (!token) { + return null; + } + return withGitHubRetry( + operation, + token, + errorMessage, + additionalLogFields, + numRetries + 1, + ); + } + + logger.error(errorMessage, { + ...additionalLogFields, + ...(errorFields ?? { error }), + }); + return null; + } +} + async function getExistingPullRequest( owner: string, repo: string, branchName: string, githubToken: string, + numRetries = 1, ) { - try { - const octokit = new Octokit({ - auth: githubToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - const { data: pullRequests } = await octokit.pulls.list({ - owner, - repo, - head: branchName, - }); + const { data: pullRequests } = await octokit.pulls.list({ + owner, + repo, + head: branchName, + }); - if (pullRequests?.[0]) { - return pullRequests[0]; - } - } catch (e) { - logger.error(`Failed to get existing pull request`, { - branch: branchName, - owner, - repo, - ...(e instanceof Error && { - name: e.name, - message: e.message, - stack: e.stack, - }), - }); - } - - return null; + return pullRequests?.[0] || null; + }, + githubToken, + "Failed to get existing pull request", + { branch: branchName, owner, repo }, + numRetries, + ); } export async function createPullRequest({ @@ -162,30 +233,33 @@ export async function getIssue({ repo, issueNumber, githubInstallationToken, + numRetries = 1, }: { owner: string; repo: string; issueNumber: number; githubInstallationToken: string; + numRetries?: number; }): Promise { - const octokit = new Octokit({ - auth: githubInstallationToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - try { - const { data: issue } = await octokit.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); + const { data: issue } = await octokit.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); - return issue; - } catch (error) { - logger.error(`Failed to get issue`, { - error, - }); - return null; - } + return issue; + }, + githubInstallationToken, + "Failed to get issue", + undefined, + numRetries, + ); } export async function getIssueComments({ @@ -194,39 +268,42 @@ export async function getIssueComments({ issueNumber, githubInstallationToken, filterBotComments = true, + numRetries = 1, }: { owner: string; repo: string; issueNumber: number; githubInstallationToken: string; filterBotComments?: boolean; + numRetries?: number; }): Promise { - const octokit = new Octokit({ - auth: githubInstallationToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - try { - const { data: comments } = await octokit.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - }); + const { data: comments } = await octokit.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + }); - if (!filterBotComments) { - return comments; - } + if (!filterBotComments) { + return comments; + } - return comments.filter((comment) => { - return ( - comment.user?.type !== "Bot" || !comment.user?.name?.includes("[bot]") - ); - }); - } catch (error) { - logger.error(`Failed to get issue comments`, { - error, - }); - return null; - } + return comments.filter((comment) => { + return ( + comment.user?.type !== "Bot" || !comment.user?.name?.includes("[bot]") + ); + }); + }, + githubInstallationToken, + "Failed to get issue comments", + undefined, + numRetries, + ); } export async function createIssue({ @@ -256,9 +333,15 @@ export async function createIssue({ return issue; } catch (error) { - logger.error(`Failed to create issue`, { - error, - }); + const errorFields = + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : { error }; + logger.error(`Failed to create issue`, errorFields); return null; } } @@ -270,6 +353,7 @@ export async function updateIssue({ githubInstallationToken, body, title, + numRetries = 1, }: { owner: string; repo: string; @@ -277,31 +361,33 @@ export async function updateIssue({ githubInstallationToken: string; body?: string; title?: string; + numRetries?: number; }) { if (!body && !title) { throw new Error("Must provide either body or title to update issue"); } - const octokit = new Octokit({ - auth: githubInstallationToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - try { - const { data: issue } = await octokit.issues.update({ - owner, - repo, - issue_number: issueNumber, - ...(body && { body }), - ...(title && { title }), - }); + const { data: issue } = await octokit.issues.update({ + owner, + repo, + issue_number: issueNumber, + ...(body && { body }), + ...(title && { title }), + }); - return issue; - } catch (error) { - logger.error(`Failed to update issue`, { - error, - }); - return null; - } + return issue; + }, + githubInstallationToken, + "Failed to update issue", + undefined, + numRetries, + ); } export async function createIssueComment({ @@ -310,6 +396,7 @@ export async function createIssueComment({ issueNumber, body, githubToken, + numRetries = 1, }: { owner: string; repo: string; @@ -320,26 +407,28 @@ export async function createIssueComment({ * or an access token if creating a user comment. */ githubToken: string; + numRetries?: number; }): Promise { - const octokit = new Octokit({ - auth: githubToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - try { - const { data: comment } = await octokit.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body, - }); + const { data: comment } = await octokit.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); - return comment; - } catch (error) { - logger.error(`Failed to create issue comment`, { - error, - }); - return null; - } + return comment; + }, + githubToken, + "Failed to create issue comment", + undefined, + numRetries, + ); } export async function updateIssueComment({ @@ -348,30 +437,33 @@ export async function updateIssueComment({ commentId, body, githubInstallationToken, + numRetries = 1, }: { owner: string; repo: string; commentId: number; body: string; githubInstallationToken: string; + numRetries?: number; }): Promise { - const octokit = new Octokit({ - auth: githubInstallationToken, - }); + return withGitHubRetry( + async (token: string) => { + const octokit = new Octokit({ + auth: token, + }); - try { - const { data: comment } = await octokit.issues.updateComment({ - owner, - repo, - comment_id: commentId, - body, - }); + const { data: comment } = await octokit.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body, + }); - return comment; - } catch (error) { - logger.error(`Failed to update issue comment`, { - error, - }); - return null; - } + return comment; + }, + githubInstallationToken, + "Failed to update issue comment", + undefined, + numRetries, + ); } diff --git a/apps/open-swe/src/utils/update-config.ts b/apps/open-swe/src/utils/update-config.ts new file mode 100644 index 00000000..605432c0 --- /dev/null +++ b/apps/open-swe/src/utils/update-config.ts @@ -0,0 +1,14 @@ +import { getConfig } from "@langchain/langgraph"; + +export function updateConfig(key: string, value: unknown) { + try { + const config = getConfig(); + if (!config.configurable) { + throw new Error("No configurable object found"); + } + config.configurable[key] = value; + } catch { + // no-op + return; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 93500573..9afcbb62 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,7 +46,6 @@ "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.1.3", "lodash": "^4.17.21", diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts index 11024ccf..52fef185 100644 --- a/apps/web/src/app/api/[..._path]/route.ts +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -4,6 +4,7 @@ import { GITHUB_INSTALLATION_ID_COOKIE, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_INSTALLATION_NAME, + GITHUB_INSTALLATION_ID, } from "@open-swe/shared/constants"; import { getGitHubInstallationTokenOrThrow, @@ -72,6 +73,7 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } = [GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey), [GITHUB_INSTALLATION_TOKEN_COOKIE]: installationToken, [GITHUB_INSTALLATION_NAME]: installationName, + [GITHUB_INSTALLATION_ID]: installationIdCookie, }; }, }); diff --git a/apps/web/src/app/api/[..._path]/utils.ts b/apps/web/src/app/api/[..._path]/utils.ts index 0bdbbb2f..fb720ac7 100644 --- a/apps/web/src/app/api/[..._path]/utils.ts +++ b/apps/web/src/app/api/[..._path]/utils.ts @@ -1,4 +1,4 @@ -import { getInstallationToken } from "@/utils/github"; +import { getInstallationToken } from "@open-swe/shared/github/auth"; import { App } from "@octokit/app"; import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; import { encryptSecret } from "@open-swe/shared/crypto"; diff --git a/apps/web/src/app/api/github/proxy/[..._path]/route.ts b/apps/web/src/app/api/github/proxy/[..._path]/route.ts index a05dbb4e..a44096fb 100644 --- a/apps/web/src/app/api/github/proxy/[..._path]/route.ts +++ b/apps/web/src/app/api/github/proxy/[..._path]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getInstallationToken } from "../../../../../utils/github"; // Adjusted path +import { getInstallationToken } from "@open-swe/shared/github/auth"; import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants"; const GITHUB_API_URL = "https://api.github.com"; diff --git a/apps/web/src/app/api/github/repositories/route.ts b/apps/web/src/app/api/github/repositories/route.ts index 99d7bf52..dcf66be0 100644 --- a/apps/web/src/app/api/github/repositories/route.ts +++ b/apps/web/src/app/api/github/repositories/route.ts @@ -1,9 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { - getInstallationToken, - getInstallationRepositories, - Repository, -} from "@/utils/github"; +import { getInstallationToken } from "@open-swe/shared/github/auth"; +import { getInstallationRepositories, Repository } from "@/utils/github"; import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants"; /** diff --git a/apps/web/src/app/api/github/token/route.ts b/apps/web/src/app/api/github/token/route.ts index 1524cd81..364de581 100644 --- a/apps/web/src/app/api/github/token/route.ts +++ b/apps/web/src/app/api/github/token/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getInstallationToken } from "@/utils/github"; +import { getInstallationToken } from "@open-swe/shared/github/auth"; import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants"; /** diff --git a/apps/web/src/utils/github.ts b/apps/web/src/utils/github.ts index 927be68e..a9c72820 100644 --- a/apps/web/src/utils/github.ts +++ b/apps/web/src/utils/github.ts @@ -1,5 +1,3 @@ -import * as jwt from "jsonwebtoken"; - function getBaseApiUrl(): string { let baseApiUrl = new URL( process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api", @@ -8,54 +6,6 @@ function getBaseApiUrl(): string { return baseApiUrl; } -/** - * 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": "OpenSWE-Agent", - }, - }, - ); - - 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 */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 8d5fce49..9e40838d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -21,6 +21,7 @@ "@langchain/langgraph": "^0.3.8", "@langchain/langgraph-sdk": "^0.0.95", "@octokit/rest": "^22.0.0", + "jsonwebtoken": "^9.0.2", "zod": "^3.25.32" }, "devDependencies": { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index de59bbc7..b74ef1c3 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -10,6 +10,7 @@ export const GITHUB_TOKEN_COOKIE = "x-github-access-token"; export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token"; export const GITHUB_INSTALLATION_NAME = "x-github-installation-name"; export const GITHUB_PAT = "x-github-pat"; +export const GITHUB_INSTALLATION_ID = "x-github-installation-id"; export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-"; export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state"; diff --git a/packages/shared/src/github/auth.ts b/packages/shared/src/github/auth.ts new file mode 100644 index 00000000..fbea3f8b --- /dev/null +++ b/packages/shared/src/github/auth.ts @@ -0,0 +1,37 @@ +import { generateJWT } from "../jwt.js"; + +/** + * 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": "OpenSWE-Agent", + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to get installation token: ${JSON.stringify(errorData)}`, + ); + } + + const data = await response.json(); + if (typeof data !== "object" || !data || !("token" in data)) { + throw new Error("No token returned after fetching installation token"); + } + return data.token as string; +} diff --git a/packages/shared/src/jwt.ts b/packages/shared/src/jwt.ts new file mode 100644 index 00000000..ce7b2a81 --- /dev/null +++ b/packages/shared/src/jwt.ts @@ -0,0 +1,16 @@ +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" }); +} diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index a7bb0b1d..79c754b5 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -21,6 +21,7 @@ import { GITHUB_USER_LOGIN_HEADER, GITHUB_PAT, DEFAULT_MCP_SERVERS, + GITHUB_INSTALLATION_ID, } from "../constants.js"; import { withLangGraph } from "@langchain/langgraph/zod"; import { BaseMessage } from "@langchain/core/messages"; @@ -417,6 +418,11 @@ export const GraphConfigurationMetadata: { type: "hidden", }, }, + [GITHUB_INSTALLATION_ID]: { + x_open_swe_ui_config: { + type: "hidden", + }, + }, [GITHUB_PAT]: { x_open_swe_ui_config: { type: "hidden", @@ -553,6 +559,12 @@ export const GraphConfiguration = z.object({ [GITHUB_INSTALLATION_NAME]: withLangGraph(z.string().optional(), { metadata: GraphConfigurationMetadata[GITHUB_INSTALLATION_NAME], }), + /** + * The installation ID of the GitHub app the user is using to create the run. + */ + [GITHUB_INSTALLATION_ID]: withLangGraph(z.string().optional(), { + metadata: GraphConfigurationMetadata[GITHUB_INSTALLATION_ID], + }), /** * GitHub Personal Access Token. Used for simpler authentication in environments like evals * where GitHub App installation tokens are not available or needed. diff --git a/yarn.lock b/yarn.lock index 618180eb..45d422c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4194,6 +4194,7 @@ __metadata: eslint-plugin-import: ^2.27.5 eslint-plugin-no-instanceof: ^1.0.1 eslint-plugin-prettier: ^4.2.1 + jsonwebtoken: ^9.0.2 prettier: ^3.5.2 typescript: ~5.7.2 typescript-eslint: ^8.22.0 @@ -4249,7 +4250,6 @@ __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.1.3 lodash: ^4.17.21