feat: include installation name in headers (#358)

* feat: include installation id in headers

* drop console logs

* fix req from frontend
This commit is contained in:
Brace Sproul 2025-07-07 15:27:47 -04:00 • committed by GitHub
parent 19206f0c34
commit 4c53b1a4e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 189 additions and 75 deletions

View file

@ -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([

View file

@ -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();

View file

@ -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<ManagerGraphUpdate> {
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();

View file

@ -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();

View file

@ -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,
},

View file

@ -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<AuthenticateReturn>(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",

View file

@ -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",

View file

@ -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 };
}

View file

@ -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,
};
}

View file

@ -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",

View file

@ -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<string> {
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<string> {
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,
};
},
});

View file

@ -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";

View file

@ -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<

View file

@ -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