From 4c53b1a4e3033c06a78fac1485cd9b2341e4ab4d Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Mon, 7 Jul 2025 15:27:47 -0400 Subject: [PATCH] feat: include installation name in headers (#358) * feat: include installation id in headers * drop console logs * fix req from frontend --- .../graphs/manager/nodes/classify-message.ts | 17 +-- .../manager/nodes/create-new-session.ts | 19 +--- .../src/graphs/manager/nodes/start-planner.ts | 19 +--- .../src/graphs/planner/nodes/proposed-plan.ts | 15 +-- .../src/routes/github/issue-webhook.ts | 2 + apps/open-swe/src/security/auth.ts | 32 +++++- apps/open-swe/src/security/github.ts | 3 + apps/open-swe/src/security/utils.ts | 3 +- apps/open-swe/src/utils/default-headers.ts | 35 ++++++ apps/web/package.json | 1 + apps/web/src/app/api/[..._path]/route.ts | 103 +++++++++++++++--- packages/shared/src/constants.ts | 1 + packages/shared/src/open-swe/types.ts | 13 +++ yarn.lock | 1 + 14 files changed, 189 insertions(+), 75 deletions(-) create mode 100644 apps/open-swe/src/utils/default-headers.ts diff --git a/apps/open-swe/src/graphs/manager/nodes/classify-message.ts b/apps/open-swe/src/graphs/manager/nodes/classify-message.ts index 32dffe90..3323eed9 100644 --- a/apps/open-swe/src/graphs/manager/nodes/classify-message.ts +++ b/apps/open-swe/src/graphs/manager/nodes/classify-message.ts @@ -4,12 +4,6 @@ import { ManagerGraphUpdate, } from "@open-swe/shared/open-swe/manager/types"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; -import { - GITHUB_INSTALLATION_TOKEN_COOKIE, - GITHUB_TOKEN_COOKIE, - GITHUB_USER_ID_HEADER, - GITHUB_USER_LOGIN_HEADER, -} from "@open-swe/shared/constants"; import { BaseMessage, HumanMessage, @@ -32,6 +26,7 @@ import { extractIssueTitleAndContentFromMessage, formatContentForIssueBody, } from "../../../utils/github/issue-messages.js"; +import { getDefaultHeaders } from "../../../utils/default-headers.js"; // This should not be shown to the user if the programmer is running const PLAN_ROUTING_OPTION = `- plan: Call this route if the user's message is a complete request which you can use to kickoff a new planning session (only if one is not already running), or it's an entirely new request which you should also start a new planning session for (only if both the planner and programmer are not running). You may also call this route if the planner is running, and the user's message contains updated instructions, or additional context which may be relevant/helpful to the planner.`; @@ -219,15 +214,7 @@ export async function classifyMessage( } const langGraphClient = createLangGraphClient({ - defaultHeaders: { - [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", - [GITHUB_INSTALLATION_TOKEN_COOKIE]: - config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", - [GITHUB_USER_ID_HEADER]: - config.configurable?.[GITHUB_USER_ID_HEADER] ?? "", - [GITHUB_USER_LOGIN_HEADER]: - config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "", - }, + defaultHeaders: getDefaultHeaders(config), }); const [programmerThread, plannerThread] = await Promise.all([ diff --git a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts index 2a39b01e..01b6c0ec 100644 --- a/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts +++ b/apps/open-swe/src/graphs/manager/nodes/create-new-session.ts @@ -5,13 +5,7 @@ import { ManagerGraphUpdate, } from "@open-swe/shared/open-swe/manager/types"; import { createIssueTitleAndBodyFromMessages } from "../utils/generate-issue-fields.js"; -import { - GITHUB_INSTALLATION_TOKEN_COOKIE, - GITHUB_TOKEN_COOKIE, - GITHUB_USER_ID_HEADER, - GITHUB_USER_LOGIN_HEADER, - MANAGER_GRAPH_ID, -} from "@open-swe/shared/constants"; +import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; import { createIssue } from "../../../utils/github/api.js"; import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js"; @@ -24,6 +18,7 @@ import { formatContentForIssueBody, } from "../../../utils/github/issue-messages.js"; import { getBranchName } from "../../../utils/github/git.js"; +import { getDefaultHeaders } from "../../../utils/default-headers.js"; /** * Create new manager session. @@ -74,15 +69,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`, ]; const langGraphClient = createLangGraphClient({ - defaultHeaders: { - [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", - [GITHUB_INSTALLATION_TOKEN_COOKIE]: - config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", - [GITHUB_USER_ID_HEADER]: - config.configurable?.[GITHUB_USER_ID_HEADER] ?? "", - [GITHUB_USER_LOGIN_HEADER]: - config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "", - }, + defaultHeaders: getDefaultHeaders(config), }); const newManagerThreadId = uuidv4(); diff --git a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts index dd91ce35..38084e7c 100644 --- a/apps/open-swe/src/graphs/manager/nodes/start-planner.ts +++ b/apps/open-swe/src/graphs/manager/nodes/start-planner.ts @@ -5,16 +5,11 @@ import { ManagerGraphUpdate, } from "@open-swe/shared/open-swe/manager/types"; import { createLangGraphClient } from "../../../utils/langgraph-client.js"; -import { - GITHUB_INSTALLATION_TOKEN_COOKIE, - GITHUB_TOKEN_COOKIE, - GITHUB_USER_ID_HEADER, - GITHUB_USER_LOGIN_HEADER, - PLANNER_GRAPH_ID, -} from "@open-swe/shared/constants"; +import { PLANNER_GRAPH_ID } from "@open-swe/shared/constants"; import { createLogger, LogLevel } from "../../../utils/logger.js"; import { getBranchName } from "../../../utils/github/git.js"; import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types"; +import { getDefaultHeaders } from "../../../utils/default-headers.js"; const logger = createLogger(LogLevel.INFO, "StartPlanner"); @@ -27,15 +22,7 @@ export async function startPlanner( config: GraphConfig, ): Promise { const langGraphClient = createLangGraphClient({ - defaultHeaders: { - [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", - [GITHUB_INSTALLATION_TOKEN_COOKIE]: - config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", - [GITHUB_USER_ID_HEADER]: - config.configurable?.[GITHUB_USER_ID_HEADER] ?? "", - [GITHUB_USER_LOGIN_HEADER]: - config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "", - }, + defaultHeaders: getDefaultHeaders(config), }); const plannerThreadId = state.plannerSession?.threadId ?? uuidv4(); diff --git a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts index 2ea577b7..64e70a80 100644 --- a/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts +++ b/apps/open-swe/src/graphs/planner/nodes/proposed-plan.ts @@ -16,10 +16,6 @@ import { startSandbox } from "../../../utils/sandbox.js"; import { createNewTask } from "@open-swe/shared/open-swe/tasks"; import { getUserRequest } from "../../../utils/user-request.js"; import { - GITHUB_INSTALLATION_TOKEN_COOKIE, - GITHUB_TOKEN_COOKIE, - GITHUB_USER_ID_HEADER, - GITHUB_USER_LOGIN_HEADER, PLAN_INTERRUPT_ACTION_TITLE, PLAN_INTERRUPT_DELIMITER, DO_NOT_RENDER_ID_PREFIX, @@ -36,6 +32,7 @@ import { ACCEPTED_PLAN_NODE_ID, CustomNodeEvent, } from "@open-swe/shared/open-swe/custom-node-events"; +import { getDefaultHeaders } from "../../../utils/default-headers.js"; const logger = createLogger(LogLevel.INFO, "ProposedPlan"); @@ -77,15 +74,7 @@ async function startProgrammerRun(input: { }) { const { runInput, state, config, newMessages } = input; const langGraphClient = createLangGraphClient({ - defaultHeaders: { - [GITHUB_TOKEN_COOKIE]: config.configurable?.[GITHUB_TOKEN_COOKIE] ?? "", - [GITHUB_INSTALLATION_TOKEN_COOKIE]: - config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE] ?? "", - [GITHUB_USER_ID_HEADER]: - config.configurable?.[GITHUB_USER_ID_HEADER] ?? "", - [GITHUB_USER_LOGIN_HEADER]: - config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? "", - }, + defaultHeaders: getDefaultHeaders(config), }); const programmerThreadId = uuidv4(); diff --git a/apps/open-swe/src/routes/github/issue-webhook.ts b/apps/open-swe/src/routes/github/issue-webhook.ts index 0f06e02d..394a941a 100644 --- a/apps/open-swe/src/routes/github/issue-webhook.ts +++ b/apps/open-swe/src/routes/github/issue-webhook.ts @@ -6,6 +6,7 @@ import { GitHubApp } from "../../utils/github-app.js"; import { Webhooks } from "@octokit/webhooks"; import { createLangGraphClient } from "../../utils/langgraph-client.js"; import { + GITHUB_INSTALLATION_NAME, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_USER_ID_HEADER, GITHUB_USER_LOGIN_HEADER, @@ -121,6 +122,7 @@ webhooks.on("issues.labeled", async ({ payload }) => { token, process.env.GITHUB_TOKEN_ENCRYPTION_KEY, ), + [GITHUB_INSTALLATION_NAME]: issueData.owner, [GITHUB_USER_ID_HEADER]: issueData.userId.toString(), [GITHUB_USER_LOGIN_HEADER]: issueData.userLogin, }, diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts index 5986dc67..0c0cf075 100644 --- a/apps/open-swe/src/security/auth.ts +++ b/apps/open-swe/src/security/auth.ts @@ -5,6 +5,7 @@ import { verifyGithubUserId, } from "@open-swe/shared/github/verify-user"; import { + GITHUB_INSTALLATION_NAME, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_TOKEN_COOKIE, GITHUB_USER_ID_HEADER, @@ -14,14 +15,31 @@ import { decryptGitHubToken } from "@open-swe/shared/crypto"; import { verifyGitHubWebhookOrThrow } from "./github.js"; import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js"; +// TODO: Export from LangGraph SDK +export interface BaseAuthReturn { + is_authenticated?: boolean; + display_name?: string; + identity: string; + permissions: string[]; +} + +interface AuthenticateReturn extends BaseAuthReturn { + metadata: { + installation_name: string; + }; +} + export const auth = new Auth() - .authenticate(async (request: Request) => { + .authenticate(async (request: Request) => { if (request.method === "OPTIONS") { return { identity: "anonymous", permissions: [], is_authenticated: false, display_name: "CORS Preflight", + metadata: { + installation_name: "n/a", + }, }; } @@ -38,6 +56,15 @@ export const auth = new Auth() ); } + const installationNameHeader = request.headers.get( + GITHUB_INSTALLATION_NAME, + ); + if (!installationNameHeader) { + throw new HTTPException(401, { + message: "GitHub installation name 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. const encryptedInstallationToken = request.headers.get( @@ -84,6 +111,9 @@ export const auth = new Auth() identity: user.id.toString(), is_authenticated: true, display_name: user.login, + metadata: { + installation_name: installationNameHeader, + }, permissions: [ "threads:create", "threads:create_run", diff --git a/apps/open-swe/src/security/github.ts b/apps/open-swe/src/security/github.ts index 2e5dd469..28af7bdf 100644 --- a/apps/open-swe/src/security/github.ts +++ b/apps/open-swe/src/security/github.ts @@ -38,6 +38,9 @@ export async function verifyGitHubWebhookOrThrow(request: Request) { identity: "x-internal-github-bot", is_authenticated: true, display_name: "GitHub Bot", + metadata: { + installation_name: "n/a", + }, permissions: [ "threads:create", "threads:create_run", diff --git a/apps/open-swe/src/security/utils.ts b/apps/open-swe/src/security/utils.ts index 7b7a78e6..3f02b4c4 100644 --- a/apps/open-swe/src/security/utils.ts +++ b/apps/open-swe/src/security/utils.ts @@ -16,7 +16,7 @@ export function createOwnerFilter(user: { identity: string }) { // Helper function for create operations that set metadata export function createWithOwnerMetadata( value: any, - user: { identity: string }, + user: { identity: string; metadata: { installation_name: string } }, ) { if (isStudioUser(user.identity)) { return; @@ -24,5 +24,6 @@ export function createWithOwnerMetadata( value.metadata ??= {}; value.metadata.owner = user.identity; + value.metadata.installation_name = user.metadata.installation_name; return { owner: user.identity }; } diff --git a/apps/open-swe/src/utils/default-headers.ts b/apps/open-swe/src/utils/default-headers.ts new file mode 100644 index 00000000..ebfe9cb9 --- /dev/null +++ b/apps/open-swe/src/utils/default-headers.ts @@ -0,0 +1,35 @@ +import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, + GITHUB_USER_ID_HEADER, + GITHUB_USER_LOGIN_HEADER, + GITHUB_INSTALLATION_NAME, +} from "@open-swe/shared/constants"; + +export function getDefaultHeaders(config: GraphConfig) { + const githubInstallationTokenCookie = + config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE]; + const githubInstallationName = + config.configurable?.[GITHUB_INSTALLATION_NAME]; + + if (!githubInstallationTokenCookie || !githubInstallationName) { + throw new Error("Missing required headers"); + } + + const githubTokenCookie = config.configurable?.[GITHUB_TOKEN_COOKIE] ?? ""; + const githubUserIdHeader = config.configurable?.[GITHUB_USER_ID_HEADER] ?? ""; + const githubUserLoginHeader = + config.configurable?.[GITHUB_USER_LOGIN_HEADER] ?? ""; + + return { + // Required headers + [GITHUB_INSTALLATION_TOKEN_COOKIE]: githubInstallationTokenCookie, + [GITHUB_INSTALLATION_NAME]: githubInstallationName, + + // Optional headers + [GITHUB_TOKEN_COOKIE]: githubTokenCookie, + [GITHUB_USER_ID_HEADER]: githubUserIdHeader, + [GITHUB_USER_LOGIN_HEADER]: githubUserLoginHeader, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index add86a4a..8d052f5b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@langchain/core": "^0.3.57", "@langchain/langgraph": "^0.3.3", "@langchain/langgraph-sdk": "^0.0.85", + "@octokit/app": "^16.0.1", "@open-swe/shared": "*", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.3", diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts index e01abceb..cc960052 100644 --- a/apps/web/src/app/api/[..._path]/route.ts +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -3,10 +3,13 @@ import { GITHUB_TOKEN_COOKIE, GITHUB_INSTALLATION_ID_COOKIE, GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_INSTALLATION_NAME, } from "@open-swe/shared/constants"; import { encryptGitHubToken } from "@open-swe/shared/crypto"; import { NextRequest } from "next/server"; import { getInstallationToken } from "@/utils/github"; +import { App } from "@octokit/app"; +import { validate } from "uuid"; function getGitHubAccessTokenOrThrow( req: NextRequest, @@ -24,19 +27,9 @@ function getGitHubAccessTokenOrThrow( } async function getGitHubInstallationTokenOrThrow( - req: NextRequest, + installationIdCookie: string, encryptionKey: string, ): Promise { - const installationIdCookie = req.cookies.get( - GITHUB_INSTALLATION_ID_COOKIE, - )?.value; - - if (!installationIdCookie) { - throw new Error( - "No GitHub installation ID found. GitHub App must be installed first.", - ); - } - const appId = process.env.GITHUB_APP_ID; const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY; @@ -52,6 +45,77 @@ async function getGitHubInstallationTokenOrThrow( return encryptGitHubToken(token, encryptionKey); } +async function getInstallationName(installationId: string) { + if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_APP_PRIVATE_KEY) { + throw new Error("GitHub App ID or Private App Key is not configured."); + } + const app = new App({ + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY, + }); + + // Get installation details + const { data } = await app.octokit.request( + "GET /app/installations/{installation_id}", + { + installation_id: Number(installationId), + }, + ); + + const installationName = + data.account && "name" in data.account + ? data.account.name + : data.account?.login; + + return installationName ?? ""; +} + +const isNewRunRequest = (reqUrlStr: string, reqMethod: string) => { + try { + const reqPathnameParts = new URL(reqUrlStr).pathname.split("/"); + const isCreateNewRunReq = + reqPathnameParts?.[1] === "api" && + reqPathnameParts?.[2] === "threads" && + validate(reqPathnameParts?.[3]) && + reqPathnameParts?.[4] === "runs" && + reqPathnameParts.length === 5 && + reqMethod.toLowerCase() === "post"; + const isStreamRunReq = + reqPathnameParts?.[1] === "api" && + reqPathnameParts?.[2] === "threads" && + validate(reqPathnameParts?.[3]) && + reqPathnameParts?.[4] === "runs" && + validate(reqPathnameParts?.[5]) && + reqPathnameParts?.[6]?.startsWith("stream") && + reqMethod.toLowerCase() === "get"; + return isCreateNewRunReq || isStreamRunReq; + } catch { + return false; + } +}; + +async function getInstallationNameFromReq( + req: Request, + installationId: string, +): Promise { + try { + const requestJson = await req.json(); + const installationName = requestJson?.input?.targetRepository?.owner; + return installationName; + } catch { + // no-op + } + + try { + if (isNewRunRequest(req.url, req.method)) { + return await getInstallationName(installationId); + } + return ""; + } catch { + return ""; + } +} + // This file acts as a proxy for requests to your LangGraph server. // Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information. @@ -67,11 +131,24 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } = "GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required", ); } + const installationIdCookie = req.cookies.get( + GITHUB_INSTALLATION_ID_COOKIE, + )?.value; + + if (!installationIdCookie) { + throw new Error( + "No GitHub installation ID found. GitHub App must be installed first.", + ); + } + const [installationToken, installationName] = await Promise.all([ + getGitHubInstallationTokenOrThrow(installationIdCookie, encryptionKey), + getInstallationNameFromReq(req.clone(), installationIdCookie), + ]); return { [GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey), - [GITHUB_INSTALLATION_TOKEN_COOKIE]: - await getGitHubInstallationTokenOrThrow(req, encryptionKey), + [GITHUB_INSTALLATION_TOKEN_COOKIE]: installationToken, + [GITHUB_INSTALLATION_NAME]: installationName, }; }, }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 23638655..494b6e13 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,6 +7,7 @@ export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan"; // Prefix the access token with `x-` so that it's included in requests to the LangGraph server. export const GITHUB_TOKEN_COOKIE = "x-github-access-token"; export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token"; +export const GITHUB_INSTALLATION_NAME = "x-github-installation-name"; 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/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 95cc4927..9d0f2f67 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -14,6 +14,7 @@ import { type RemoveUIMessage, } from "@langchain/langgraph-sdk/react-ui"; import { + GITHUB_INSTALLATION_NAME, GITHUB_INSTALLATION_TOKEN_COOKIE, GITHUB_TOKEN_COOKIE, GITHUB_USER_ID_HEADER, @@ -407,6 +408,11 @@ export const GraphConfigurationMetadata: { type: "hidden", }, }, + [GITHUB_INSTALLATION_NAME]: { + x_open_swe_ui_config: { + type: "hidden", + }, + }, }; export const GraphConfiguration = z.object({ @@ -576,6 +582,13 @@ export const GraphConfiguration = z.object({ .string() .optional() .langgraph.metadata(GraphConfigurationMetadata[GITHUB_USER_LOGIN_HEADER]), + /** + * The installation name of the GitHub app. Required when creating runs triggered by a bot (e.g. GitHub issue) + */ + [GITHUB_INSTALLATION_NAME]: z + .string() + .optional() + .langgraph.metadata(GraphConfigurationMetadata[GITHUB_INSTALLATION_NAME]), }); export type GraphConfig = LangGraphRunnableConfig< diff --git a/yarn.lock b/yarn.lock index c48cdc0f..8fa13fb1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2479,6 +2479,7 @@ __metadata: "@langchain/core": ^0.3.57 "@langchain/langgraph": ^0.3.3 "@langchain/langgraph-sdk": ^0.0.85 + "@octokit/app": ^16.0.1 "@open-swe/shared": "*" "@radix-ui/react-alert-dialog": ^1.1.14 "@radix-ui/react-avatar": ^1.1.3