mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
fix: better docs, github auth (#1055)
* fix: Update github auth methods * cr
This commit is contained in:
parent
ebfa8eb388
commit
3e73447920
11 changed files with 500 additions and 171 deletions
73
Dockerfile
Normal file
73
Dockerfile
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
FROM python:3.12.12-slim-trixie
|
||||
|
||||
ARG DOCKER_CLI_VERSION=5:29.1.5-1~debian.13~trixie
|
||||
ARG NODEJS_VERSION=22.22.0-1nodesource1
|
||||
ARG UV_VERSION=0.9.26
|
||||
ARG YARN_VERSION=4.12.0
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
lsb-release \
|
||||
build-essential \
|
||||
openssh-client \
|
||||
jq \
|
||||
unzip \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" \
|
||||
| tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y "docker-ce-cli=${DOCKER_CLI_VERSION}" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "${arch}" in \
|
||||
amd64) uv_arch="x86_64-unknown-linux-gnu"; uv_sha256="30ccbf0a66dc8727a02b0e245c583ee970bdafecf3a443c1686e1b30ec4939e8" ;; \
|
||||
arm64) uv_arch="aarch64-unknown-linux-gnu"; uv_sha256="f71040c59798f79c44c08a7a1c1af7de95a8d334ea924b47b67ad6b9632be270" ;; \
|
||||
*) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_arch}.tar.gz" -o /tmp/uv.tar.gz; \
|
||||
echo "${uv_sha256} /tmp/uv.tar.gz" | sha256sum -c -; \
|
||||
tar -xzf /tmp/uv.tar.gz -C /tmp; \
|
||||
install -m 0755 -d /root/.local/bin; \
|
||||
install -m 0755 "/tmp/uv-${uv_arch}/uv" /root/.local/bin/uv; \
|
||||
install -m 0755 "/tmp/uv-${uv_arch}/uvx" /root/.local/bin/uvx; \
|
||||
rm -rf /tmp/uv.tar.gz "/tmp/uv-${uv_arch}"
|
||||
|
||||
ENV PATH=/root/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y "nodejs=${NODEJS_VERSION}" \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable \
|
||||
&& corepack prepare "yarn@${YARN_VERSION}" --activate
|
||||
|
||||
ENV GO_VERSION=1.23.5
|
||||
|
||||
RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz
|
||||
|
||||
ENV PATH=/usr/local/go/bin:/root/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ENV GOPATH=/root/go
|
||||
ENV PATH=/root/go/bin:/usr/local/go/bin:/root/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN echo "=== Installed versions ===" \
|
||||
&& python --version \
|
||||
&& uv --version \
|
||||
&& node --version \
|
||||
&& yarn --version \
|
||||
&& go version \
|
||||
&& docker --version \
|
||||
&& git --version
|
||||
273
INSTALLATION.md
273
INSTALLATION.md
|
|
@ -1,10 +1,12 @@
|
|||
# Installation Guide
|
||||
|
||||
This guide walks you through setting up Open SWE end-to-end: local development, GitHub App creation, Linear and Slack webhooks, and production deployment.
|
||||
This guide walks you through setting up Open SWE end-to-end: local development, GitHub App creation, LangSmith configuration, webhooks, and production deployment.
|
||||
|
||||
> **The steps are ordered to avoid forward references.** Each step only depends on things you've already completed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- **Python 3.11 – 3.13** (3.14 is not yet supported due to dependency constraints)
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- [LangGraph CLI](https://langchain-ai.github.io/langgraph/cloud/reference/cli/)
|
||||
- [ngrok](https://ngrok.com/) (for local development — exposes webhook endpoints to the internet)
|
||||
|
|
@ -19,18 +21,47 @@ source .venv/bin/activate
|
|||
uv sync --all-extras
|
||||
```
|
||||
|
||||
## 2. Create a GitHub App
|
||||
## 2. Start ngrok
|
||||
|
||||
You'll need the ngrok URL in subsequent steps when configuring webhooks, so start it first.
|
||||
|
||||
```bash
|
||||
ngrok http 2024 --url https://some-url-you-configure.ngrok.dev
|
||||
```
|
||||
|
||||
You don't need to pass the `--url` flag, however doing so will use the same subdomain each time you startup the server. Without this, you'll need to update the webhook URL in GitHub, Slack and Linear every time you restart your server for local development.
|
||||
|
||||
Copy the HTTPS URL you set, or if you didn't pass `--url`, the one ngrok gives you. You'll paste this into the webhook settings in steps 3 and 5.
|
||||
|
||||
> Keep this terminal open — ngrok needs to stay running during local development. Use a second terminal for the rest of the steps.
|
||||
|
||||
## 3. Create a GitHub App
|
||||
|
||||
Open SWE authenticates as a [GitHub App](https://docs.github.com/en/apps/creating-github-apps) to clone repos, push branches, and open PRs.
|
||||
|
||||
1. Go to **GitHub Settings** → **Developer settings** → **GitHub Apps** → **New GitHub App**
|
||||
### 3a. Choose your OAuth provider ID
|
||||
|
||||
Before creating the app you need to decide on an **OAuth provider ID** — this is a short string you'll use in both GitHub and LangSmith to link the two. Pick something memorable, for example:
|
||||
|
||||
```
|
||||
github-oauth-provider
|
||||
```
|
||||
|
||||
Write this down. You'll use it in the callback URL below and again in step 4 when configuring LangSmith.
|
||||
|
||||
### 3b. Create the app
|
||||
|
||||
1. Go to **GitHub Settings → Developer settings → GitHub Apps → New GitHub App**
|
||||
2. Fill in:
|
||||
- **App name**: `open-swe` (or your preferred name)
|
||||
- **Homepage URL**: any valid URL
|
||||
- **Callback URL**: Set this to `https://smith.langchain.com/host-oauth-callback/<your-provider-id>` (replace `<your-provider-id>` with the actual provider ID you configure in LangSmith during step #3. e.g. `github-oauth-provider`)
|
||||
- **Request user authorization (OAuth) during installation**: Enable this
|
||||
- **Webhook URL**: `https://<your-ngrok-url>/webhooks/github` (you'll set this up in step 4)
|
||||
- **Webhook secret**: generate with `openssl rand -hex 32` — save this for `GITHUB_WEBHOOK_SECRET`
|
||||
- **Homepage URL**: This can be any valid URL — it's only shown on the GitHub Marketplace page (which you won't be using). Use something like `https://github.com/langchain-ai/open-swe`
|
||||
- **Callback URL**: `https://smith.langchain.com/host-oauth-callback/<your-provider-id>` — replace `<your-provider-id>` with the ID you chose in step 3a (e.g. `https://smith.langchain.com/host-oauth-callback/github-oauth-provider`)
|
||||
- **Request user authorization (OAuth) during installation**: ✅ Enable this
|
||||
- **Webhook URL**: `https://<your-ngrok-url>/webhooks/github` — use the ngrok URL from step 2
|
||||
- **Webhook secret**: generate one and save it — you'll need it later as `GITHUB_WEBHOOK_SECRET`:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
3. Set permissions:
|
||||
- **Repository permissions**:
|
||||
- Contents: Read & write
|
||||
|
|
@ -42,67 +73,120 @@ Open SWE authenticates as a [GitHub App](https://docs.github.com/en/apps/creatin
|
|||
- `Pull request review`
|
||||
- `Pull request review comment`
|
||||
5. Click **Create GitHub App**
|
||||
6. Note the **App ID** from the app settings page - you'll need this for the `GITHUB_APP_ID` environment variable.
|
||||
7. Generate a **private key** (scroll down on the app page → **Generate a private key**). Save the `.pem` file contents. You'll need to set this under `GITHUB_APP_PRIVATE_KEY`.
|
||||
8. **Install the app** on the repositories you want Open SWE to access:
|
||||
- Go to your app's page → **Install App** → select your org/account → choose repositories
|
||||
- Note the **Installation ID** from the URL after installation (e.g. `https://github.com/settings/installations/12345678` → `12345678`) - you'll need to set this under `GITHUB_APP_INSTALLATION_ID`
|
||||
|
||||
## 3. Set up LangSmith
|
||||
### 3c. Collect credentials
|
||||
|
||||
Open SWE uses [LangSmith](https://smith.langchain.com/) for two things:
|
||||
After creating the app:
|
||||
|
||||
1. **App ID** — shown at the top of the app's settings page. Save this as `GITHUB_APP_ID`.
|
||||
2. **Private key** — scroll down to **Private keys** → click **Generate a private key**. A `.pem` file will download. Save its contents as `GITHUB_APP_PRIVATE_KEY`.
|
||||
|
||||
### 3d. Install the app on your repositories
|
||||
|
||||
1. From your app's settings page, click **Install App** in the sidebar
|
||||
2. Select your org or personal account
|
||||
3. Choose which repositories Open SWE should have access to
|
||||
4. Click **Install**
|
||||
5. After installation, look at the URL in your browser — it will look like:
|
||||
```
|
||||
https://github.com/settings/installations/12345678
|
||||
```
|
||||
or for an org:
|
||||
```
|
||||
https://github.com/organizations/YOUR-ORG/settings/installations/12345678
|
||||
```
|
||||
The number at the end (`12345678`) is your **Installation ID**. Save this as `GITHUB_APP_INSTALLATION_ID`.
|
||||
|
||||
> **Note**: The installation page may prompt you to authenticate with LangSmith. If you haven't set up LangSmith yet (step 4), that's fine — you can still grab the Installation ID from the URL and complete the OAuth setup later.
|
||||
|
||||
## 4. Set up LangSmith
|
||||
|
||||
Open SWE uses [LangSmith](https://smith.langchain.com/) for:
|
||||
- **Tracing**: all agent runs are logged for debugging and observability
|
||||
- **Sandboxes**: each task runs in an isolated LangSmith cloud sandbox
|
||||
|
||||
### 4a. Get your API key
|
||||
|
||||
1. Create a [LangSmith account](https://smith.langchain.com/) if you don't have one
|
||||
2. Go to **Settings** → **API Keys** → create a new API key
|
||||
2. Go to **Settings → API Keys → Create API Key**
|
||||
3. Save it as `LANGSMITH_API_KEY_PROD`
|
||||
|
||||
### GitHub OAuth (for user authentication)
|
||||
### 4b. Configure GitHub OAuth (optional but recommended)
|
||||
|
||||
Open SWE resolves GitHub tokens per-user via LangSmith's OAuth integration. This lets each user authenticate with their own GitHub account rather than sharing a single bot token.
|
||||
This lets each user authenticate with their own GitHub account. Without it, all operations use the GitHub App's installation token (a shared bot identity).
|
||||
|
||||
You'll need these from your LangSmith workspace settings:
|
||||
- `GITHUB_OAUTH_PROVIDER_ID` — the OAuth provider ID configured in LangSmith (e.g. `github-oauth-provider`)
|
||||
**What this affects:**
|
||||
- **With per-user OAuth**: PRs and commits show the triggering user's identity; each user's GitHub permissions are respected
|
||||
- **Without it (bot-token-only mode)**: all PRs and commits appear as the GitHub App bot; the app's installation-level permissions are used for everything
|
||||
|
||||
> **Note**: If these aren't configured, the agent will fall back to the GitHub App's installation token for all operations.
|
||||
To set up per-user OAuth:
|
||||
|
||||
### Sandbox templates (optional)
|
||||
1. In LangSmith, go to **Settings → OAuth Providers → Add Provider**
|
||||
2. Set the **Provider ID** to the same string you chose in step 3a (e.g. `github-oauth-provider`)
|
||||
3. Enter the **Client ID** and **Client Secret** from your GitHub App (found on the GitHub App settings page under **OAuth credentials**)
|
||||
4. Save. You'll reference this Provider ID as `GITHUB_OAUTH_PROVIDER_ID` in your environment variables.
|
||||
|
||||
You can configure a custom sandbox template for the agent's execution environment:
|
||||
### 4c. Sandbox templates (optional)
|
||||
|
||||
- `DEFAULT_SANDBOX_TEMPLATE_NAME` — name of a LangSmith sandbox template
|
||||
- `DEFAULT_SANDBOX_TEMPLATE_IMAGE` — Docker image for the sandbox
|
||||
LangSmith sandboxes provide the isolated execution environment for each agent run. You can create a template using the same Docker image we use internally by visiting the sandbox page in LangSmith, and setting the following fields:
|
||||
|
||||
If not set, the default LangSmith sandbox image is used.
|
||||
- `Name`: you can set this to whatever name you'd like, e.g. `open-swe`
|
||||
- `Container Image`: `bracelangchain/deepagents-sandbox:v1` this contains the [Docker file in this repo](./Dockerfile)
|
||||
- `CPU`: `500m`
|
||||
- `Memory`: `4096Mi`
|
||||
- `Ephemeral Storage`: `15Gi`
|
||||
|
||||
## 4. Set up triggers
|
||||
> If you don't set these, you can use a Python based docker image in the template.
|
||||
|
||||
Open SWE can be triggered from Linear, Slack, or GitHub. Configure whichever invocation surfaces your team uses — you don't need all of them.
|
||||
## 5. Set up triggers
|
||||
|
||||
### Linear
|
||||
Open SWE can be triggered from GitHub, Linear, and/or Slack. **Configure whichever surfaces your team uses — you don't need all of them.**
|
||||
|
||||
### GitHub
|
||||
|
||||
GitHub triggering works automatically once your GitHub App is set up (step 3). Users can:
|
||||
- Tag `@openswe` in issue titles or bodies to start a task
|
||||
- Tag `@openswe` in issue comments for follow-up instructions
|
||||
- Tag `@openswe` in PR review comments to have it address review feedback
|
||||
|
||||
To control which GitHub users can trigger the agent, add them to the `GITHUB_USER_EMAIL_MAP` in `agent/utils/github_user_email_map.py`:
|
||||
|
||||
```python
|
||||
GITHUB_USER_EMAIL_MAP = {
|
||||
"their-github-username": "their-email@example.com",
|
||||
}
|
||||
```
|
||||
|
||||
You should also add the GitHub organization which should be allowed to be triggered from in GitHub:
|
||||
|
||||
`agent/webapp.py`
|
||||
```python
|
||||
ALLOWED_GITHUB_ORGS = "langchain-ai,anthropics"
|
||||
```
|
||||
|
||||
### Linear (optional)
|
||||
|
||||
Open SWE listens for Linear comments that mention `@openswe`.
|
||||
|
||||
**Create a webhook:**
|
||||
|
||||
1. In Linear, go to **Settings** → **API** → **Webhooks** → **New webhook**
|
||||
1. In Linear, go to **Settings → API → Webhooks → New webhook**
|
||||
2. Fill in:
|
||||
- **Label**: `open-swe`
|
||||
- **URL**: `https://<your-ngrok-url>/webhooks/linear`
|
||||
- **Secret**: generate with `openssl rand -hex 32` — save this for `LINEAR_WEBHOOK_SECRET`
|
||||
3. Under **Data change events**, enable **Comments** → `Create` only
|
||||
- **URL**: `https://<your-ngrok-url>/webhooks/linear` — use the ngrok URL from step 2
|
||||
- **Secret**: generate with `openssl rand -hex 32` — save this as `LINEAR_WEBHOOK_SECRET`
|
||||
3. Under **Data change events**, enable **Comments → Create** only
|
||||
4. Click **Create webhook**
|
||||
|
||||
**Get your API key:**
|
||||
|
||||
1. Go to **Settings** → **API** → **Personal API keys** → **New API key**
|
||||
1. Go to **Settings → API → Personal API keys → New API key**
|
||||
2. Name it `open-swe`, select **All access**, and copy the key
|
||||
3. Save it as `LINEAR_API_KEY`
|
||||
|
||||
**Configure team-to-repo mapping:**
|
||||
|
||||
Open SWE routes Linear issues to GitHub repos based on the Linear team and project. The mapping is defined in `agent/utils/linear_team_repo_map.py` in the `LINEAR_TEAM_TO_REPO` dict:
|
||||
Open SWE routes Linear issues to GitHub repos based on the Linear team and project. Edit the mapping in `agent/utils/linear_team_repo_map.py`:
|
||||
|
||||
```python
|
||||
LINEAR_TEAM_TO_REPO = {
|
||||
|
|
@ -117,17 +201,15 @@ LINEAR_TEAM_TO_REPO = {
|
|||
}
|
||||
```
|
||||
|
||||
- **Flat mapping**: team name → single repo
|
||||
- **Nested mapping**: team name → project name → repo, with an optional `default` fallback
|
||||
|
||||
Update this to match your Linear workspace structure.
|
||||
|
||||
### Slack
|
||||
### Slack (optional)
|
||||
|
||||
**Create a Slack App:**
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From a manifest**
|
||||
2. Copy the following Slack App Manifest, and paste it in
|
||||
2. Copy the manifest below, replacing the two placeholder URLs:
|
||||
- Replace `<your-provider-id>` with the OAuth provider ID from step 3a
|
||||
- Replace `<your-ngrok-url>` with the ngrok URL from step 2
|
||||
|
||||
<details>
|
||||
<summary>Slack App Manifest</summary>
|
||||
|
||||
|
|
@ -151,7 +233,7 @@ Update this to match your Linear workspace structure.
|
|||
},
|
||||
"oauth_config": {
|
||||
"redirect_urls": [
|
||||
"https://smith.langchain.com/host-oauth-callback/<replace-with-your-langsmith-oauth-provider-id>"
|
||||
"https://smith.langchain.com/host-oauth-callback/<your-provider-id>"
|
||||
],
|
||||
"scopes": {
|
||||
"bot": [
|
||||
|
|
@ -175,7 +257,7 @@ Update this to match your Linear workspace structure.
|
|||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"request_url": "<replace-with-your-langsmith-deployment-url-or-ngrok-url>/webhooks/slack",
|
||||
"request_url": "https://<your-ngrok-url>/webhooks/slack",
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"message.im",
|
||||
|
|
@ -189,19 +271,14 @@ Update this to match your Linear workspace structure.
|
|||
}
|
||||
```
|
||||
|
||||
Place the two URLs with their proper values:
|
||||
1. **redirect_urls**: `"https://smith.langchain.com/host-oauth-callback/<replace-with-your-langsmith-oauth-provider-id>"` add your LangSmith OAuth provider ID you set when creating the OAuth provider in LangSmith.
|
||||
2. **request_url**: `"https://<your-ngrok-url>/webhooks/slack"` add your ngrok URL pointing to `http://localhost:2024` for local development, or your LangSmith deployment URL if deployed on LangSmith Deployments.
|
||||
|
||||
</details>
|
||||
3. Copy the following App Manifest and paste it in the Slack App configuration page.
|
||||
|
||||
Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
3. Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
|
||||
**Credentials you'll need:**
|
||||
|
||||
- `SLACK_BOT_TOKEN`: the Bot User OAuth Token (`xoxb-...`)
|
||||
- `SLACK_SIGNING_SECRET`: found under **Basic Information** → **App Credentials**
|
||||
- `SLACK_SIGNING_SECRET`: found under **Basic Information → App Credentials**
|
||||
- `SLACK_BOT_USER_ID`: the bot's user ID (find it in Slack by clicking the bot's profile)
|
||||
- `SLACK_BOT_USERNAME`: the bot's display name (e.g. `open-swe`)
|
||||
|
||||
|
|
@ -214,72 +291,63 @@ SLACK_REPO_OWNER="my-org" # Default GitHub org
|
|||
SLACK_REPO_NAME="my-repo" # Default GitHub repo
|
||||
```
|
||||
|
||||
### GitHub
|
||||
## 6. Environment variables
|
||||
|
||||
GitHub triggering works automatically once your GitHub App is set up (step 2). Tag `@openswe` in PR comments on agent-created PRs to have it address review feedback and push fixes to the same branch.
|
||||
|
||||
In order for Open SWE to recognize users who should be able to trigger it via GitHub, you'll need to add them to the `GITHUB_USER_EMAIL_MAP` in `agent/utils/github_user_email_map.py`. The email should be the same email associated with their LangSmith account.
|
||||
|
||||
## 5. Environment variables
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
Create a `.env` file in the project root. Below is the full list — only fill in the sections relevant to the triggers you configured.
|
||||
|
||||
```bash
|
||||
# === LangSmith ===
|
||||
LANGSMITH_API_KEY_PROD="" # LangSmith API key
|
||||
LANGSMITH_API_KEY_PROD="" # From step 4a
|
||||
LANGCHAIN_TRACING_V2="true"
|
||||
LANGCHAIN_PROJECT="" # LangSmith project name for traces
|
||||
|
||||
# === LLM ===
|
||||
ANTHROPIC_API_KEY="" # Anthropic API key (default provider)
|
||||
|
||||
# === GitHub App ===
|
||||
GITHUB_APP_ID="" # From step 2
|
||||
# === GitHub App (required) ===
|
||||
GITHUB_APP_ID="" # From step 3c
|
||||
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||
...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
"
|
||||
GITHUB_APP_INSTALLATION_ID="" # From step 2
|
||||
GITHUB_APP_INSTALLATION_ID="" # From step 3d
|
||||
|
||||
# === GitHub Webhook ===
|
||||
GITHUB_WEBHOOK_SECRET="" # openssl rand -hex 32
|
||||
# === GitHub Webhook (required) ===
|
||||
GITHUB_WEBHOOK_SECRET="" # The secret you generated in step 3b
|
||||
|
||||
# === GitHub OAuth (via LangSmith) ===
|
||||
GITHUB_OAUTH_PROVIDER_ID="" # Optional — LangSmith OAuth provider
|
||||
# === GitHub OAuth via LangSmith (optional) ===
|
||||
# Without these, all operations use the GitHub App's bot token.
|
||||
# With these, each user authenticates with their own GitHub account.
|
||||
GITHUB_OAUTH_PROVIDER_ID="" # The provider ID from steps 3a / 4b
|
||||
|
||||
# === Linear ===
|
||||
LINEAR_API_KEY="" # From step 4
|
||||
LINEAR_WEBHOOK_SECRET="" # From step 4
|
||||
# === Org Allowlist (optional) ===
|
||||
# Comma-separated list of GitHub orgs the agent is allowed to operate on.
|
||||
# Leave empty to allow all orgs.
|
||||
ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org"
|
||||
|
||||
# === Slack (optional) ===
|
||||
SLACK_BOT_TOKEN="" # From step 4
|
||||
# === Linear (if using Linear trigger) ===
|
||||
LINEAR_API_KEY="" # From step 5
|
||||
LINEAR_WEBHOOK_SECRET="" # From step 5
|
||||
|
||||
# === Slack (if using Slack trigger) ===
|
||||
SLACK_BOT_TOKEN="" # From step 5
|
||||
SLACK_BOT_USER_ID=""
|
||||
SLACK_BOT_USERNAME=""
|
||||
SLACK_SIGNING_SECRET=""
|
||||
SLACK_REPO_OWNER="" # Default org for Slack-triggered tasks
|
||||
SLACK_REPO_NAME="" # Default repo for Slack-triggered tasks
|
||||
|
||||
# === Sandbox ===
|
||||
DEFAULT_SANDBOX_TEMPLATE_NAME="" # Optional — custom sandbox template
|
||||
DEFAULT_SANDBOX_TEMPLATE_IMAGE="" # Optional — custom Docker image
|
||||
# === Sandbox (optional) ===
|
||||
DEFAULT_SANDBOX_TEMPLATE_NAME="" # Custom sandbox template name (default: deepagents-cli)
|
||||
DEFAULT_SANDBOX_TEMPLATE_IMAGE="" # Custom Docker image (default: python:3)
|
||||
|
||||
# === Token Encryption ===
|
||||
TOKEN_ENCRYPTION_KEY="" # openssl rand -base64 32
|
||||
TOKEN_ENCRYPTION_KEY="" # Generate with: openssl rand -base64 32
|
||||
```
|
||||
|
||||
## 6. Start the server (local development)
|
||||
## 7. Start the server
|
||||
|
||||
Start ngrok in one terminal to expose your local server:
|
||||
|
||||
In one terminal, expose your local server:
|
||||
|
||||
```bash
|
||||
ngrok http 2024
|
||||
```
|
||||
|
||||
Copy the HTTPS URL (e.g. `https://xxxx.ngrok.io`) and update your webhook URLs from step 4.
|
||||
|
||||
Then start the LangGraph server in another terminal:
|
||||
Make sure ngrok is still running from step 2, then start the LangGraph server in a second terminal:
|
||||
|
||||
```bash
|
||||
uv run langgraph dev --no-browser
|
||||
|
|
@ -289,13 +357,23 @@ The server runs on `http://localhost:2024` with these endpoints:
|
|||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /webhooks/github` | GitHub issue/PR/comment webhooks |
|
||||
| `POST /webhooks/linear` | Linear comment webhooks |
|
||||
| `GET /webhooks/linear` | Linear webhook verification |
|
||||
| `POST /webhooks/slack` | Slack event webhooks |
|
||||
| `GET /webhooks/slack` | Slack webhook verification |
|
||||
| `GET /health` | Health check |
|
||||
|
||||
## 7. Verify it works
|
||||
## 8. Verify it works
|
||||
|
||||
### GitHub
|
||||
|
||||
1. Go to any issue in a repository where the app is installed
|
||||
2. Create or comment on an issue with: `@openswe what files are in this repo?`
|
||||
3. You should see:
|
||||
- A 👀 reaction on your comment within a few seconds
|
||||
- A new run in your LangSmith project
|
||||
- The agent replies with a comment on the issue
|
||||
|
||||
### Linear
|
||||
|
||||
|
|
@ -314,14 +392,14 @@ The server runs on `http://localhost:2024` with these endpoints:
|
|||
- An 👀 reaction on your message
|
||||
- A reply in the thread with the agent's response
|
||||
|
||||
## 8. Production deployment
|
||||
## 9. Production deployment
|
||||
|
||||
For production, deploy the agent on [LangGraph Cloud](https://langchain-ai.github.io/langgraph/cloud/) instead of running locally:
|
||||
|
||||
1. Push your code to a GitHub repository
|
||||
2. Connect the repo to LangGraph Cloud
|
||||
3. Set all environment variables from step 5 in the deployment config
|
||||
4. Update your Linear and Slack webhook URLs to point to your production URL (replace the ngrok URL)
|
||||
3. Set all environment variables from step 6 in the deployment config
|
||||
4. Update your webhook URLs (Linear, Slack, GitHub App) to point to your production URL (replace the ngrok URL)
|
||||
|
||||
The `langgraph.json` at the project root already defines the graph entry point and HTTP app:
|
||||
|
||||
|
|
@ -340,9 +418,10 @@ The `langgraph.json` at the project root already defines the graph entry point a
|
|||
|
||||
### Webhook not receiving events
|
||||
|
||||
- Verify ngrok is running and the URL matches what's configured in Linear/Slack
|
||||
- Verify ngrok is running and the URL matches what's configured in GitHub/Linear/Slack
|
||||
- Check the ngrok web inspector at `http://localhost:4040` for incoming requests
|
||||
- Ensure you enabled the correct event types (Comments → Create for Linear, `app_mention` for Slack)
|
||||
- Ensure you enabled the correct event types (Comments → Create for Linear, `app_mention` for Slack, Issues + Issue comment for GitHub)
|
||||
- **Webhook secrets are required** — if `GITHUB_WEBHOOK_SECRET`, `LINEAR_WEBHOOK_SECRET`, or `SLACK_SIGNING_SECRET` is not set, all requests to that endpoint will be rejected with 401
|
||||
|
||||
### GitHub authentication errors
|
||||
|
||||
|
|
@ -354,10 +433,12 @@ The `langgraph.json` at the project root already defines the graph entry point a
|
|||
|
||||
- Verify `LANGSMITH_API_KEY_PROD` is set and valid
|
||||
- Check LangSmith sandbox quotas in your workspace settings
|
||||
- If using a custom template, verify `DEFAULT_SANDBOX_TEMPLATE_NAME` matches an existing template
|
||||
- If you see `Failed to check template ''`, ensure either `DEFAULT_SANDBOX_TEMPLATE_NAME` is set or that your LangSmith API key has permissions to create sandbox templates
|
||||
- If you get a 403 Forbidden error on the sandbox templates endpoint, your LangSmith workspace may not have sandbox access enabled — contact LangSmith support
|
||||
|
||||
### Agent not responding to comments
|
||||
|
||||
- For GitHub: ensure the comment or issue contains `@openswe` (case-insensitive), and the commenter's GitHub username is in `GITHUB_USER_EMAIL_MAP`
|
||||
- For Linear: ensure the comment contains `@openswe` (case-insensitive)
|
||||
- For Slack: ensure the bot is invited to the channel and the message is an `@mention`
|
||||
- Check server logs for webhook processing errors
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ class SandboxProvider(ABC):
|
|||
|
||||
|
||||
# Default template configuration
|
||||
DEFAULT_TEMPLATE_NAME = "deepagents-cli"
|
||||
DEFAULT_TEMPLATE_NAME = "open-swe"
|
||||
DEFAULT_TEMPLATE_IMAGE = "python:3"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,9 +52,12 @@ SANDBOX_POLL_INTERVAL = 1.0
|
|||
|
||||
from .utils.agents_md import read_agents_md_in_sandbox
|
||||
from .utils.github import (
|
||||
_CRED_FILE_PATH,
|
||||
cleanup_git_credentials,
|
||||
git_has_uncommitted_changes,
|
||||
is_valid_git_repo,
|
||||
remove_directory,
|
||||
setup_git_credentials,
|
||||
)
|
||||
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
|
||||
|
||||
|
|
@ -86,8 +89,8 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
|||
raise ValueError(msg)
|
||||
|
||||
repo_dir = f"/workspace/{repo}"
|
||||
auth_url = f"https://git:{token}@github.com/{owner}/{repo}.git"
|
||||
clean_url = f"https://github.com/{owner}/{repo}.git"
|
||||
cred_helper_arg = f"-c credential.helper='store --file={_CRED_FILE_PATH}'"
|
||||
|
||||
is_git_repo = await loop.run_in_executor(None, is_valid_git_repo, sandbox_backend, repo_dir)
|
||||
|
||||
|
|
@ -115,16 +118,12 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
|||
|
||||
logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo)
|
||||
|
||||
await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token)
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
sandbox_backend.execute,
|
||||
f"cd {repo_dir} && git remote set-url origin {auth_url}",
|
||||
)
|
||||
pull_result = await loop.run_in_executor(
|
||||
None,
|
||||
sandbox_backend.execute,
|
||||
f"cd {repo_dir} && git pull origin $(git rev-parse --abbrev-ref HEAD)",
|
||||
f"cd {repo_dir} && git {cred_helper_arg} pull origin $(git rev-parse --abbrev-ref HEAD)",
|
||||
)
|
||||
logger.debug("Git pull result: exit_code=%s", pull_result.exit_code)
|
||||
if pull_result.exit_code != 0:
|
||||
|
|
@ -137,44 +136,31 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
|||
logger.exception("Failed to execute git pull")
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
sandbox_backend.execute,
|
||||
f"cd {repo_dir} && git remote set-url origin {clean_url}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to restore clean remote URL")
|
||||
raise
|
||||
await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend)
|
||||
|
||||
logger.info("Repo updated at %s", repo_dir)
|
||||
return repo_dir
|
||||
|
||||
logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir)
|
||||
await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token)
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None, sandbox_backend.execute, f"git clone {auth_url} {repo_dir}"
|
||||
None,
|
||||
sandbox_backend.execute,
|
||||
f"git {cred_helper_arg} clone {clean_url} {repo_dir}",
|
||||
)
|
||||
logger.debug("Git clone result: exit_code=%s", result.exit_code)
|
||||
except Exception:
|
||||
logger.exception("Failed to execute git clone")
|
||||
raise
|
||||
finally:
|
||||
await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend)
|
||||
|
||||
if result.exit_code != 0:
|
||||
msg = f"Failed to clone repo {owner}/{repo}: {result.output}"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
sandbox_backend.execute,
|
||||
f"cd {repo_dir} && git remote set-url origin {clean_url}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to set remote URL after clone")
|
||||
raise
|
||||
|
||||
logger.info("Repo cloned successfully at %s", repo_dir)
|
||||
return repo_dir
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,49 @@
|
|||
import ipaddress
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _is_url_safe(url: str) -> tuple[bool, str]:
|
||||
"""Check if a URL is safe to request (not targeting private/internal networks)."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False, "Could not parse hostname from URL"
|
||||
|
||||
try:
|
||||
addr_infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
return False, f"Could not resolve hostname: {hostname}"
|
||||
|
||||
for addr_info in addr_infos:
|
||||
ip_str = addr_info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False, f"URL resolves to blocked address: {ip_str}"
|
||||
|
||||
return True, ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"URL validation error: {e}"
|
||||
|
||||
|
||||
def _blocked_response(url: str, reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"success": False,
|
||||
"status_code": 0,
|
||||
"headers": {},
|
||||
"content": f"Request blocked: {reason}",
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def http_request(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
|
|
@ -24,6 +65,10 @@ def http_request(
|
|||
Returns:
|
||||
Dictionary with response data including status, headers, and content
|
||||
"""
|
||||
is_safe, reason = _is_url_safe(url)
|
||||
if not is_safe:
|
||||
return _blocked_response(url, reason)
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from langgraph.graph.state import RunnableConfig
|
|||
from langgraph_sdk import get_client
|
||||
|
||||
from ..encryption import encrypt_token
|
||||
from .github_app import get_github_app_installation_token
|
||||
from .github_token import get_github_token_from_thread
|
||||
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
||||
from .linear import comment_on_linear_issue
|
||||
|
|
@ -40,6 +41,17 @@ logger.debug(
|
|||
)
|
||||
|
||||
|
||||
def is_bot_token_only_mode() -> bool:
|
||||
"""Check if we're in bot-token-only mode.
|
||||
|
||||
This is the case when LANGSMITH_API_KEY_PROD is set (deployed) but neither
|
||||
X_SERVICE_AUTH_JWT_SECRET nor USER_ID_API_KEY_MAP is configured, meaning we
|
||||
can't resolve per-user GitHub OAuth tokens. In this mode the GitHub App
|
||||
installation token is used for all git operations instead.
|
||||
"""
|
||||
return bool(LANGSMITH_API_KEY and not X_SERVICE_AUTH_JWT_SECRET and not USER_ID_API_KEY_MAP)
|
||||
|
||||
|
||||
def _retry_instruction(source: str) -> str:
|
||||
if source == "slack":
|
||||
return "Once authenticated, mention me again in this Slack thread to retry."
|
||||
|
|
@ -64,27 +76,11 @@ def _work_item_label(source: str) -> str:
|
|||
return "issue"
|
||||
|
||||
|
||||
def parse_user_id_api_key_map() -> dict[str, str]:
|
||||
if not USER_ID_API_KEY_MAP:
|
||||
return {}
|
||||
return {
|
||||
k.strip(): v.strip()
|
||||
for k, v in (pair.split(":", 1) for pair in USER_ID_API_KEY_MAP.split(","))
|
||||
}
|
||||
|
||||
|
||||
def get_secret_key_for_user(
|
||||
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
||||
) -> tuple[str, Literal["service", "api_key"]]:
|
||||
"""Create a short-lived service JWT for authenticating as a specific user."""
|
||||
if not X_SERVICE_AUTH_JWT_SECRET:
|
||||
user_id_api_key_map = parse_user_id_api_key_map()
|
||||
if user_id_api_key_map:
|
||||
if user_id in user_id_api_key_map:
|
||||
return user_id_api_key_map[user_id], "api_key"
|
||||
msg = f"User {user_id} not found in USER_ID_API_KEY_MAP"
|
||||
raise ValueError(msg)
|
||||
|
||||
msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
|
@ -345,18 +341,41 @@ async def save_encrypted_token_from_email(
|
|||
return token, encrypted
|
||||
|
||||
|
||||
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str]:
|
||||
"""Get a GitHub App installation token and persist it for the thread."""
|
||||
bot_token = await get_github_app_installation_token()
|
||||
if not bot_token:
|
||||
raise RuntimeError(
|
||||
"Bot-token-only mode is active (LANGSMITH_API_KEY_PROD set without "
|
||||
"X_SERVICE_AUTH_JWT_SECRET) but the GitHub App is not configured. "
|
||||
"Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_INSTALLATION_ID."
|
||||
)
|
||||
logger.info(
|
||||
"Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id
|
||||
)
|
||||
encrypted = await persist_encrypted_github_token(thread_id, bot_token)
|
||||
return bot_token, encrypted
|
||||
|
||||
|
||||
async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str]:
|
||||
"""Resolve a GitHub token from the run config based on the source.
|
||||
|
||||
Routes to the correct auth method depending on whether the run was
|
||||
triggered from GitHub (login-based) or Linear/Slack (email-based).
|
||||
|
||||
In bot-token-only mode (LANGSMITH_API_KEY_PROD set without
|
||||
X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used
|
||||
for all operations instead of per-user OAuth tokens.
|
||||
|
||||
Returns:
|
||||
(github_token, new_encrypted) tuple.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If source is missing or token resolution fails.
|
||||
"""
|
||||
if is_bot_token_only_mode():
|
||||
return await _resolve_bot_installation_token(thread_id)
|
||||
|
||||
configurable = config["configurable"]
|
||||
source = configurable.get("source")
|
||||
if not source:
|
||||
|
|
|
|||
|
|
@ -113,6 +113,34 @@ def git_get_remote_url(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -
|
|||
return result.output.strip()
|
||||
|
||||
|
||||
_CRED_FILE_PATH = "/tmp/.git-credentials"
|
||||
|
||||
|
||||
def setup_git_credentials(sandbox_backend: SandboxBackendProtocol, github_token: str) -> None:
|
||||
"""Write GitHub credentials to a temporary file using the sandbox write API.
|
||||
|
||||
The write API sends content in the HTTP body (not via a shell command),
|
||||
so the token never appears in shell history or process listings.
|
||||
"""
|
||||
sandbox_backend.write(_CRED_FILE_PATH, f"https://git:{github_token}@github.com\n")
|
||||
sandbox_backend.execute(f"chmod 600 {_CRED_FILE_PATH}")
|
||||
|
||||
|
||||
def cleanup_git_credentials(sandbox_backend: SandboxBackendProtocol) -> None:
|
||||
"""Remove the temporary credentials file."""
|
||||
sandbox_backend.execute(f"rm -f {_CRED_FILE_PATH}")
|
||||
|
||||
|
||||
def _git_with_credentials(
|
||||
sandbox_backend: SandboxBackendProtocol,
|
||||
repo_dir: str,
|
||||
command: str,
|
||||
) -> ExecuteResponse:
|
||||
"""Run a git command using the temporary credential file."""
|
||||
cred_helper = shlex.quote(f"store --file={_CRED_FILE_PATH}")
|
||||
return _run_git(sandbox_backend, repo_dir, f"git -c credential.helper={cred_helper} {command}")
|
||||
|
||||
|
||||
def git_push(
|
||||
sandbox_backend: SandboxBackendProtocol,
|
||||
repo_dir: str,
|
||||
|
|
@ -121,11 +149,13 @@ def git_push(
|
|||
) -> ExecuteResponse:
|
||||
"""Push the branch to origin, using a token if needed."""
|
||||
safe_branch = shlex.quote(branch)
|
||||
remote_url = git_get_remote_url(sandbox_backend, repo_dir)
|
||||
if remote_url and "github.com" in remote_url and "@" not in remote_url and github_token:
|
||||
auth_url = remote_url.replace("https://", f"https://git:{github_token}@")
|
||||
return _run_git(sandbox_backend, repo_dir, f"git push {auth_url} {safe_branch}")
|
||||
return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}")
|
||||
if not github_token:
|
||||
return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}")
|
||||
setup_git_credentials(sandbox_backend, github_token)
|
||||
try:
|
||||
return _git_with_credentials(sandbox_backend, repo_dir, f"push origin {safe_branch}")
|
||||
finally:
|
||||
cleanup_git_credentials(sandbox_backend)
|
||||
|
||||
|
||||
async def create_github_pr(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ def verify_github_signature(body: bytes, signature: str, *, secret: str) -> bool
|
|||
True if signature is valid or no secret is configured.
|
||||
"""
|
||||
if not secret:
|
||||
return True
|
||||
logger.warning("GITHUB_WEBHOOK_SECRET is not configured — rejecting webhook request")
|
||||
return False
|
||||
|
||||
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ def verify_slack_signature(
|
|||
) -> bool:
|
||||
"""Verify Slack request signature."""
|
||||
if not secret:
|
||||
return True
|
||||
logger.warning("SLACK_SIGNING_SECRET is not configured — rejecting webhook request")
|
||||
return False
|
||||
if not timestamp or not signature:
|
||||
return False
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ from langchain_core.messages.content import create_text_block
|
|||
from langgraph_sdk import get_client
|
||||
from langgraph_sdk.client import LangGraphClient
|
||||
|
||||
from .utils.auth import persist_encrypted_github_token, resolve_github_token_from_email
|
||||
from .utils.auth import (
|
||||
is_bot_token_only_mode,
|
||||
persist_encrypted_github_token,
|
||||
resolve_github_token_from_email,
|
||||
)
|
||||
from .utils.comments import get_recent_comments
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
from .utils.github_comments import (
|
||||
|
|
@ -62,6 +66,12 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||
)
|
||||
|
||||
ALLOWED_GITHUB_ORGS: frozenset[str] = frozenset(
|
||||
org.strip().lower()
|
||||
for org in os.environ.get("ALLOWED_GITHUB_ORGS", "").split(",")
|
||||
if org.strip()
|
||||
)
|
||||
|
||||
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
|
||||
|
||||
_GITHUB_BOT_MESSAGE_PREFIXES = (
|
||||
|
|
@ -278,6 +288,18 @@ def _is_not_found_error(exc: Exception) -> bool:
|
|||
return getattr(exc, "status_code", None) == 404
|
||||
|
||||
|
||||
def _is_repo_org_allowed(repo_config: dict[str, str]) -> bool:
|
||||
"""Check if the repo owner/org is in the allowlist.
|
||||
|
||||
Returns True if no allowlist is configured (empty ALLOWED_GITHUB_ORGS),
|
||||
or if the repo owner is in the allowlist.
|
||||
"""
|
||||
if not ALLOWED_GITHUB_ORGS:
|
||||
return True
|
||||
owner = repo_config.get("owner", "").lower()
|
||||
return owner in ALLOWED_GITHUB_ORGS
|
||||
|
||||
|
||||
async def _upsert_slack_thread_repo_metadata(
|
||||
thread_id: str, repo_config: dict[str, str], langgraph_client: LangGraphClient
|
||||
) -> None:
|
||||
|
|
@ -794,7 +816,8 @@ def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
|
|||
True if signature is valid, False otherwise
|
||||
"""
|
||||
if not secret:
|
||||
return True
|
||||
logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request")
|
||||
return False
|
||||
|
||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
|
@ -813,9 +836,7 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
|
|||
body = await request.body()
|
||||
|
||||
signature = request.headers.get("Linear-Signature", "")
|
||||
if LINEAR_WEBHOOK_SECRET and not verify_linear_signature(
|
||||
body, signature, LINEAR_WEBHOOK_SECRET
|
||||
):
|
||||
if not verify_linear_signature(body, signature, LINEAR_WEBHOOK_SECRET):
|
||||
logger.warning("Invalid webhook signature")
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
|
|
@ -892,6 +913,13 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
|
|||
},
|
||||
)
|
||||
|
||||
if not _is_repo_org_allowed(repo_config):
|
||||
logger.warning(
|
||||
"Rejecting Linear webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
|
||||
repo_config.get("owner"),
|
||||
)
|
||||
return {"status": "ignored", "reason": "Repository org not in allowlist"}
|
||||
|
||||
repo_owner = repo_config["owner"]
|
||||
repo_name = repo_config["name"]
|
||||
|
||||
|
|
@ -927,7 +955,7 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) ->
|
|||
|
||||
signature = request.headers.get("X-Slack-Signature", "")
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||
if SLACK_SIGNING_SECRET and not verify_slack_signature(
|
||||
if not verify_slack_signature(
|
||||
body=body,
|
||||
timestamp=timestamp,
|
||||
signature=signature,
|
||||
|
|
@ -1003,6 +1031,13 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) ->
|
|||
}
|
||||
repo_config = await get_slack_repo_config(text, channel_id, thread_ts)
|
||||
|
||||
if not _is_repo_org_allowed(repo_config):
|
||||
logger.warning(
|
||||
"Rejecting Slack webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
|
||||
repo_config.get("owner"),
|
||||
)
|
||||
return {"status": "ignored", "reason": "Repository org not in allowlist"}
|
||||
|
||||
background_tasks.add_task(process_slack_mention, event_data, repo_config)
|
||||
|
||||
return {"status": "accepted", "message": "Slack mention queued"}
|
||||
|
|
@ -1050,17 +1085,20 @@ def build_github_issue_prompt(
|
|||
comments: list[dict[str, Any]],
|
||||
*,
|
||||
github_login: str,
|
||||
issue_author: str = "",
|
||||
) -> str:
|
||||
"""Build the user prompt for a GitHub issue-triggered run."""
|
||||
triggered_by_line = f"## Triggered by: {github_login}\n\n" if github_login else ""
|
||||
comments_text = _build_github_issue_comments_text(comments)
|
||||
sanitized_title = sanitize_github_comment_body(title)
|
||||
formatted_body = format_github_comment_body_for_prompt(issue_author or github_login, body)
|
||||
return (
|
||||
"Please work on the following GitHub issue:\n\n"
|
||||
f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
|
||||
f"{triggered_by_line}"
|
||||
f"## GitHub Issue: #{issue_number} - Issue ID: {issue_id}\n\n"
|
||||
f"## Title: {title}\n\n"
|
||||
f"## Description:\n{body}\n"
|
||||
f"## Title: {sanitized_title}\n\n"
|
||||
f"## Description:\n{formatted_body}\n"
|
||||
f"{comments_text}\n\n"
|
||||
"Please analyze this issue and implement the necessary changes. "
|
||||
"When you need to communicate on GitHub, use `github_comment` with the issue number."
|
||||
|
|
@ -1120,7 +1158,22 @@ async def _trigger_or_queue_run(
|
|||
|
||||
|
||||
async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
|
||||
"""Resolve and persist a GitHub token for a thread when available."""
|
||||
"""Resolve and persist a GitHub token for a thread when available.
|
||||
|
||||
In bot-token-only mode, returns a fresh GitHub App installation token
|
||||
instead of resolving per-user OAuth tokens.
|
||||
"""
|
||||
if is_bot_token_only_mode():
|
||||
bot_token = await get_github_app_installation_token()
|
||||
if bot_token:
|
||||
try:
|
||||
await persist_encrypted_github_token(thread_id, bot_token)
|
||||
except Exception:
|
||||
logger.warning("Could not persist bot token for thread %s", thread_id)
|
||||
return bot_token
|
||||
logger.warning("Bot-token-only mode but GitHub App token unavailable")
|
||||
return None
|
||||
|
||||
github_token, _encrypted_token = await get_github_token_from_thread(thread_id)
|
||||
if github_token:
|
||||
return github_token
|
||||
|
|
@ -1224,6 +1277,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
|||
issue_url = issue.get("html_url", "") or issue.get("url", "")
|
||||
title = issue.get("title", "No title")
|
||||
description = issue.get("body") or "No description"
|
||||
issue_author = issue.get("user", {}).get("login", "")
|
||||
|
||||
logger.info(
|
||||
"Processing GitHub issue: event=%s, issue=%s, repo=%s/%s",
|
||||
|
|
@ -1293,6 +1347,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
|
|||
description,
|
||||
comments,
|
||||
github_login=github_login,
|
||||
issue_author=issue_author,
|
||||
)
|
||||
configurable: dict[str, Any] = {
|
||||
"source": "github",
|
||||
|
|
@ -1345,6 +1400,19 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
|
|||
logger.exception("Failed to parse GitHub webhook JSON")
|
||||
return {"status": "error", "message": "Invalid JSON"}
|
||||
|
||||
# Check org allowlist
|
||||
webhook_repo = payload.get("repository", {})
|
||||
webhook_repo_config = {
|
||||
"owner": webhook_repo.get("owner", {}).get("login", ""),
|
||||
"name": webhook_repo.get("name", ""),
|
||||
}
|
||||
if not _is_repo_org_allowed(webhook_repo_config):
|
||||
logger.warning(
|
||||
"Rejecting GitHub webhook: org '%s' not in ALLOWED_GITHUB_ORGS",
|
||||
webhook_repo_config.get("owner"),
|
||||
)
|
||||
return {"status": "ignored", "reason": "Repository org not in allowlist"}
|
||||
|
||||
issue = payload.get("issue", {})
|
||||
is_pull_request_comment = bool(event_type == "issue_comment" and issue.get("pull_request"))
|
||||
is_issue_comment = bool(event_type == "issue_comment" and not issue.get("pull_request"))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent import webapp
|
||||
from agent.utils import github_comments
|
||||
|
||||
_TEST_WEBHOOK_SECRET = "test-secret-for-webhook"
|
||||
|
||||
|
||||
def _sign_body(body: bytes, secret: str = _TEST_WEBHOOK_SECRET) -> str:
|
||||
"""Compute the X-Hub-Signature-256 header value for raw bytes."""
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={sig}"
|
||||
|
||||
|
||||
def _post_github_webhook(client: TestClient, event_type: str, payload: dict) -> object:
|
||||
"""Send a signed GitHub webhook POST request."""
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
return client.post(
|
||||
"/webhooks/github",
|
||||
content=body,
|
||||
headers={
|
||||
"X-GitHub-Event": event_type,
|
||||
"X-Hub-Signature-256": _sign_body(body),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_generate_thread_id_from_github_issue_is_deterministic() -> None:
|
||||
first = webapp.generate_thread_id_from_github_issue("12345")
|
||||
|
|
@ -49,13 +74,13 @@ def test_github_webhook_accepts_issue_events(monkeypatch) -> None:
|
|||
called["event_type"] = event_type
|
||||
|
||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||
|
||||
client = TestClient(webapp.app)
|
||||
response = client.post(
|
||||
"/webhooks/github",
|
||||
headers={"X-GitHub-Event": "issues"},
|
||||
json={
|
||||
response = _post_github_webhook(
|
||||
client,
|
||||
"issues",
|
||||
{
|
||||
"action": "opened",
|
||||
"issue": {
|
||||
"id": 12345,
|
||||
|
|
@ -81,13 +106,13 @@ def test_github_webhook_ignores_issue_events_without_body_or_title_change(monkey
|
|||
called = True
|
||||
|
||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||
|
||||
client = TestClient(webapp.app)
|
||||
response = client.post(
|
||||
"/webhooks/github",
|
||||
headers={"X-GitHub-Event": "issues"},
|
||||
json={
|
||||
response = _post_github_webhook(
|
||||
client,
|
||||
"issues",
|
||||
{
|
||||
"action": "edited",
|
||||
"changes": {"labels": {"from": []}},
|
||||
"issue": {
|
||||
|
|
@ -114,13 +139,13 @@ def test_github_webhook_accepts_issue_comment_events(monkeypatch) -> None:
|
|||
called["event_type"] = event_type
|
||||
|
||||
monkeypatch.setattr(webapp, "process_github_issue", fake_process_github_issue)
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setattr(webapp, "GITHUB_WEBHOOK_SECRET", _TEST_WEBHOOK_SECRET)
|
||||
|
||||
client = TestClient(webapp.app)
|
||||
response = client.post(
|
||||
"/webhooks/github",
|
||||
headers={"X-GitHub-Event": "issue_comment"},
|
||||
json={
|
||||
response = _post_github_webhook(
|
||||
client,
|
||||
"issue_comment",
|
||||
{
|
||||
"issue": {"id": 12345, "number": 42, "title": "Fix the flaky test"},
|
||||
"comment": {"body": "@openswe please handle this"},
|
||||
"repository": {"owner": {"login": "langchain-ai"}, "name": "open-swe"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue