Skip to content

Commit a467f84

Browse files
committed
feat: add GitHub App coder authentication
1 parent e3fcb8c commit a467f84

40 files changed

Lines changed: 1755 additions & 589 deletions

.env.example

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,20 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope
3030
# export SERVER_PORT=8123
3131

3232
# -- Internal Sources (Optional) --
33-
# A fine-grained PAT with read access enables GitHub code, repo, issue, and PR search.
33+
# A fine-grained PAT with read access enables GitHub repo, code, PR, and CI search.
3434
# If coding reuses this token, it also needs permission to push branches and open PRs.
3535
# export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_...
3636
# export GITHUB_MCP_URL=https://api.githubcopilot.com/mcp/readonly
37-
# Coding (optional). Needs DAYTONA_API_KEY and a GitHub token.
37+
# Coding (optional). Needs DAYTONA_API_KEY and one coding credential method.
3838
# export DAYTONA_API_KEY=dtn_...
3939
# export DAYTONA_SNAPSHOT=
4040
# export DAYTONA_TTL_MINUTES=60
41-
# A dedicated write token is preferred; it also powers read-only GitHub MCP
42-
# discovery when GITHUB_PERSONAL_ACCESS_TOKEN is unset.
41+
# A dedicated fine-grained PAT is preferred; classic PATs remain supported.
4342
# export GITHUB_CODER_TOKEN=github_pat_...
44-
# export GITHUB_ALLOWED_REPOS=your-org/*
43+
# Or configure one GitHub App installation instead of GITHUB_CODER_TOKEN.
44+
# export GITHUB_APP_ID=12345
45+
# export GITHUB_APP_INSTALLATION_ID=67890
46+
# export GITHUB_APP_PRIVATE_KEY_BASE64=base64-encoded-pem
4547
# Create a personal API key with PostHog's "MCP Server" preset.
4648
# The bundled connection uses CLI mode and is read-only.
4749
# export POSTHOG_PERSONAL_API_KEY=phx_...

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ jobs:
2121
- run: pnpm install --frozen-lockfile
2222
- run: pnpm check-types
2323
- run: pnpm test
24+
- name: Install AWS deployment dependencies
25+
run: pnpm --dir deployment/aws install --frozen-lockfile
26+
- name: Test AWS deployment
27+
run: pnpm --dir deployment/aws test
2428
- name: Validate Railway graph
2529
run: node node_modules/railway/dist/iac/bin.js
2630

.railway/railway.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ export default defineRailway(() => {
3232
DAYTONA_TTL_MINUTES: preserve(),
3333
GITHUB_PERSONAL_ACCESS_TOKEN: preserve(),
3434
GITHUB_CODER_TOKEN: preserve(),
35-
GITHUB_ALLOWED_REPOS: preserve(),
35+
GITHUB_APP_ID: preserve(),
36+
GITHUB_APP_INSTALLATION_ID: preserve(),
37+
GITHUB_APP_PRIVATE_KEY_BASE64: preserve(),
3638
GITHUB_MCP_URL: preserve(),
3739
POSTHOG_PERSONAL_API_KEY: preserve(),
3840
POSTHOG_MCP_URL: preserve(),

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ files.
4343
| AG-UI adapter | `agent/agui.py` | Slack recursion limit and user-facing graph-stop handling |
4444
| Persona | `agent/prompts/` | `system.py` is the base system prompt |
4545
| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before Linear or Notion writes |
46-
| Coder | `agent/coding/` | Daytona sandbox, `open_pull_request`, coder prompt |
46+
| Coder | `agent/coding/` | GitHub credentials, Daytona sandbox, repository publish tools, coder prompt |
4747
| Coder skills | `agent/coding/skills/` | Committed skills. Do not put them in `agent/skills/` |
4848
| Deployment | `.railway/railway.ts` | Two services, declared as code |
4949

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,15 +358,17 @@ knowledge work, and renders UI from model knowledge.
358358
| Variable | Enables |
359359
| ------------------------------------------ | ---------------------------------------------------------------- |
360360
| `TAVILY_API_KEY` | Live web research |
361-
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, issue, and PR search |
361+
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search |
362362
| `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) |
363363
| `LINEAR_API_KEY` | Hosted Linear MCP |
364364
| `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it |
365-
| `DAYTONA_API_KEY` + a GitHub token | Coding subagent: clone in Daytona, run tests, open a draft PR after `confirm_write` |
365+
| `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` |
366366

367367
Every Linear and Notion mutation is intercepted in code before the MCP request
368368
runs. The interceptor emits `confirm_write` and proceeds only after approval;
369-
reads and rendering do not pause. Draft PR opens use the same card.
369+
reads and rendering do not pause. Coder push plus draft-PR create/update uses the
370+
same card. See [`setup.md`](./setup.md#github) for PAT/App selection and required
371+
GitHub permissions.
370372

371373
[`setup.md`](./setup.md) documents each source, its overrides, and the full
372374
environment contract.

agent/agent.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818
from langgraph.checkpoint.memory import MemorySaver
1919
from langgraph.errors import GraphRecursionError
2020

21-
from coding.config import coding_enabled
21+
from coding.config import (
22+
coding_enabled,
23+
github_providers,
24+
log_configuration_warnings,
25+
)
2226
from coding.subagent import build_coder_subagent
2327
from copilotkit.langgraph import copilotkit_emit_message
2428
from langchain_core.runnables.config import ensure_config
25-
from internal_sources import internal_source_tools
29+
from internal_sources import internal_source_toolsets
2630
from prompts import (
2731
BASE_SYSTEM_PROMPT,
2832
DEFAULT_AGENT_DISPLAY_NAME,
@@ -163,7 +167,12 @@ def build_agent():
163167
use_responses_api=True,
164168
)
165169

166-
internal_tools = internal_source_tools()
170+
providers = github_providers()
171+
log_configuration_warnings(providers)
172+
source_toolsets = internal_source_toolsets(providers.search)
173+
internal_tools = [
174+
tool for tools in source_toolsets.values() for tool in tools
175+
]
167176
main_tools = (
168177
[web_search, *internal_tools]
169178
if has_web_search
@@ -199,9 +208,21 @@ def build_agent():
199208
"backend": StateBackend(),
200209
"checkpointer": checkpointer,
201210
}
202-
if coding_enabled():
211+
coding_on = bool(
212+
(os.environ.get("DAYTONA_API_KEY") or "").strip()
213+
and providers.coding
214+
and not providers.error
215+
and not providers.warning
216+
)
217+
if coding_on:
218+
assert providers.coding is not None
203219
create_kwargs["subagents"] = [
204-
build_coder_subagent(model=llm, checkpointer=checkpointer)
220+
build_coder_subagent(
221+
model=llm,
222+
checkpointer=checkpointer,
223+
provider=providers.coding,
224+
github_tools=source_toolsets.get("github", []),
225+
)
205226
]
206227

207228
agent_graph = create_deep_agent(**create_kwargs)
@@ -211,7 +232,7 @@ def build_agent():
211232
f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}"
212233
)
213234
print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}")
214-
print(f"[AGENT] coding: {'enabled' if coding_enabled() else 'disabled'}")
235+
print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}")
215236
print(f"[AGENT] internal-source tools: {len(internal_tools)}")
216237
print(f"[AGENT] Main tools: {[t.name for t in main_tools]}")
217238

agent/coding/config.py

Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,133 @@
1-
"""Env contract for the optional coding subagent."""
1+
"""Environment contract for the optional coding subagent."""
22

3+
from __future__ import annotations
4+
5+
import logging
36
import os
47
from collections.abc import Mapping
8+
from dataclasses import dataclass
9+
10+
from coding.github_credentials import (
11+
GitHubAppProvider,
12+
GitHubCredentialError,
13+
GitHubCredentialProvider,
14+
GitHubPatProvider,
15+
)
516

6-
# LangGraph always applies a limit (default 25). This is a safety stop for a
7-
# stuck loop, not a budget for a real job.
817
CODER_RECURSION_LIMIT = 500
18+
APP_ENV_NAMES = (
19+
"GITHUB_APP_ID",
20+
"GITHUB_APP_INSTALLATION_ID",
21+
"GITHUB_APP_PRIVATE_KEY_BASE64",
22+
)
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
@dataclass(frozen=True)
28+
class GitHubProviders:
29+
coding: GitHubCredentialProvider | None
30+
search: GitHubCredentialProvider | None
31+
error: str | None = None
32+
warning: str | None = None
933

1034

1135
def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
1236
return os.environ if env is None else env
1337

1438

15-
def write_token(env: Mapping[str, str] | None = None) -> str | None:
16-
source = _env(env)
17-
for name in ("GITHUB_CODER_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"):
18-
value = (source.get(name) or "").strip()
19-
if value:
20-
return value
21-
return None
39+
def _value(source: Mapping[str, str], name: str) -> str:
40+
return (source.get(name) or "").strip()
2241

2342

24-
def coding_enabled(env: Mapping[str, str] | None = None) -> bool:
43+
def github_providers(
44+
env: Mapping[str, str] | None = None,
45+
*,
46+
client=None,
47+
now=None,
48+
) -> GitHubProviders:
49+
"""Select search and coding credentials without making network calls."""
2550
source = _env(env)
26-
return bool((source.get("DAYTONA_API_KEY") or "").strip() and write_token(source))
27-
28-
29-
def allowed_repos(env: Mapping[str, str] | None = None) -> tuple[str, ...]:
30-
raw = (_env(env).get("GITHUB_ALLOWED_REPOS") or "").strip()
31-
if not raw:
32-
return ()
33-
return tuple(part.strip() for part in raw.split(",") if part.strip())
51+
search_pat = _value(source, "GITHUB_PERSONAL_ACCESS_TOKEN")
52+
coder_pat = _value(source, "GITHUB_CODER_TOKEN")
53+
app_values = tuple(_value(source, name) for name in APP_ENV_NAMES)
54+
app_configured = any(app_values)
55+
app_complete = all(app_values)
56+
57+
search = GitHubPatProvider(search_pat, client=client) if search_pat else None
58+
59+
if coder_pat and app_complete:
60+
return GitHubProviders(
61+
coding=None,
62+
search=search,
63+
error=(
64+
"GITHUB_CODER_TOKEN and complete GitHub App credentials are both "
65+
"configured; choose exactly one explicit coding method"
66+
),
67+
)
68+
if app_configured and not app_complete:
69+
missing = ", ".join(
70+
name for name, value in zip(APP_ENV_NAMES, app_values) if not value
71+
)
72+
return GitHubProviders(
73+
coding=None,
74+
search=search,
75+
warning=(
76+
"incomplete GitHub App credentials disable coding; missing " + missing
77+
),
78+
)
79+
80+
coding: GitHubCredentialProvider | None
81+
if coder_pat:
82+
coding = GitHubPatProvider(coder_pat, client=client)
83+
elif app_complete:
84+
try:
85+
coding = GitHubAppProvider(
86+
app_id=app_values[0],
87+
installation_id=app_values[1],
88+
private_key_base64=app_values[2],
89+
client=client,
90+
now=now,
91+
)
92+
except GitHubCredentialError as error:
93+
return GitHubProviders(coding=None, search=search, error=str(error))
94+
elif search_pat:
95+
coding = search
96+
else:
97+
coding = None
98+
99+
return GitHubProviders(coding=coding, search=search or coding)
34100

35101

36-
def repo_is_allowed(repo: str, env: Mapping[str, str] | None = None) -> bool:
37-
rules = allowed_repos(env)
38-
if not rules:
39-
return True
40-
owner, _, name = repo.partition("/")
41-
for rule in rules:
42-
if rule.endswith("/*"):
43-
if owner == rule[:-2]:
44-
return True
45-
elif repo == rule:
46-
return True
47-
return False
102+
def coding_enabled(env: Mapping[str, str] | None = None) -> bool:
103+
source = _env(env)
104+
selection = github_providers(source)
105+
return bool(
106+
_value(source, "DAYTONA_API_KEY")
107+
and selection.coding is not None
108+
and selection.error is None
109+
and selection.warning is None
110+
)
111+
112+
113+
def log_configuration_warnings(
114+
selection: GitHubProviders,
115+
env: Mapping[str, str] | None = None,
116+
) -> None:
117+
source = _env(env)
118+
if selection.error:
119+
logger.error("[CODER] GitHub configuration error: %s", selection.error)
120+
if selection.warning:
121+
logger.warning("[CODER] %s", selection.warning)
122+
if _value(source, "GITHUB_ALLOWED_REPOS"):
123+
logger.warning(
124+
"[CODER] GITHUB_ALLOWED_REPOS is ignored; GitHub permissions now "
125+
"define repository access"
126+
)
48127

49128

50129
def ttl_minutes(env: Mapping[str, str] | None = None) -> int:
51-
raw = (_env(env).get("DAYTONA_TTL_MINUTES") or "").strip()
130+
raw = _value(_env(env), "DAYTONA_TTL_MINUTES")
52131
try:
53132
value = int(raw)
54133
except ValueError:
@@ -57,5 +136,5 @@ def ttl_minutes(env: Mapping[str, str] | None = None) -> int:
57136

58137

59138
def snapshot_id(env: Mapping[str, str] | None = None) -> str | None:
60-
value = (_env(env).get("DAYTONA_SNAPSHOT") or "").strip()
139+
value = _value(_env(env), "DAYTONA_SNAPSHOT")
61140
return value or None

0 commit comments

Comments
 (0)