diff --git a/.env.example b/.env.example index e156405..a93ba82 100644 --- a/.env.example +++ b/.env.example @@ -29,24 +29,35 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # export SERVER_HOST=0.0.0.0 # export SERVER_PORT=8123 -# -- Internal Sources (Optional) -- -# A fine-grained PAT with read access enables GitHub code, repo, issue, and PR search. +# -- GitHub Search (Optional) -- +# A fine-grained PAT with read access enables GitHub repo, code, PR, and CI search. # If coding reuses this token, it also needs permission to push branches and open PRs. # export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_... # export GITHUB_MCP_URL=https://api.githubcopilot.com/mcp/readonly -# Coding (optional). Needs DAYTONA_API_KEY and a GitHub token. + +# -- Coding Agent (Optional) -- +# Requires Daytona and a GitHub credential: reuse the search PAT above, set a +# dedicated coder PAT, or configure one GitHub App installation. # export DAYTONA_API_KEY=dtn_... # export DAYTONA_SNAPSHOT= # export DAYTONA_TTL_MINUTES=60 -# A dedicated write token is preferred; it also powers read-only GitHub MCP -# discovery when GITHUB_PERSONAL_ACCESS_TOKEN is unset. +# Dedicated fine-grained PAT (preferred; classic PATs remain supported): # export GITHUB_CODER_TOKEN=github_pat_... -# export GITHUB_ALLOWED_REPOS=your-org/* +# GitHub App (instead of GITHUB_CODER_TOKEN): +# export GITHUB_APP_ID=12345 +# export GITHUB_APP_INSTALLATION_ID=67890 +# export GITHUB_APP_PRIVATE_KEY_BASE64=base64-encoded-pem + +# -- PostHog (Optional) -- # Create a personal API key with PostHog's "MCP Server" preset. # The bundled connection uses CLI mode and is read-only. # export POSTHOG_PERSONAL_API_KEY=phx_... # export POSTHOG_MCP_URL=https://mcp.posthog.com/mcp?mode=cli&readonly=true + +# -- Linear (Optional) -- # export LINEAR_API_KEY=lin_api_... # export LINEAR_MCP_URL=https://mcp.linear.app/mcp + +# -- Notion (Optional) -- # export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01612aa..718326b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm check-types - run: pnpm test + - name: Install AWS deployment dependencies + run: pnpm --dir deployment/aws install --frozen-lockfile + - name: Test AWS deployment + run: pnpm --dir deployment/aws test - name: Validate Railway graph run: node node_modules/railway/dist/iac/bin.js diff --git a/.railway/railway.ts b/.railway/railway.ts index cde1a2d..a71abfa 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -32,7 +32,9 @@ export default defineRailway(() => { DAYTONA_TTL_MINUTES: preserve(), GITHUB_PERSONAL_ACCESS_TOKEN: preserve(), GITHUB_CODER_TOKEN: preserve(), - GITHUB_ALLOWED_REPOS: preserve(), + GITHUB_APP_ID: preserve(), + GITHUB_APP_INSTALLATION_ID: preserve(), + GITHUB_APP_PRIVATE_KEY_BASE64: preserve(), GITHUB_MCP_URL: preserve(), POSTHOG_PERSONAL_API_KEY: preserve(), POSTHOG_MCP_URL: preserve(), diff --git a/AGENTS.md b/AGENTS.md index 255a901..1883b26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ files. | AG-UI adapter | `agent/agui.py` | Slack recursion limit and user-facing graph-stop handling | | Persona | `agent/prompts/` | `system.py` is the base system prompt | | Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before Linear or Notion writes | -| Coder | `agent/coding/` | Daytona sandbox, `open_pull_request`, coder prompt | +| Coder | `agent/coding/` | GitHub credentials, Daytona sandbox, repository publish tools, coder prompt | | Coder skills | `agent/coding/skills/` | Committed skills. Do not put them in `agent/skills/` | | Deployment | `.railway/railway.ts` | Two services, declared as code | diff --git a/README.md b/README.md index 6e89725..b9485b4 100644 --- a/README.md +++ b/README.md @@ -358,15 +358,17 @@ knowledge work, and renders UI from model knowledge. | Variable | Enables | | ------------------------------------------ | ---------------------------------------------------------------- | | `TAVILY_API_KEY` | Live web research | -| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, issue, and PR search | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search | | `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) | | `LINEAR_API_KEY` | Hosted Linear MCP | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | -| `DAYTONA_API_KEY` + a GitHub token | Coding subagent: clone in Daytona, run tests, open a draft PR after `confirm_write` | +| `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; -reads and rendering do not pause. Draft PR opens use the same card. +reads and rendering do not pause. Coder push plus draft-PR create/update uses the +same card. See [`setup.md`](./setup.md#github) for PAT/App selection and required +GitHub permissions. [`setup.md`](./setup.md) documents each source, its overrides, and the full environment contract. diff --git a/agent/agent.py b/agent/agent.py index 26cb2e6..d8fd087 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -18,11 +18,15 @@ from langgraph.checkpoint.memory import MemorySaver from langgraph.errors import GraphRecursionError -from coding.config import coding_enabled +from coding.config import ( + coding_enabled, + github_providers, + log_configuration_warnings, +) from coding.subagent import build_coder_subagent from copilotkit.langgraph import copilotkit_emit_message from langchain_core.runnables.config import ensure_config -from internal_sources import internal_source_tools +from internal_sources import internal_source_toolsets from prompts import ( BASE_SYSTEM_PROMPT, DEFAULT_AGENT_DISPLAY_NAME, @@ -132,9 +136,9 @@ def _validated_openai_setting( return value -def graph_recursion_limit() -> int: +def graph_recursion_limit(coding_on: bool | None = None) -> int: """Steps the main graph may take in one Slack turn.""" - return 80 if coding_enabled() else 25 + return 80 if (coding_enabled() if coding_on is None else coding_on) else 25 def build_agent(): @@ -163,7 +167,13 @@ def build_agent(): use_responses_api=True, ) - internal_tools = internal_source_tools() + providers = github_providers() + log_configuration_warnings(providers) + coding_on = coding_enabled(selection=providers) + source_toolsets = internal_source_toolsets(providers.search) + internal_tools = [ + tool for tools in source_toolsets.values() for tool in tools + ] main_tools = ( [web_search, *internal_tools] if has_web_search @@ -180,7 +190,7 @@ def build_agent(): else NO_WEB_SEARCH_TOOL_ADDENDUM ) system_prompt = system_prompt + ( - CODING_ON_ADDENDUM if coding_enabled() else CODING_OFF_ADDENDUM + CODING_ON_ADDENDUM if coding_on else CODING_OFF_ADDENDUM ) checkpointer = MemorySaver() @@ -199,9 +209,15 @@ def build_agent(): "backend": StateBackend(), "checkpointer": checkpointer, } - if coding_enabled(): + if coding_on: + assert providers.coding is not None create_kwargs["subagents"] = [ - build_coder_subagent(model=llm, checkpointer=checkpointer) + build_coder_subagent( + model=llm, + checkpointer=checkpointer, + provider=providers.coding, + github_tools=source_toolsets.get("github", []), + ) ] agent_graph = create_deep_agent(**create_kwargs) @@ -211,7 +227,7 @@ def build_agent(): f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}" ) print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") - print(f"[AGENT] coding: {'enabled' if coding_enabled() else 'disabled'}") + print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") @@ -219,6 +235,6 @@ def build_agent(): # enough for chat and too low for "read this PR, then code". # graph.with_config is for direct invoke. Slack/AG-UI must also get # this value on LangGraphAGUIAgent(config=...) in main.py. - recursion_limit = graph_recursion_limit() + recursion_limit = graph_recursion_limit(coding_on) print(f"[AGENT] recursion_limit: {recursion_limit}") return agent_graph.with_config({"recursion_limit": recursion_limit}) diff --git a/agent/agui.py b/agent/agui.py index e4d9798..c2ff184 100644 --- a/agent/agui.py +++ b/agent/agui.py @@ -35,7 +35,10 @@ async def run(self, input_data): def build_agui_agent(graph, *, recursion_limit: int | None = None): - """Wire the Slack/AG-UI adapter. It does not use graph.with_config.""" + """Wire the Slack/AG-UI adapter with the graph's resolved step limit.""" + if recursion_limit is None: + graph_config = getattr(graph, "config", None) or {} + recursion_limit = graph_config.get("recursion_limit") if recursion_limit is None: recursion_limit = graph_recursion_limit() return OpenTagAGUIAgent( diff --git a/agent/coding/config.py b/agent/coding/config.py index 03263a2..3456185 100644 --- a/agent/coding/config.py +++ b/agent/coding/config.py @@ -1,54 +1,137 @@ -"""Env contract for the optional coding subagent.""" +"""Environment contract for the optional coding subagent.""" +from __future__ import annotations + +import logging import os from collections.abc import Mapping +from dataclasses import dataclass -# LangGraph always applies a limit (default 25). This is a safety stop for a -# stuck loop, not a budget for a real job. -CODER_RECURSION_LIMIT = 500 +from coding.github_credentials import ( + GitHubAppProvider, + GitHubCredentialError, + GitHubCredentialProvider, + GitHubPatProvider, +) +CODER_RECURSION_LIMIT = 500 +APP_ENV_NAMES = ( + "GITHUB_APP_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_APP_PRIVATE_KEY_BASE64", +) -def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: - return os.environ if env is None else env +logger = logging.getLogger(__name__) -def write_token(env: Mapping[str, str] | None = None) -> str | None: - source = _env(env) - for name in ("GITHUB_CODER_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"): - value = (source.get(name) or "").strip() - if value: - return value - return None +@dataclass(frozen=True) +class GitHubProviders: + coding: GitHubCredentialProvider | None + search: GitHubCredentialProvider | None + error: str | None = None + warning: str | None = None -def coding_enabled(env: Mapping[str, str] | None = None) -> bool: - source = _env(env) - return bool((source.get("DAYTONA_API_KEY") or "").strip() and write_token(source)) +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env -def allowed_repos(env: Mapping[str, str] | None = None) -> tuple[str, ...]: - raw = (_env(env).get("GITHUB_ALLOWED_REPOS") or "").strip() - if not raw: - return () - return tuple(part.strip() for part in raw.split(",") if part.strip()) +def _value(source: Mapping[str, str], name: str) -> str: + return (source.get(name) or "").strip() -def repo_is_allowed(repo: str, env: Mapping[str, str] | None = None) -> bool: - rules = allowed_repos(env) - if not rules: - return True - owner, _, name = repo.partition("/") - for rule in rules: - if rule.endswith("/*"): - if owner == rule[:-2]: - return True - elif repo == rule: - return True - return False +def github_providers( + env: Mapping[str, str] | None = None, + *, + client=None, + now=None, +) -> GitHubProviders: + """Select search and coding credentials without making network calls.""" + source = _env(env) + search_pat = _value(source, "GITHUB_PERSONAL_ACCESS_TOKEN") + coder_pat = _value(source, "GITHUB_CODER_TOKEN") + app_values = tuple(_value(source, name) for name in APP_ENV_NAMES) + app_configured = any(app_values) + app_complete = all(app_values) + + search = GitHubPatProvider(search_pat, client=client) if search_pat else None + + if coder_pat and app_complete: + return GitHubProviders( + coding=None, + search=search, + error=( + "GITHUB_CODER_TOKEN and complete GitHub App credentials are both " + "configured; choose exactly one explicit coding method" + ), + ) + if app_configured and not app_complete: + missing = ", ".join( + name for name, value in zip(APP_ENV_NAMES, app_values) if not value + ) + return GitHubProviders( + coding=None, + search=search, + warning=( + "incomplete GitHub App credentials disable coding; missing " + missing + ), + ) + + coding: GitHubCredentialProvider | None + if coder_pat: + coding = GitHubPatProvider(coder_pat, client=client) + elif app_complete: + try: + coding = GitHubAppProvider( + app_id=app_values[0], + installation_id=app_values[1], + private_key_base64=app_values[2], + client=client, + now=now, + ) + except GitHubCredentialError as error: + return GitHubProviders(coding=None, search=search, error=str(error)) + elif search_pat: + coding = search + else: + coding = None + + return GitHubProviders(coding=coding, search=search or coding) + + +def coding_enabled( + env: Mapping[str, str] | None = None, + *, + selection: GitHubProviders | None = None, +) -> bool: + source = _env(env) + selection = selection or github_providers(source) + return bool( + _value(source, "DAYTONA_API_KEY") + and selection.coding is not None + and selection.error is None + and selection.warning is None + ) + + +def log_configuration_warnings( + selection: GitHubProviders, + env: Mapping[str, str] | None = None, +) -> None: + source = _env(env) + if selection.error: + logger.error("[CODER] GitHub configuration error: %s", selection.error) + if selection.warning: + logger.warning("[CODER] %s", selection.warning) + if _value(source, "GITHUB_ALLOWED_REPOS"): + logger.warning( + "[CODER] GITHUB_ALLOWED_REPOS is ignored; GitHub permissions now " + "define repository access" + ) def ttl_minutes(env: Mapping[str, str] | None = None) -> int: - raw = (_env(env).get("DAYTONA_TTL_MINUTES") or "").strip() + raw = _value(_env(env), "DAYTONA_TTL_MINUTES") try: value = int(raw) except ValueError: @@ -57,5 +140,5 @@ def ttl_minutes(env: Mapping[str, str] | None = None) -> int: def snapshot_id(env: Mapping[str, str] | None = None) -> str | None: - value = (_env(env).get("DAYTONA_SNAPSHOT") or "").strip() + value = _value(_env(env), "DAYTONA_SNAPSHOT") return value or None diff --git a/agent/coding/github_credentials.py b/agent/coding/github_credentials.py new file mode 100644 index 0000000..584bfb4 --- /dev/null +++ b/agent/coding/github_credentials.py @@ -0,0 +1,276 @@ +"""Host-side GitHub PAT and App credential providers.""" + +from __future__ import annotations + +import asyncio +import base64 +import threading +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable +from urllib.parse import quote + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import load_pem_private_key + +GITHUB_API_URL = "https://api.github.com" +TOKEN_REFRESH_WINDOW = timedelta(minutes=5) + + +@dataclass(frozen=True) +class GitHubIdentity: + login: str + database_id: int + email: str + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _redact(text: str, credential: str | None) -> str: + return text.replace(credential, "[redacted]") if credential else text + + +class GitHubCredentialError(RuntimeError): + pass + + +def _object_response(value: Any, operation: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise GitHubCredentialError( + f"GitHub returned an invalid response for {operation}" + ) + return value + + +class GitHubCredentialProvider(ABC): + """A host-only source of short-lived operation credentials and identity.""" + + kind: str + git_username = "x-access-token" + + def __init__(self, *, client=None): + self._client = client + + def _http_client(self): + if self._client is None: + self._client = httpx.Client(timeout=20.0) + return self._client + + @abstractmethod + def token(self) -> str: + raise NotImplementedError + + @abstractmethod + def identity(self) -> GitHubIdentity: + raise NotImplementedError + + def request_json( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[Any]: + credential = self.token() + return self._request_json(method, path, credential=credential, json=json) + + def _request_json( + self, + method: str, + path: str, + *, + credential: str, + json: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[Any]: + try: + response = self._http_client().request( + method, + f"{GITHUB_API_URL}{path}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {credential}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json=json, + ) + response.raise_for_status() + result = response.json() + if not isinstance(result, (dict, list)): + raise GitHubCredentialError("GitHub returned an invalid JSON response") + return result + except GitHubCredentialError: + raise + except Exception as error: + detail = _redact(str(error), credential) + raise GitHubCredentialError( + f"GitHub API {method.upper()} {path} failed: {detail}" + ) from error + + +class GitHubPatProvider(GitHubCredentialProvider): + kind = "personal access token" + + def __init__(self, token: str, *, client=None): + super().__init__(client=client) + self._token = token + self._identity: GitHubIdentity | None = None + self._identity_lock = threading.Lock() + + def token(self) -> str: + return self._token + + def identity(self) -> GitHubIdentity: + if self._identity is not None: + return self._identity + with self._identity_lock: + if self._identity is None: + data = _object_response( + self.request_json("GET", "/user"), "user identity" + ) + login = str(data["login"]) + database_id = int(data["id"]) + self._identity = GitHubIdentity( + login=login, + database_id=database_id, + email=f"{database_id}+{login}@users.noreply.github.com", + ) + return self._identity + + +class GitHubProviderAuth(httpx.Auth): + """Resolve the provider's current token for every MCP HTTP session.""" + + def __init__(self, provider: GitHubCredentialProvider): + self.provider = provider + + def sync_auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self.provider.token()}" + yield request + + async def async_auth_flow(self, request): + token = await asyncio.to_thread(self.provider.token) + request.headers["Authorization"] = f"Bearer {token}" + yield request + + +class GitHubAppProvider(GitHubCredentialProvider): + kind = "GitHub App" + + def __init__( + self, + *, + app_id: str, + installation_id: str, + private_key_base64: str, + client=None, + now: Callable[[], datetime] | None = None, + ): + super().__init__(client=client) + self.app_id = app_id + self.installation_id = installation_id + try: + encoded_key = base64.b64decode(private_key_base64, validate=True) + except Exception as error: + raise GitHubCredentialError( + "GITHUB_APP_PRIVATE_KEY_BASE64 is not valid base64-encoded text" + ) from error + try: + private_key = load_pem_private_key(encoded_key, password=None) + except Exception as error: + raise GitHubCredentialError( + "GITHUB_APP_PRIVATE_KEY_BASE64 is not a valid unencrypted PEM private key" + ) from error + if not isinstance(private_key, rsa.RSAPrivateKey): + raise GitHubCredentialError( + "GITHUB_APP_PRIVATE_KEY_BASE64 must contain an RSA private key" + ) + self._private_key = private_key + self._now = now or _utcnow + self._installation_token: str | None = None + self._expires_at: datetime | None = None + self._refresh_lock = threading.Lock() + self._identity: GitHubIdentity | None = None + self._identity_lock = threading.Lock() + + def _app_jwt(self) -> str: + now = self._now() + try: + return jwt.encode( + { + "iat": int((now - timedelta(seconds=60)).timestamp()), + "exp": int((now + timedelta(minutes=9)).timestamp()), + "iss": self.app_id, + }, + self._private_key, + algorithm="RS256", + ) + except Exception as error: + raise GitHubCredentialError( + "failed to sign a GitHub App authentication JWT" + ) from error + + def token(self) -> str: + now = self._now() + if ( + self._installation_token + and self._expires_at + and self._expires_at - now > TOKEN_REFRESH_WINDOW + ): + return self._installation_token + with self._refresh_lock: + now = self._now() + if ( + self._installation_token + and self._expires_at + and self._expires_at - now > TOKEN_REFRESH_WINDOW + ): + return self._installation_token + app_jwt = self._app_jwt() + data = _object_response( + self._request_json( + "POST", + f"/app/installations/{quote(self.installation_id, safe='')}/access_tokens", + credential=app_jwt, + ), + "installation token", + ) + self._installation_token = str(data["token"]) + self._expires_at = _parse_time(str(data["expires_at"])) + return self._installation_token + + def identity(self) -> GitHubIdentity: + if self._identity is not None: + return self._identity + with self._identity_lock: + if self._identity is None: + app_jwt = self._app_jwt() + app = _object_response( + self._request_json("GET", "/app", credential=app_jwt), + "App identity", + ) + login = f"{app['slug']}[bot]" + user = _object_response( + self.request_json( + "GET", f"/users/{quote(login, safe='')}" + ), + "App bot identity", + ) + resolved_login = str(user["login"]) + database_id = int(user["id"]) + self._identity = GitHubIdentity( + login=resolved_login, + database_id=database_id, + email=( + f"{database_id}+{resolved_login}@users.noreply.github.com" + ), + ) + return self._identity diff --git a/agent/coding/open_pull_request.py b/agent/coding/open_pull_request.py deleted file mode 100644 index 424f2c6..0000000 --- a/agent/coding/open_pull_request.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Open a draft PR from the current Daytona box after confirm_write.""" - -import re -import shlex - -from langchain_core.tools import tool - -from coding.config import repo_is_allowed -from coding.sandbox import PerJobDaytonaBackend -from write_confirmation import ( - emit_write_failure, - require_write_confirmation, - summarize_args, -) - -_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") -_BODY_PATH = "/tmp/opentag-pr-body.md" - - -def build_open_pull_request(backend: PerJobDaytonaBackend): - @tool - def open_pull_request( - repo: str, - base: str, - head: str, - title: str, - body: str, - test_command: str, - test_exit_code: int, - open_anyway: bool = False, - ) -> str: - """Open a draft GitHub pull request from the sandbox after approval. - - Call this only after the job check has run. Pass the exact command and - its exit code. Set open_anyway only when the user asked to open a PR - even if the check is red. - """ - if not _REPO_RE.fullmatch(repo): - raise RuntimeError( - f"invalid repo {repo!r}: expected owner/name with only " - "letters, digits, dots, underscores, and hyphens" - ) - if not repo_is_allowed(repo): - raise RuntimeError(f"{repo} is outside GITHUB_ALLOWED_REPOS") - if test_exit_code != 0 and not open_anyway: - raise RuntimeError( - "test_exit_code is not 0; will not open a PR " - "(pass open_anyway only if the user asked)" - ) - - fields = summarize_args( - { - "repo": repo, - "base": base, - "head": head, - "title": title, - "test_command": test_command, - "test_exit_code": test_exit_code, - "open_anyway": open_anyway, - } - ) - confirmed = require_write_confirmation( - action="Open draft pull request", - fields=fields, - ) - if not confirmed: - return ( - "status: cancelled\n" - "Pull request was not opened. " - "If the branch was already pushed, it is still on GitHub." - ) - - backend.write(_BODY_PATH, body) - result = backend.execute( - "gh pr create --draft " - f"--repo {shlex.quote(repo)} " - f"--base {shlex.quote(base)} " - f"--head {shlex.quote(head)} " - f"--title {shlex.quote(title)} " - f"--body-file {shlex.quote(_BODY_PATH)}" - ) - if result.exit_code != 0: - failure = ( - f"gh pr create failed ({result.exit_code}): {result.output}" - ) - emit_write_failure("Open draft pull request", failure) - raise RuntimeError(failure) - return ( - "status: opened\n" - f"pr_url: {result.output.strip()}\n" - f"test_command: {test_command}\n" - f"test_exit_code: {test_exit_code}" - ) - - return open_pull_request diff --git a/agent/coding/prompt.py b/agent/coding/prompt.py index 60c314b..2df7f61 100644 --- a/agent/coding/prompt.py +++ b/agent/coding/prompt.py @@ -3,10 +3,12 @@ CODER_PROMPT = """You are the OpenTag coder. You work only in the Daytona sandbox. Hard rules: -- Use execute, filesystem tools, and open_pull_request only -- Do not call Linear, Notion, or GitHub MCP. You do not have those tools -- Clone with git. Authenticate with the GITHUB_TOKEN already in the environment -- Work on a new branch. Do not push to main +- Call prepare_repository before reading or editing the checkout +- Use execute, filesystem tools, the provided read-only GitHub tools, prepare_repository, and publish_changes only +- GitHub tools are discovery-only. Never use a write, trigger, rerun, cancel, or delete tool +- Perform only local Git commands yourself. Never clone, pull, fetch, or push with git, and never invoke gh +- Use the working directory, base branch, and head branch returned by prepare_repository +- Work only on the head branch returned by prepare_repository. Never target main - Run the job's check after your edits - Node is on the default snapshot. The first node/npm/pnpm command installs pnpm into $HOME/.local/bin via corepack. Use that PATH. - If pnpm or node is still missing after that install, stop. Return status: failed. Do not invent a test patch. Do not push untested changes. @@ -18,11 +20,12 @@ - For fix-tests, fix-ci, and merge-main, inspect only the checkout and logs needed to reproduce the failure, then edit the smallest necessary file set. Prefer a test command from the brief; otherwise follow the selected skill -- Call open_pull_request only when the check has exit 0, unless the brief says open anyway: true (open_anyway: true) +- Commit locally, then call publish_changes only when the check has exit 0, unless the brief says open anyway: true (open_anyway: true) - If the check stays red, return status: failed, the command, and the tail of the log - Never invent a PR URL -- Never print GITHUB_TOKEN or other secrets -- When the user rejects confirm_write, stop. Say if a branch was already pushed +- Never look for, print, or configure GitHub credentials. No credential exists in the sandbox +- If publish_changes reports that the branch was pushed but the PR write failed, call it once more with identical arguments; the retry skips the push +- When the user rejects confirm_write, stop. No branch was pushed Read the skill named in the brief and follow it. Return a short result with status, pr_url if any, test_command, test_exit_code, summary, and branch_pushed. diff --git a/agent/coding/repository_tools.py b/agent/coding/repository_tools.py new file mode 100644 index 0000000..f871c58 --- /dev/null +++ b/agent/coding/repository_tools.py @@ -0,0 +1,480 @@ +"""Prepare and publish coder repositories without exposing GitHub credentials.""" + +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +from langchain_core.tools import tool + +from coding.github_credentials import GitHubCredentialProvider, GitHubIdentity +from coding.sandbox import PerJobDaytonaBackend, current_run_id +from write_confirmation import ( + emit_write_failure, + require_write_confirmation, + summarize_args, +) + +_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") +_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") + + +@dataclass +class PreparedRepository: + repo: str + push_repo: str + path: str + base_branch: str + head_branch: str + identity: GitHubIdentity + pr_number: int | None + request_signature: tuple[Any, ...] + approved_signature: tuple[Any, ...] | None = None + pushed_commit: str | None = None + published_signature: tuple[Any, ...] | None = None + published_result: str | None = None + + +def _validate_repo(repo: str) -> None: + if not _REPO_RE.fullmatch(repo): + raise RuntimeError( + f"invalid repo {repo!r}: expected owner/name with only letters, " + "digits, dots, underscores, and hyphens" + ) + + +def _validate_branch(branch: str) -> None: + if ( + not _BRANCH_RE.fullmatch(branch) + or branch.startswith(("/", "-", ".")) + or branch.endswith(("/", ".")) + or ".." in branch + or "//" in branch + ): + raise RuntimeError(f"invalid Git branch {branch!r}") + + +def _nested(data: dict[str, Any], *path: str) -> Any: + value: Any = data + for part in path: + if not isinstance(value, dict) or part not in value: + raise RuntimeError(f"GitHub response is missing {'.'.join(path)}") + value = value[part] + return value + + +def _generated_branch() -> str: + suffix = re.sub(r"[^A-Za-z0-9._-]", "-", current_run_id()).strip("-.") + return f"opentag/{suffix or 'change'}" + + +def _run_git(backend: PerJobDaytonaBackend, path: str, args: str): + return backend.execute(f"git -C {shlex.quote(path)} {args}") + + +def _prepared_result(prepared: PreparedRepository, *, replayed: bool = False) -> str: + status = "already_prepared" if replayed else "prepared" + return ( + f"status: {status}\n" + f"repository: {prepared.repo}\n" + f"push_repository: {prepared.push_repo}\n" + f"working_directory: {prepared.path}\n" + f"base_branch: {prepared.base_branch}\n" + f"head_branch: {prepared.head_branch}\n" + f"actor: {prepared.identity.login}\n" + f"pr_number: {prepared.pr_number or ''}" + ) + + +def _matching_open_pull( + provider: GitHubCredentialProvider, + prepared: PreparedRepository, + commit: str, + *, + title: str, + body: str, +) -> dict[str, Any] | None: + """Find the PR created by an ambiguous prior POST, if one exists.""" + head_owner = prepared.push_repo.split("/", 1)[0] + query = urlencode( + { + "state": "open", + "head": f"{head_owner}:{prepared.head_branch}", + "base": prepared.base_branch, + } + ) + pulls = provider.request_json( + "GET", f"/repos/{prepared.repo}/pulls?{query}" + ) + if not isinstance(pulls, list): + raise RuntimeError("GitHub returned an invalid pull-request list") + for pull in pulls: + if not isinstance(pull, dict): + continue + if ( + str(_nested(pull, "base", "repo", "full_name")).casefold() + == prepared.repo.casefold() + and str(_nested(pull, "head", "repo", "full_name")).casefold() + == prepared.push_repo.casefold() + and str(_nested(pull, "base", "ref")) == prepared.base_branch + and str(_nested(pull, "head", "ref")) == prepared.head_branch + and str(_nested(pull, "head", "sha")) == commit + and pull.get("title") == title + and pull.get("body") == body + and pull.get("draft") is True + ): + return pull + return None + + +def _publish_result( + *, + action: str, + pull: dict[str, Any] | list[Any], + test_command: str, + test_exit_code: int, +) -> tuple[str, int]: + if not isinstance(pull, dict): + raise RuntimeError("GitHub returned an invalid pull-request response") + url = pull.get("html_url") + number = pull.get("number") + if not isinstance(url, str) or not url or not isinstance(number, int): + raise RuntimeError("GitHub pull-request response is missing its URL or number") + return ( + f"status: {action}\n" + f"pr_url: {url}\n" + "branch_pushed: true\n" + f"test_command: {test_command}\n" + f"test_exit_code: {test_exit_code}", + number, + ) + + +def build_repository_tools( + backend: PerJobDaytonaBackend, + provider: GitHubCredentialProvider, +): + @tool + def prepare_repository( + repo: str, + base_branch: str | None = None, + head_branch: str | None = None, + pr_number: int | None = None, + sync_base: bool = False, + ) -> str: + """Clone an explicit GitHub target and prepare its local working branch. + + Call this before reading or editing the repository. For an existing PR, + pass pr_number so its actual head repository and branch are verified. + Set sync_base only for merge-main work. + """ + _validate_repo(repo) + if base_branch: + _validate_branch(base_branch) + if head_branch: + _validate_branch(head_branch) + if pr_number is not None and pr_number < 1: + raise RuntimeError("pr_number must be positive") + + request_signature = ( + repo.casefold(), + base_branch, + head_branch, + pr_number, + sync_base, + ) + + state = backend.job_state() + prior = state.get("repository") + if isinstance(prior, PreparedRepository): + if prior.request_signature == request_signature: + return _prepared_result(prior, replayed=True) + raise RuntimeError("prepare_repository may be called only once per coder job") + if prior is not None: + raise RuntimeError("coder job contains invalid repository state") + + push_repo = repo + existing_pr: dict[str, Any] | None = None + if pr_number is not None: + existing_pr = provider.request_json( + "GET", f"/repos/{repo}/pulls/{pr_number}" + ) + if not isinstance(existing_pr, dict): + raise RuntimeError("GitHub returned an invalid pull-request response") + if existing_pr.get("state") != "open": + raise RuntimeError(f"PR #{pr_number} is not open") + actual_base = str(_nested(existing_pr, "base", "ref")) + actual_head = str(_nested(existing_pr, "head", "ref")) + actual_base_repo = str( + _nested(existing_pr, "base", "repo", "full_name") + ) + push_repo = str(_nested(existing_pr, "head", "repo", "full_name")) + if actual_base_repo.casefold() != repo.casefold(): + raise RuntimeError(f"PR #{pr_number} does not target {repo}") + if base_branch and base_branch != actual_base: + raise RuntimeError( + f"PR #{pr_number} targets {actual_base}, not {base_branch}" + ) + if head_branch and head_branch != actual_head: + raise RuntimeError( + f"PR #{pr_number} uses {actual_head}, not {head_branch}" + ) + base_branch, head_branch = actual_base, actual_head + else: + if not base_branch: + repository = provider.request_json("GET", f"/repos/{repo}") + if not isinstance(repository, dict): + raise RuntimeError("GitHub returned an invalid repository response") + base_branch = str(repository["default_branch"]) + head_branch = head_branch or _generated_branch() + if head_branch == base_branch: + raise RuntimeError("head_branch must differ from base_branch for a new PR") + + assert base_branch is not None and head_branch is not None + _validate_repo(push_repo) + _validate_branch(base_branch) + _validate_branch(head_branch) + + identity = provider.identity() + token = provider.token() + path = "workspace/" + push_repo.replace("/", "-") + clone_branch = head_branch if existing_pr else base_branch + backend.clone_repository( + repo=push_repo, + path=path, + branch=clone_branch, + username=provider.git_username, + token=token, + ) + backend.set_git_identity( + path=path, + name=identity.login, + email=identity.email, + ) + + if existing_pr is None: + result = _run_git( + backend, path, f"switch -c {shlex.quote(head_branch)}" + ) + if result.exit_code != 0: + raise RuntimeError(f"failed to create working branch: {result.output}") + + if sync_base: + pull_mode = _run_git(backend, path, "config pull.rebase false") + if pull_mode.exit_code != 0: + raise RuntimeError( + f"failed to configure merge-based synchronization: {pull_mode.output}" + ) + remote = "origin" + if push_repo.casefold() != repo.casefold(): + remote = "upstream" + backend.add_remote(path=path, name=remote, repo=repo) + backend.pull_repository( + path=path, + branch=base_branch, + remote=remote, + username=provider.git_username, + token=provider.token(), + ) + + prepared = PreparedRepository( + repo=repo, + push_repo=push_repo, + path=path, + base_branch=base_branch, + head_branch=head_branch, + identity=identity, + pr_number=pr_number, + request_signature=request_signature, + ) + state["repository"] = prepared + return _prepared_result(prepared) + + @tool + def publish_changes( + repo: str, + base_branch: str, + head_branch: str, + title: str, + body: str, + test_command: str, + test_exit_code: int, + existing_pr_number: int | None = None, + open_anyway: bool = False, + ) -> str: + """After a local commit, confirm, push once, and create or update a PR. + + A retry after PR creation fails skips the already-successful push and + retries only the GitHub REST write. + """ + _validate_repo(repo) + _validate_branch(base_branch) + _validate_branch(head_branch) + if test_exit_code != 0 and not open_anyway: + raise RuntimeError( + "test_exit_code is not 0; will not publish changes " + "(pass open_anyway only if the user asked)" + ) + + prepared = backend.job_state().get("repository") + if not isinstance(prepared, PreparedRepository): + raise RuntimeError("prepare_repository must succeed before publish_changes") + if (repo, base_branch, head_branch) != ( + prepared.repo, + prepared.base_branch, + prepared.head_branch, + ): + raise RuntimeError("publish target does not match prepare_repository") + + pr_number = existing_pr_number or prepared.pr_number + if existing_pr_number and existing_pr_number != prepared.pr_number: + raise RuntimeError( + "existing_pr_number was not verified by prepare_repository" + ) + + branch = _run_git(backend, prepared.path, "branch --show-current") + if branch.exit_code != 0 or branch.output.strip() != head_branch: + raise RuntimeError( + f"working branch is {branch.output.strip()!r}, expected {head_branch!r}" + ) + dirty = _run_git(backend, prepared.path, "status --porcelain") + if dirty.exit_code != 0: + raise RuntimeError(f"failed to inspect worktree: {dirty.output}") + if dirty.output.strip(): + raise RuntimeError("worktree has uncommitted changes; commit before publishing") + revision = _run_git(backend, prepared.path, "rev-parse HEAD") + if revision.exit_code != 0: + raise RuntimeError(f"failed to resolve commit: {revision.output}") + commit = revision.output.strip() + + publish_signature = ( + commit, + repo, + base_branch, + head_branch, + title, + body, + test_command, + test_exit_code, + open_anyway, + ) + if ( + prepared.published_signature == publish_signature + and prepared.published_result is not None + ): + return prepared.published_result + + approval_signature = ( + commit, + title, + body, + test_command, + test_exit_code, + pr_number, + open_anyway, + ) + if prepared.approved_signature != approval_signature: + fields = summarize_args( + { + "actor": prepared.identity.login, + "repository": repo, + "commit": commit, + "branch": head_branch, + "base_branch": base_branch, + "pull_request": ( + f"update #{pr_number}" if pr_number else "create draft" + ), + "test_command": test_command, + "test_exit_code": test_exit_code, + "open_anyway": open_anyway, + } + ) + confirmed = require_write_confirmation( + action="Push branch and publish pull request", + fields=fields, + ) + if not confirmed: + return ( + "status: cancelled\n" + "branch_pushed: false\n" + "No GitHub write was performed." + ) + prepared.approved_signature = approval_signature + + if prepared.pushed_commit != commit: + try: + backend.push_repository( + path=prepared.path, + branch=head_branch, + username=provider.git_username, + token=provider.token(), + ) + except Exception as error: + failure = f"push failed: {error}" + emit_write_failure("Push branch and publish pull request", failure) + raise RuntimeError(failure) from error + prepared.pushed_commit = commit + + payload = { + "title": title, + "body": body, + "base": base_branch, + } + action = "updated" if pr_number else "opened" + try: + if pr_number: + pull = provider.request_json( + "PATCH", + f"/repos/{repo}/pulls/{pr_number}", + json=payload, + ) + else: + try: + pull = provider.request_json( + "POST", + f"/repos/{repo}/pulls", + json={**payload, "head": head_branch, "draft": True}, + ) + except Exception as create_error: + try: + pull = _matching_open_pull( + provider, + prepared, + commit, + title=title, + body=body, + ) + except Exception: + raise create_error + if pull is None: + raise create_error + + result, published_pr_number = _publish_result( + action=action, + pull=pull, + test_command=test_command, + test_exit_code=test_exit_code, + ) + except Exception as error: + failure = f"pull request write failed after push: {error}" + emit_write_failure("Push branch and publish pull request", failure) + return ( + "status: branch_pushed\n" + "branch_pushed: true\n" + f"repository: {prepared.push_repo}\n" + f"head_branch: {head_branch}\n" + f"commit: {commit}\n" + f"pr_error: {failure}\n" + "retry: call publish_changes again; only the PR write will retry" + ) + + if prepared.pr_number is None: + prepared.pr_number = published_pr_number + prepared.published_signature = publish_signature + prepared.published_result = result + return result + + return [prepare_repository, publish_changes] diff --git a/agent/coding/sandbox.py b/agent/coding/sandbox.py index a1a88b0..7865b77 100644 --- a/agent/coding/sandbox.py +++ b/agent/coding/sandbox.py @@ -21,22 +21,20 @@ ) from langchain.agents.middleware import AgentMiddleware -from coding.config import snapshot_id, ttl_minutes, write_token +from coding.config import snapshot_id, ttl_minutes -_TOKEN_RE = re.compile(r"(ghp|github_pat|gho)_[A-Za-z0-9_]+") -_TOOL_CMD_RE = re.compile(r"\b(git|gh|node|npm|npx|pnpm|corepack|yarn)\b") +_TOKEN_RE = re.compile(r"(ghp|github_pat|gho|ghs|ghu|ghr)_[A-Za-z0-9_]+") +_TOOL_CMD_RE = re.compile(r"\b(git|node|npm|npx|pnpm|corepack|yarn)\b") _NODE_TOOLS = frozenset({"node", "npm", "npx", "pnpm", "corepack", "yarn"}) _logger = logging.getLogger(__name__) _PROBE_TIMEOUT = 30 _GIT_INSTALL_TIMEOUT = 180 -_GH_INSTALL_TIMEOUT = 120 _PNPM_INSTALL_TIMEOUT = 120 _MAX_EXECUTE_OUTPUT = 8000 _PROBE_CMD = ( "printf '%s\\n' __probe_ok__; " 'printf "GIT=%s\\n" "$(command -v git || echo MISSING)"; ' - 'printf "GH=%s\\n" "$(command -v gh || echo MISSING)"; ' 'printf "NODE=%s\\n" "$(command -v node || echo MISSING)"; ' 'printf "PNPM=%s\\n" "$(command -v pnpm || echo MISSING)"' ) @@ -46,27 +44,6 @@ 'PATH="$HOME/.local/bin:/usr/local/bin:$PATH"; ' "apt-get update && apt-get install -y git curl" ) -_GH_INSTALL_CMD = ( - "set -e; " - 'export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"; ' - "if ! command -v curl >/dev/null; then " - "export DEBIAN_FRONTEND=noninteractive; " - "apt-get update && apt-get install -y curl; " - "fi; " - "mkdir -p /tmp/gh-install \"$HOME/.local/bin\"; " - "ARCH=$(uname -m); " - 'case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; esac; ' - "curl -fL --retry 2 --max-time 60 " - '"https://github.com/cli/cli/releases/download/v2.74.1/' - 'gh_2.74.1_linux_${ARCH}.tar.gz" ' - "-o /tmp/gh-install/gh.tgz; " - "tar -xzf /tmp/gh-install/gh.tgz -C /tmp/gh-install; " - 'GH_BIN=$(find /tmp/gh-install -type f -name gh | head -1); ' - 'cp "$GH_BIN" "$HOME/.local/bin/gh"; ' - 'chmod +x "$HOME/.local/bin/gh"; ' - 'cp "$HOME/.local/bin/gh" /usr/local/bin/gh 2>/dev/null || true; ' - "command -v gh" -) _PNPM_INSTALL_CMD = ( "set -e; " 'export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"; ' @@ -83,7 +60,7 @@ def current_run_id() -> str: - """LangGraph run id, or thread id, or `unknown`.""" + """Stable coder-job id across nested tool calls and approval resumes.""" try: from langgraph.config import get_config @@ -91,12 +68,16 @@ def current_run_id() -> str: configurable = config.get("configurable") or {} except Exception: return "unknown" - return str( - config.get("run_id") - or configurable.get("run_id") - or configurable.get("thread_id") - or "unknown" - ) + thread_id = configurable.get("thread_id") + checkpoint_ns = str(configurable.get("checkpoint_ns") or "") + job_ns = checkpoint_ns.partition("|")[0] + if job_ns: + return f"{thread_id}:{job_ns}" if thread_id else job_ns + + run_id = config.get("run_id") or configurable.get("run_id") + if run_id: + return str(run_id) + return str(thread_id or "unknown") def _raw_sandbox(box): @@ -132,9 +113,8 @@ def _tool_present(output: str, name: str) -> bool: def redact_secrets(text: str, secret: str | None = None) -> str: redacted = _TOKEN_RE.sub(lambda match: f"{match.group(1)}_[redacted]", text) - token = secret if secret is not None else write_token() - if token: - redacted = redacted.replace(token, "[redacted]") + if secret: + redacted = redacted.replace(secret, "[redacted]") return redacted @@ -221,6 +201,7 @@ def __init__(self, create_sandbox: CreateSandbox | None = None): self._boxes: dict[str, Any] = {} self._tool_facts: dict[str, dict[str, bool]] = {} self._tools_attempted: set[tuple[str, str]] = set() + self._job_state: dict[str, dict[str, Any]] = {} @property def id(self) -> str: @@ -236,7 +217,6 @@ def _ensure(self): box = self._create_sandbox( snapshot=snapshot, env={ - "GITHUB_TOKEN": write_token() or "", "PATH": ( "/root/.local/bin:/home/daytona/.local/bin:" "/usr/local/bin:/usr/bin:/bin" @@ -266,7 +246,6 @@ def _probe(self, box) -> dict[str, bool]: raise RuntimeError(redact_secrets(output) or "sandbox probe failed") return { "git": _tool_present(output, "GIT"), - "gh": _tool_present(output, "GH"), "node": _tool_present(output, "NODE"), "pnpm": _tool_present(output, "PNPM"), } @@ -287,12 +266,12 @@ def _maybe_install_tools(self, box, command: str) -> None: return key = current_run_id() facts = self._tool_facts.setdefault( - key, {"git": False, "gh": False, "node": False, "pnpm": False} + key, {"git": False, "node": False, "pnpm": False} ) needed = {match.group(1) for match in _TOOL_CMD_RE.finditer(command)} if needed & _NODE_TOOLS: needed.add("pnpm") - for name in ("git", "gh", "pnpm"): + for name in ("git", "pnpm"): if name not in needed: continue if facts.get(name): @@ -309,13 +288,6 @@ def _maybe_install_tools(self, box, command: str) -> None: timeout=_GIT_INSTALL_TIMEOUT, fail_message="git install failed", ) - elif name == "gh": - self._run_box( - box, - _GH_INSTALL_CMD, - timeout=_GH_INSTALL_TIMEOUT, - fail_message="gh install failed", - ) else: self._run_box( box, @@ -347,6 +319,108 @@ def _box_or_error(self): except Exception as error: return None, redact_secrets(f"{type(error).__name__}: {error}") + def job_state(self) -> dict[str, Any]: + """Mutable host-side state scoped to the current coder run.""" + return self._job_state.setdefault(current_run_id(), {}) + + def _git(self, box): + raw = _raw_sandbox(box) or box + git = getattr(raw, "git", None) or getattr(box, "git", None) + if git is None: + raise RuntimeError("Daytona sandbox does not expose its Git API") + return git + + def _git_call(self, operation: str, secret: str, action): + box = self._ensure() + try: + return action(self._git(box)) + except Exception as error: + detail = redact_secrets(str(error), secret) + raise RuntimeError(f"Daytona Git {operation} failed: {detail}") from error + + def clone_repository( + self, + *, + repo: str, + path: str, + branch: str, + username: str, + token: str, + ) -> None: + self._git_call( + "clone", + token, + lambda git: git.clone( + url=f"https://github.com/{repo}.git", + path=path, + branch=branch, + username=username, + password=token, + ), + ) + + def pull_repository( + self, + *, + path: str, + branch: str, + remote: str, + username: str, + token: str, + ) -> None: + self._git_call( + "pull", + token, + lambda git: git.pull( + path=path, + branch=branch, + remote=remote, + username=username, + password=token, + ), + ) + + def push_repository( + self, + *, + path: str, + branch: str, + username: str, + token: str, + ) -> None: + self._git_call( + "push", + token, + lambda git: git.push( + path=path, + branch=branch, + remote="origin", + set_upstream=True, + username=username, + password=token, + ), + ) + + def add_remote(self, *, path: str, name: str, repo: str) -> None: + box = self._ensure() + try: + self._git(box).remote_add( + path=path, + name=name, + url=f"https://github.com/{repo}.git", + ) + except Exception as error: + raise RuntimeError(f"Daytona Git remote_add failed: {error}") from error + + def set_git_identity(self, *, path: str, name: str, email: str) -> None: + box = self._ensure() + try: + git = self._git(box) + git.set_config("user.name", name, scope="local", path=path) + git.set_config("user.email", email, scope="local", path=path) + except Exception as error: + raise RuntimeError(f"Daytona Git identity setup failed: {error}") from error + def execute(self, command: str, *, timeout: int | None = None, **kwargs): if timeout is not None: kwargs["timeout"] = timeout @@ -425,6 +499,7 @@ def stop_current(self) -> None: key = current_run_id() box = self._boxes.pop(key, None) self._tool_facts.pop(key, None) + self._job_state.pop(key, None) self._tools_attempted = { item for item in self._tools_attempted if item[0] != key } diff --git a/agent/coding/skills/fix-ci/SKILL.md b/agent/coding/skills/fix-ci/SKILL.md index 8968339..859a925 100644 --- a/agent/coding/skills/fix-ci/SKILL.md +++ b/agent/coding/skills/fix-ci/SKILL.md @@ -9,9 +9,9 @@ description: > Reproduce a red GitHub check in the sandbox and make it green. -1. Clone the repo. Check out the PR head. -2. If the brief names a failing check, reproduce that command. Use `gh run view` only if you need the log. -3. Create a branch `opentag/fix-ci-` if you are not already on a safe working branch. +1. Call `prepare_repository`, including `pr_number` for an existing PR. +2. If the brief names a failing check, reproduce that command. Use the read-only GitHub Actions tools if you need its log. Never use `gh`. +3. Work on the returned safe head branch. 4. Edit until the reproduced command exits 0. -5. If green, commit, push, call `open_pull_request`. -6. If red, do not call `open_pull_request`. Return the log tail. +5. If green, commit locally and call `publish_changes`. +6. If red, do not call `publish_changes`. Return the log tail. diff --git a/agent/coding/skills/fix-tests/SKILL.md b/agent/coding/skills/fix-tests/SKILL.md index 49f8bb3..930f415 100644 --- a/agent/coding/skills/fix-tests/SKILL.md +++ b/agent/coding/skills/fix-tests/SKILL.md @@ -9,10 +9,10 @@ description: > Fix failing tests in the repo from the brief. -1. Clone the repo. Check out the PR head if `kind` is `pr`, else the default branch. -2. Create a branch `opentag/fix-tests-`. +1. Call `prepare_repository`, including `pr_number` when `kind` is `pr`. +2. Work on the returned branch; otherwise request `opentag/fix-tests-`. 3. Find the test command: the brief, then `package.json` `test`/`ci`, then `pytest` or `go test ./...` if those files exist. 4. Run the command. Read the failure. Edit the smallest set of files that fixes it. 5. Re-run until exit 0 or you cannot fix it. -6. If green, commit, push, call `open_pull_request`. -7. If red, do not call `open_pull_request`. Return the log tail. +6. If green, commit locally and call `publish_changes`. +7. If red, do not call `publish_changes`. Return the log tail. diff --git a/agent/coding/skills/implement-issue/SKILL.md b/agent/coding/skills/implement-issue/SKILL.md index 7fdc89e..224b40a 100644 --- a/agent/coding/skills/implement-issue/SKILL.md +++ b/agent/coding/skills/implement-issue/SKILL.md @@ -14,10 +14,10 @@ The brief must include `repo`, `issue`, `files`, `change`, and one `test` command. If any of those are missing, stop. Return `status: failed` and say the brief is incomplete. Do not guess. -1. Clone the repo on the default branch. -2. Create a branch `opentag/`. +1. Call `prepare_repository` with the repo and branch `opentag/`. +2. Work from the returned directory and branch. 3. Edit only the listed files. 4. Run the one test command from the brief. 5. Do not guess `pnpm test`. Do not run the full monorepo suite. -6. If green, commit, push, call `open_pull_request`. -7. If red, do not call `open_pull_request` unless the brief says `open_anyway: true`. +6. If green, commit locally and call `publish_changes`. +7. If red, do not call `publish_changes` unless the brief says `open_anyway: true`. diff --git a/agent/coding/skills/merge-main/SKILL.md b/agent/coding/skills/merge-main/SKILL.md index 0a184cb..bca2292 100644 --- a/agent/coding/skills/merge-main/SKILL.md +++ b/agent/coding/skills/merge-main/SKILL.md @@ -9,9 +9,9 @@ description: > Merge `origin/main` into the working branch from the brief. -1. Clone the repo. Check out the PR head or named branch. -2. Fetch `origin/main`. Merge it. +1. Call `prepare_repository` with the PR or named head and `sync_base: true`. +2. The host synchronizes the base through Daytona. Never fetch or pull with Git yourself. 3. Resolve conflicts with the smallest correct edits. Do not drop the PR's intent. 4. Run the test command from the brief, or the repo default test command. -5. If green, push, call `open_pull_request` if no PR exists, or report that the branch is updated. -6. If red, do not call `open_pull_request`. Return the log tail. +5. If green, commit locally if needed and call `publish_changes`; it reuses an existing PR. +6. If red, do not call `publish_changes`. Return the log tail. diff --git a/agent/coding/subagent.py b/agent/coding/subagent.py index 5f3b135..6e327ea 100644 --- a/agent/coding/subagent.py +++ b/agent/coding/subagent.py @@ -6,15 +6,23 @@ from deepagents.backends import CompositeBackend, FilesystemBackend from coding.config import CODER_RECURSION_LIMIT -from coding.open_pull_request import build_open_pull_request +from coding.github_credentials import GitHubCredentialProvider from coding.prompt import CODER_PROMPT +from coding.repository_tools import build_repository_tools from coding.sandbox import PerJobDaytonaBackend, StopSandboxAfterJob SKILLS_DIR = Path(__file__).resolve().parent / "skills" SKILLS_PREFIX = "/skills/" -def build_coder_subagent(*, model, checkpointer, backend=None): +def build_coder_subagent( + *, + model, + checkpointer, + provider: GitHubCredentialProvider, + github_tools=(), + backend=None, +): """Build the coder CompiledSubAgent. Does not create a Daytona box.""" sandbox = backend or PerJobDaytonaBackend() routed = CompositeBackend( @@ -29,7 +37,7 @@ def build_coder_subagent(*, model, checkpointer, backend=None): graph = create_deep_agent( model=model, system_prompt=CODER_PROMPT, - tools=[build_open_pull_request(sandbox)], + tools=[*github_tools, *build_repository_tools(sandbox, provider)], skills=[SKILLS_PREFIX], backend=routed, middleware=[StopSandboxAfterJob(sandbox)], @@ -38,8 +46,8 @@ def build_coder_subagent(*, model, checkpointer, backend=None): return CompiledSubAgent( name="coder", description=( - "Clone a GitHub repo in Daytona, run fix-tests / merge-main / " - "fix-ci / implement-issue, then open a draft PR." + "Prepare a GitHub repo in Daytona, run fix-tests / merge-main / " + "fix-ci / implement-issue, then publish a draft PR." ), runnable=graph, ) diff --git a/agent/internal_sources.py b/agent/internal_sources.py index c71df29..5c30551 100644 --- a/agent/internal_sources.py +++ b/agent/internal_sources.py @@ -4,23 +4,24 @@ import logging import os from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from typing import Any from langchain_mcp_adapters.client import MultiServerMCPClient +from coding.config import github_providers +from coding.github_credentials import GitHubCredentialProvider, GitHubProviderAuth from write_confirmation import WriteConfirmationInterceptor MCP_SERVERS = { "github": { - "token_env": "GITHUB_PERSONAL_ACCESS_TOKEN", - "fallback_token_env": "GITHUB_CODER_TOKEN", "url_env": "GITHUB_MCP_URL", "default_url": "https://api.githubcopilot.com/mcp/readonly", "headers": { "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests", + "X-MCP-Toolsets": "repos,pull_requests,actions", }, }, "posthog": { @@ -40,6 +41,33 @@ }, } MCP_LOAD_TIMEOUT_SECONDS = 8.0 +GITHUB_READ_TOOL_ALLOWLIST = frozenset( + { + "actions_get", + "actions_list", + "get_commit", + "get_file_blame", + "get_file_contents", + "get_job_logs", + "get_pull_request", + "get_pull_request_comments", + "get_pull_request_files", + "get_pull_request_reviews", + "get_pull_request_status", + "get_workflow_run", + "get_workflow_run_logs", + "list_branches", + "list_commits", + "list_pull_requests", + "list_workflow_jobs", + "list_workflow_runs", + "list_workflows", + "pull_request_read", + "search_code", + "search_pull_requests", + "search_repositories", + } +) logger = logging.getLogger(__name__) @@ -68,21 +96,21 @@ def _emit_internal_source_error( def _configured_connections( env: Mapping[str, str], + github_provider: GitHubCredentialProvider | None = None, ) -> dict[str, dict[str, Any]]: + github_provider = github_provider or github_providers(env).search connections: dict[str, dict[str, Any]] = {} for name, config in MCP_SERVERS.items(): - token = env.get(config["token_env"]) - fallback_token_env = config.get("fallback_token_env") - if not token and fallback_token_env: - token = env.get(fallback_token_env) + is_github = name == "github" + token = None if is_github else env.get(config.get("token_env", "")) configured_url = env.get(config["url_env"]) url = configured_url or config["default_url"] - if not token: + if (is_github and github_provider is None) or (not is_github and not token): if configured_url: logger.warning( "[TOOLS] skipping %s: %s must be set with %s", name, - config["token_env"], + config.get("token_env", "GitHub credentials"), config["url_env"], ) continue @@ -91,24 +119,35 @@ def _configured_connections( "[TOOLS] skipping %s: %s must be set with %s", name, config["url_env"], - config["token_env"], + config.get("token_env", "GitHub credentials"), ) continue - headers = { - "Authorization": f"Bearer {token}", - **config.get("headers", {}), - } + headers = dict(config.get("headers", {})) + if token: + headers["Authorization"] = f"Bearer {token}" connections[name] = { "transport": "streamable_http", "url": url, "headers": headers, } + if is_github: + connections[name]["auth"] = GitHubProviderAuth(github_provider) return connections -async def _load_tools(connections: dict[str, dict[str, Any]]) -> list: - loaded = [] +def _read_only_hint(tool) -> bool: + metadata = getattr(tool, "metadata", None) or {} + if metadata.get("readOnlyHint") is True: + return True + annotations = metadata.get("annotations") or {} + return annotations.get("readOnlyHint") is True + + +async def _load_tools( + connections: dict[str, dict[str, Any]], +) -> dict[str, list]: + loaded: dict[str, list] = {} for name, connection in connections.items(): try: confirmation = WriteConfirmationInterceptor() @@ -120,8 +159,15 @@ async def _load_tools(connections: dict[str, dict[str, Any]]) -> list: client.get_tools(), timeout=MCP_LOAD_TIMEOUT_SECONDS, ) + if name == "github": + tools = [ + item + for item in tools + if item.name in GITHUB_READ_TOOL_ALLOWLIST + and _read_only_hint(item) + ] confirmation.register_tools(tools) - loaded.extend(tools) + loaded[name] = tools print(f"[TOOLS] loaded {len(tools)} tool(s) from {name}") except asyncio.TimeoutError as error: _emit_internal_source_error( @@ -140,15 +186,35 @@ async def _load_tools(connections: dict[str, dict[str, Any]]) -> list: return loaded -def internal_source_tools() -> list: - """Load optional MCP tools for the configured internal sources.""" - connections = _configured_connections(os.environ) +def internal_source_toolsets( + github_provider: GitHubCredentialProvider | None = None, +) -> dict[str, list]: + """Load optional MCP tools grouped by source.""" + try: + connections = _configured_connections(os.environ, github_provider) + except Exception as error: + _emit_internal_source_error( + error, + message="Failed to resolve internal source credentials", + source="github", + recovery="skip_optional_integration", + ) + return {} if not connections: - return [] + return {} - # MCP discovery is async; agent construction is synchronous. + # MCP discovery is async; agent construction is synchronous. If an event + # loop already owns this thread, do the blocking startup work in one helper + # thread rather than handing asyncio.run() an unawaited coroutine. try: - return asyncio.run(_load_tools(connections)) + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_load_tools(connections)) + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit( + lambda: asyncio.run(_load_tools(connections)) + ).result() except Exception as error: _emit_internal_source_error( error, @@ -156,4 +222,12 @@ def internal_source_tools() -> list: source="all", recovery="skip_optional_integrations", ) - return [] + return {} + + +def internal_source_tools( + github_provider: GitHubCredentialProvider | None = None, +) -> list: + """Load optional MCP tools as the main agent's flat tool list.""" + toolsets = internal_source_toolsets(github_provider) + return [tool for tools in toolsets.values() for tool in tools] diff --git a/agent/prompts/tools.py b/agent/prompts/tools.py index 55d24d2..19f2f9f 100644 --- a/agent/prompts/tools.py +++ b/agent/prompts/tools.py @@ -2,8 +2,8 @@ TOOLS_PROMPT = """- For internal or company-specific questions, prefer the team's Notion/Linear and GitHub sources first; use the web for external questions -- Use GitHub tools to search repositories, code, issues, and pull requests. The - GitHub integration is read-only +- Use GitHub tools to read repositories, code, pull requests, Actions runs, and + job logs. The GitHub integration is read-only - CRITICAL: Every Linear or Notion mutation tool automatically pauses with its exact action and draft details. Call the mutation once; it runs only after the user grants approval, and otherwise no write occurs @@ -18,7 +18,8 @@ implement-issue, also provide files, the exact change, and one test command; do not send the coder to rediscover the issue. Repair and merge jobs may inspect the checkout and CI logs to identify the smallest fix -- GitHub MCP stays read-only. The coder opens the draft PR +- GitHub MCP stays read-only. After one approval, the coder pushes its local + commit and creates or updates the draft PR through host-side tools - Never invent a pull request URL. Only report a URL the coder returned Example brief: diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 813f969..85d4362 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -8,10 +8,12 @@ dependencies = [ "copilotkit>=0.1.76", "deepagents>=0.6.12", "fastapi>=0.115.14", + "httpx>=0.27.0", "langchain>=1.2.4", "langchain-mcp-adapters>=0.3.0", "langchain-openai>=1.1.7", "python-dotenv>=1.2.1", + "pyjwt[crypto]>=2.10.1", "tavily-python>=0.3.0", "uvicorn[standard]>=0.40.0", "daytona", @@ -19,7 +21,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest>=8.0.0", "httpx>=0.27.0"] +dev = ["pytest>=8.0.0"] [tool.setuptools] packages = ["prompts", "coding"] diff --git a/agent/scripts/probe_daytona.py b/agent/scripts/probe_daytona.py index d6169dd..bd53842 100644 --- a/agent/scripts/probe_daytona.py +++ b/agent/scripts/probe_daytona.py @@ -26,9 +26,8 @@ from daytona import CreateSandboxFromSnapshotParams, Daytona # noqa: E402 from langchain_daytona import DaytonaSandbox # noqa: E402 -from coding.config import snapshot_id, ttl_minutes, write_token # noqa: E402 +from coding.config import snapshot_id, ttl_minutes # noqa: E402 from coding.sandbox import ( # noqa: E402 - _GH_INSTALL_CMD, _GIT_INSTALL_CMD, _PROBE_CMD, PerJobDaytonaBackend, @@ -82,7 +81,6 @@ def main() -> int: else: print(f"DAYTONA_SNAPSHOT: set ({len(snapshot_id() or '')} chars)") print(f"DAYTONA_TTL_MINUTES: {ttl_minutes()}") - print(f"GITHUB write token: {'set' if write_token() else 'missing'}") client = Daytona() try: @@ -98,7 +96,6 @@ def main() -> int: params = CreateSandboxFromSnapshotParams( snapshot=snapshot_id(), env_vars={ - "GITHUB_TOKEN": write_token() or "", "PATH": ( "/root/.local/bin:/home/daytona/.local/bin:" "/usr/local/bin:/usr/bin:/bin" @@ -139,7 +136,7 @@ def main() -> int: seconds, code, output = _exec_sdk( sandbox, - "command -v git; command -v gh; command -v curl; uname -m; id; echo PATH=$PATH", + "command -v git; command -v curl; uname -m; id; echo PATH=$PATH", 20, ) probe.step("inspect tools", seconds, code == 0, f"exit={code} {output}") @@ -153,8 +150,7 @@ def main() -> int: ) has_git = _tool_present(output, "GIT") - has_gh = _tool_present(output, "GH") - print(f" parsed probe: git={has_git} gh={has_gh}") + print(f" parsed probe: git={has_git}") if not has_git: seconds, code, output = _exec_sdk(sandbox, _GIT_INSTALL_CMD, 180) @@ -162,18 +158,9 @@ def main() -> int: else: print("[SKIP] install git (already present)") - if not has_gh: - seconds, code, output = _exec_sdk(sandbox, _GH_INSTALL_CMD, 180) - probe.step("install gh (sdk)", seconds, code == 0, f"exit={code} {output[-200:]}") - else: - print("[SKIP] install gh (already present)") - seconds, code, output = _exec_sdk(sandbox, "git --version", 20) probe.step("git --version", seconds, code == 0, f"exit={code} {output}") - seconds, code, output = _exec_sdk(sandbox, "gh --version", 20) - probe.step("gh --version", seconds, code == 0, f"exit={code} {output}") - seconds, code, output = _exec_session(wrapper, "echo after-tools", 20) probe.step( "session echo after tools", @@ -232,14 +219,6 @@ def main() -> int: f"exit={result.exit_code} {result.output}", ) - started = time.monotonic() - result = backend.execute("gh --version", timeout=180) - probe.step( - "backend gh --version", - time.monotonic() - started, - result.exit_code == 0 and "gh version" in (result.output or ""), - f"exit={result.exit_code} {result.output}", - ) except Exception as error: probe.failed += 1 print(f"[FAIL] backend {type(error).__name__}: {_out(str(error))}") diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py index ac6d716..e83747a 100644 --- a/agent/tests/test_agent_configuration.py +++ b/agent/tests/test_agent_configuration.py @@ -45,7 +45,10 @@ def build_with_captured_configuration(monkeypatch): monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) - monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + monkeypatch.delenv("GITHUB_APP_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) + monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) def fake_chat_openai(**kwargs): captured["model"] = kwargs @@ -167,7 +170,7 @@ def _generate( monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: model) - monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) graph = agent_mod.build_agent() graph.invoke( @@ -193,6 +196,9 @@ def _configure_minimal_environment(monkeypatch): monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_APP_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) def _response_payload(output, response_id): diff --git a/agent/tests/test_agui_recursion.py b/agent/tests/test_agui_recursion.py index fc383b6..c298b45 100644 --- a/agent/tests/test_agui_recursion.py +++ b/agent/tests/test_agui_recursion.py @@ -9,9 +9,9 @@ def test_agui_agent_receives_the_main_graph_recursion_limit(): - graph = SimpleNamespace(nodes={}) + graph = SimpleNamespace(nodes={}, config={"recursion_limit": 80}) - agent = build_agui_agent(graph, recursion_limit=80) + agent = build_agui_agent(graph) assert agent.config["recursion_limit"] == 80 diff --git a/agent/tests/test_coder_approval_resume.py b/agent/tests/test_coder_approval_resume.py new file mode 100644 index 0000000..240553c --- /dev/null +++ b/agent/tests/test_coder_approval_resume.py @@ -0,0 +1,234 @@ +import asyncio +from types import SimpleNamespace +from typing import Any + +from ag_ui.core import RunAgentInput +from copilotkit import CopilotKitMiddleware +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langgraph.checkpoint.memory import MemorySaver +from pydantic import Field + +from agui import build_agui_agent +from coding.github_credentials import GitHubIdentity +from coding.sandbox import current_run_id +from coding.subagent import build_coder_subagent + + +class ApprovalResumeModel(BaseChatModel): + tool_names: frozenset[str] = Field(default_factory=frozenset) + + @property + def _llm_type(self): + return "coder-approval-resume" + + def bind_tools(self, tools, **_kwargs): + return self.model_copy( + update={"tool_names": frozenset(tool.name for tool in tools)} + ) + + def _generate( + self, + messages: list[BaseMessage], + stop=None, + run_manager=None, + **_kwargs: Any, + ): + del stop, run_manager + results = { + message.tool_call_id + for message in messages + if isinstance(message, ToolMessage) + } + if "prepare_repository" not in self.tool_names: + message = ( + AIMessage(content="done") + if "task-1" in results + else AIMessage( + content="", + tool_calls=[ + { + "id": "task-1", + "name": "task", + "args": { + "description": "Make the requested change", + "subagent_type": "coder", + }, + } + ], + ) + ) + elif "prepare-1" not in results: + message = AIMessage( + content="", + tool_calls=[ + { + "id": "prepare-1", + "name": "prepare_repository", + "args": { + "repo": "org/repo", + "base_branch": "main", + "head_branch": "opentag/test", + }, + } + ], + ) + elif "publish-1" not in results: + message = AIMessage( + content="", + tool_calls=[ + { + "id": "publish-1", + "name": "publish_changes", + "args": { + "repo": "org/repo", + "base_branch": "main", + "head_branch": "opentag/test", + "title": "Test approval resume", + "body": "Test body", + "test_command": "true", + "test_exit_code": 0, + }, + } + ], + ) + else: + message = AIMessage(content="coder done") + return ChatResult(generations=[ChatGeneration(message=message)]) + + +class ApprovalResumeProvider: + git_username = "x-access-token" + + def __init__(self): + self.requests = [] + + def token(self): + return "operation-secret" + + def identity(self): + return GitHubIdentity( + "open-tag[bot]", + 42, + "42+open-tag[bot]@users.noreply.github.com", + ) + + def request_json(self, method, path, *, json=None): + self.requests.append((method, path, json)) + if method == "POST": + return { + "html_url": "https://github.com/org/repo/pull/9", + "number": 9, + } + raise AssertionError((method, path, json)) + + +class ApprovalResumeBackend: + def __init__(self): + self.states = {} + self.job_keys = [] + self.branch = "" + self.clones = 0 + self.pushes = 0 + + @property + def id(self): + return "approval-resume" + + def job_state(self): + key = current_run_id() + self.job_keys.append(key) + return self.states.setdefault(key, {}) + + def clone_repository(self, **kwargs): + self.clones += 1 + self.branch = kwargs["branch"] + + def set_git_identity(self, **_kwargs): + pass + + def push_repository(self, **_kwargs): + self.pushes += 1 + + def execute(self, command, **_kwargs): + if "switch -c" in command: + self.branch = "opentag/test" + return SimpleNamespace(output="", exit_code=0) + if "branch --show-current" in command: + return SimpleNamespace(output=self.branch, exit_code=0) + if "status --porcelain" in command: + return SimpleNamespace(output="", exit_code=0) + if "rev-parse HEAD" in command: + return SimpleNamespace(output="abc123", exit_code=0) + raise AssertionError(command) + + def stop_current(self): + pass + + +def test_coder_confirmation_survives_subagent_tool_replay(): + model = ApprovalResumeModel() + checkpointer = MemorySaver() + backend = ApprovalResumeBackend() + provider = ApprovalResumeProvider() + coder = build_coder_subagent( + model=model, + checkpointer=checkpointer, + provider=provider, + backend=backend, + ) + graph = create_deep_agent( + model=model, + middleware=[CopilotKitMiddleware()], + subagents=[coder], + checkpointer=checkpointer, + ) + agent = build_agui_agent(graph, recursion_limit=80) + request = { + "threadId": "approval-resume-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "go"}], + "tools": [], + "context": [], + } + + first = asyncio.run( + _collect( + agent.run( + RunAgentInput(runId="run-1", forwardedProps={}, **request) + ) + ) + ) + assert any(getattr(event, "name", None) == "on_interrupt" for event in first) + + # A nested interrupt can replay the parent task if its subgraph checkpoint + # is unavailable. The prepared sandbox state still survives that replay. + namespaces = checkpointer.storage["approval-resume-thread"] + for namespace in list(namespaces): + if namespace: + del namespaces[namespace] + + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={ + "command": {"resume": {"confirmed": True}} + }, + **request, + ) + ) + ) + ) + + assert backend.clones == 1 + assert backend.pushes == 1 + assert len(set(backend.job_keys)) == 1 + assert provider.requests[-1][:2] == ("POST", "/repos/org/repo/pulls") + + +async def _collect(stream): + return [event async for event in stream] diff --git a/agent/tests/test_coder_prompt.py b/agent/tests/test_coder_prompt.py index ba3a656..7aad0ec 100644 --- a/agent/tests/test_coder_prompt.py +++ b/agent/tests/test_coder_prompt.py @@ -7,8 +7,10 @@ def test_coder_prompt_forbids_linear_and_requires_green_check(): assert "You are the OpenTag coder" in CODER_PROMPT - assert "Do not call Linear" in CODER_PROMPT - assert "open_pull_request" in CODER_PROMPT + assert "prepare_repository" in CODER_PROMPT + assert "publish_changes" in CODER_PROMPT + assert "Never clone, pull, fetch, or push with git" in CODER_PROMPT + assert "never invoke gh" in CODER_PROMPT assert "exit 0" in CODER_PROMPT assert "open anyway" in CODER_PROMPT assert "corepack" in CODER_PROMPT @@ -40,7 +42,8 @@ def test_four_skill_files_exist(): assert path.is_file(), path text = path.read_text(encoding="utf-8") assert name.replace("-", " ") in text.lower() or name in text - assert "open_pull_request" in text + assert "prepare_repository" in text + assert "publish_changes" in text def test_skill_files_start_with_yaml_frontmatter(): diff --git a/agent/tests/test_coder_wiring.py b/agent/tests/test_coder_wiring.py index ec74b5d..b82a97a 100644 --- a/agent/tests/test_coder_wiring.py +++ b/agent/tests/test_coder_wiring.py @@ -28,7 +28,7 @@ def test_build_agent_omits_coder_when_coding_is_off(monkeypatch): monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **kwargs: object()) def fake_create_deep_agent(**kwargs): @@ -65,7 +65,7 @@ def test_build_agent_registers_coder_when_coding_is_on(monkeypatch): monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False) - monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **kwargs: object()) monkeypatch.setattr( agent_mod, @@ -104,6 +104,7 @@ def fake_create_deep_agent(**kwargs): build_coder_subagent( model=object(), checkpointer=object(), + provider=object(), backend=sandbox, ) return captured @@ -128,6 +129,10 @@ def test_build_coder_subagent_routes_skills_to_host_filesystem(monkeypatch): assert backend.default is sandbox assert "/skills/" in backend.routes assert captured["agent"]["middleware"][0].backend is sandbox + assert {tool.name for tool in captured["agent"]["tools"]} == { + "prepare_repository", + "publish_changes", + } listed = backend.ls("/skills/") assert listed.error is None diff --git a/agent/tests/test_coding_config.py b/agent/tests/test_coding_config.py index c33ba0d..f929769 100644 --- a/agent/tests/test_coding_config.py +++ b/agent/tests/test_coding_config.py @@ -1,56 +1,161 @@ +import base64 +import logging + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + from coding import config +from coding.github_credentials import GitHubAppProvider, GitHubPatProvider + + +TEST_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +TEST_PRIVATE_KEY_BASE64 = base64.b64encode( + TEST_PRIVATE_KEY.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) +).decode() + +APP = { + "GITHUB_APP_ID": "123", + "GITHUB_APP_INSTALLATION_ID": "456", + "GITHUB_APP_PRIVATE_KEY_BASE64": TEST_PRIVATE_KEY_BASE64, +} + + +@pytest.mark.parametrize( + ("env", "provider_type", "enabled"), + [ + ({}, None, False), + ({"GITHUB_PERSONAL_ACCESS_TOKEN": "legacy"}, GitHubPatProvider, True), + ({"GITHUB_CODER_TOKEN": "coder"}, GitHubPatProvider, True), + (APP, GitHubAppProvider, True), + ], +) +def test_coding_credential_matrix(env, provider_type, enabled): + complete = {"DAYTONA_API_KEY": "daytona", **env} + selected = config.github_providers(complete) + + assert config.coding_enabled(complete) is enabled + if provider_type is None: + assert selected.coding is None + else: + assert isinstance(selected.coding, provider_type) + + +def test_daytona_is_required_for_coding(): + assert config.coding_enabled({"GITHUB_CODER_TOKEN": "coder"}) is False + + +def test_explicit_pat_and_app_is_a_configuration_error(): + selected = config.github_providers({"GITHUB_CODER_TOKEN": "coder", **APP}) + + assert selected.coding is None + assert "choose exactly one" in (selected.error or "") + + +def test_explicit_credential_conflict_is_logged_at_startup(caplog): + selected = config.github_providers({"GITHUB_CODER_TOKEN": "coder", **APP}) + + with caplog.at_level(logging.ERROR, logger=config.__name__): + config.log_configuration_warnings(selected, {}) + + assert "configuration error" in caplog.records[0].getMessage() + + +def test_incomplete_app_disables_coding_without_legacy_fallback(): + env = { + "DAYTONA_API_KEY": "daytona", + "GITHUB_PERSONAL_ACCESS_TOKEN": "legacy", + "GITHUB_APP_ID": "123", + } + selected = config.github_providers(env) + + assert selected.coding is None + assert selected.search is not None + assert "incomplete" in (selected.warning or "") + assert config.coding_enabled(env) is False + + +def test_invalid_app_private_key_is_a_configuration_error(): + selected = config.github_providers( + { + "GITHUB_APP_ID": "123", + "GITHUB_APP_INSTALLATION_ID": "456", + "GITHUB_APP_PRIVATE_KEY_BASE64": "not base64", + } + ) + + assert selected.coding is None + assert "not valid base64" in (selected.error or "") + + +def test_search_pat_coexists_with_app_coding(): + selected = config.github_providers( + {"GITHUB_PERSONAL_ACCESS_TOKEN": "search", **APP} + ) + + assert isinstance(selected.search, GitHubPatProvider) + assert isinstance(selected.coding, GitHubAppProvider) + +def test_search_pat_and_dedicated_coder_pat_select_separate_providers(): + selected = config.github_providers( + { + "GITHUB_PERSONAL_ACCESS_TOKEN": "search", + "GITHUB_CODER_TOKEN": "coder", + } + ) -def test_coding_disabled_without_daytona_key(monkeypatch): - monkeypatch.delenv("DAYTONA_API_KEY", raising=False) - monkeypatch.setenv("GITHUB_PERSONAL_ACCESS_TOKEN", "github_pat_test") - assert config.coding_enabled() is False + assert selected.search.token() == "search" + assert selected.coding.token() == "coder" -def test_coding_disabled_without_github_token(monkeypatch): - monkeypatch.setenv("DAYTONA_API_KEY", "dtn_test") - monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) - monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) - assert config.coding_enabled() is False +def test_incomplete_app_disables_explicit_pat_coding_too(): + selected = config.github_providers( + { + "GITHUB_CODER_TOKEN": "coder", + "GITHUB_APP_ID": "123", + } + ) + assert selected.coding is None + assert "incomplete" in (selected.warning or "") -def test_coding_enabled_with_daytona_and_pat(monkeypatch): - monkeypatch.setenv("DAYTONA_API_KEY", "dtn_test") - monkeypatch.setenv("GITHUB_PERSONAL_ACCESS_TOKEN", "github_pat_test") - monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) - assert config.coding_enabled() is True - assert config.write_token() == "github_pat_test" +def test_decoded_app_key_must_be_a_valid_pem(): + selected = config.github_providers( + { + "GITHUB_APP_ID": "123", + "GITHUB_APP_INSTALLATION_ID": "456", + "GITHUB_APP_PRIVATE_KEY_BASE64": "cHJpdmF0ZQ==", + } + ) -def test_coder_token_overrides_pat(monkeypatch): - monkeypatch.setenv("GITHUB_PERSONAL_ACCESS_TOKEN", "github_pat_read") - monkeypatch.setenv("GITHUB_CODER_TOKEN", "github_pat_write") - assert config.write_token() == "github_pat_write" + assert selected.coding is None + assert "valid unencrypted PEM" in (selected.error or "") -def test_allowlist_unset_allows_any_repo(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - assert config.repo_is_allowed("any/repo") is True +def test_startup_warnings_cover_incomplete_app_and_ignored_allowlist(caplog): + env = {"GITHUB_APP_ID": "123", "GITHUB_ALLOWED_REPOS": "org/*"} + selected = config.github_providers(env) + with caplog.at_level(logging.WARNING, logger=config.__name__): + config.log_configuration_warnings(selected, env) -def test_allowlist_matches_exact_and_org_glob(monkeypatch): - monkeypatch.setenv("GITHUB_ALLOWED_REPOS", "CopilotKit/OpenTag, acme/*") - assert config.repo_is_allowed("CopilotKit/OpenTag") is True - assert config.repo_is_allowed("acme/widgets") is True - assert config.repo_is_allowed("other/repo") is False + text = "\n".join(record.getMessage() for record in caplog.records) + assert "incomplete GitHub App credentials" in text + assert "GITHUB_ALLOWED_REPOS is ignored" in text -def test_ttl_defaults_to_sixty_and_rejects_junk(monkeypatch): - monkeypatch.delenv("DAYTONA_TTL_MINUTES", raising=False) - assert config.ttl_minutes() == 60 - monkeypatch.setenv("DAYTONA_TTL_MINUTES", "nope") - assert config.ttl_minutes() == 60 - monkeypatch.setenv("DAYTONA_TTL_MINUTES", "15") - assert config.ttl_minutes() == 15 +def test_ttl_defaults_to_sixty_and_rejects_junk(): + assert config.ttl_minutes({}) == 60 + assert config.ttl_minutes({"DAYTONA_TTL_MINUTES": "nope"}) == 60 + assert config.ttl_minutes({"DAYTONA_TTL_MINUTES": "15"}) == 15 -def test_snapshot_is_none_when_unset(monkeypatch): - monkeypatch.delenv("DAYTONA_SNAPSHOT", raising=False) - assert config.snapshot_id() is None - monkeypatch.setenv("DAYTONA_SNAPSHOT", "snap-1") - assert config.snapshot_id() == "snap-1" +def test_snapshot_is_none_when_unset(): + assert config.snapshot_id({}) is None + assert config.snapshot_id({"DAYTONA_SNAPSHOT": "snap-1"}) == "snap-1" diff --git a/agent/tests/test_github_credentials.py b/agent/tests/test_github_credentials.py new file mode 100644 index 0000000..e994f6c --- /dev/null +++ b/agent/tests/test_github_credentials.py @@ -0,0 +1,156 @@ +import base64 +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from coding.github_credentials import ( + GitHubAppProvider, + GitHubCredentialError, + GitHubPatProvider, +) + + +TEST_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +TEST_PRIVATE_KEY_BASE64 = base64.b64encode( + TEST_PRIVATE_KEY.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) +).decode() + + +def _client(handler): + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def test_pat_resolves_and_caches_numeric_noreply_identity(): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json={"login": "octocat", "id": 42}) + + provider = GitHubPatProvider("secret", client=_client(handler)) + + assert provider.identity().email == "42+octocat@users.noreply.github.com" + assert provider.identity().login == "octocat" + assert len(requests) == 1 + + +def test_app_token_cache_refresh_and_bot_identity(monkeypatch): + now = [datetime(2026, 1, 1, tzinfo=timezone.utc)] + minted = [] + signed = [] + + def encode(claims, key, *, algorithm): + signed.append((claims, key, algorithm)) + return "app-jwt" + + monkeypatch.setattr("coding.github_credentials.jwt.encode", encode) + + def handler(request): + if request.url.path.endswith("/access_tokens"): + token = f"installation-{len(minted) + 1}" + minted.append(token) + return httpx.Response( + 201, + json={ + "token": token, + "expires_at": (now[0] + timedelta(hours=1)).isoformat(), + }, + ) + if request.url.path == "/app": + return httpx.Response(200, json={"slug": "open-tag"}) + if request.url.path == "/users/open-tag[bot]": + return httpx.Response(200, json={"login": "open-tag[bot]", "id": 99}) + raise AssertionError(request.url) + + provider = GitHubAppProvider( + app_id="1", + installation_id="2", + private_key_base64=TEST_PRIVATE_KEY_BASE64, + client=_client(handler), + now=lambda: now[0], + ) + + assert provider.token() == "installation-1" + assert provider.token() == "installation-1" + now[0] += timedelta(minutes=56) + assert provider.token() == "installation-2" + assert provider.identity().email == "99+open-tag[bot]@users.noreply.github.com" + claims, key, algorithm = signed[0] + assert claims["iss"] == "1" + assert claims["exp"] - claims["iat"] == 600 + assert isinstance(key, rsa.RSAPrivateKey) + assert algorithm == "RS256" + + +def test_app_refresh_is_serialized(monkeypatch): + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + calls = 0 + monkeypatch.setattr("coding.github_credentials.jwt.encode", lambda *_a, **_k: "app-jwt") + + def handler(request): + nonlocal calls + calls += 1 + return httpx.Response( + 201, + json={ + "token": "one-token", + "expires_at": (now + timedelta(hours=1)).isoformat(), + }, + ) + + provider = GitHubAppProvider( + app_id="1", + installation_id="2", + private_key_base64=TEST_PRIVATE_KEY_BASE64, + client=_client(handler), + now=lambda: now, + ) + with ThreadPoolExecutor(max_workers=8) as pool: + tokens = list(pool.map(lambda _n: provider.token(), range(16))) + + assert tokens == ["one-token"] * 16 + assert calls == 1 + + +def test_failures_redact_the_exact_operation_credential(): + token = "arbitrary-secret-value" + + def handler(_request): + raise RuntimeError(f"transport leaked {token}") + + provider = GitHubPatProvider(token, client=_client(handler)) + + with pytest.raises(GitHubCredentialError) as captured: + provider.request_json("GET", "/user") + assert token not in str(captured.value) + assert "[redacted]" in str(captured.value) + + +def test_app_failures_redact_the_signed_jwt(monkeypatch): + app_jwt = "signed-app-jwt" + monkeypatch.setattr( + "coding.github_credentials.jwt.encode", lambda *_a, **_k: app_jwt + ) + + def handler(_request): + raise RuntimeError(f"transport leaked {app_jwt}") + + provider = GitHubAppProvider( + app_id="1", + installation_id="2", + private_key_base64=TEST_PRIVATE_KEY_BASE64, + client=_client(handler), + ) + + with pytest.raises(GitHubCredentialError) as captured: + provider.token() + assert app_jwt not in str(captured.value) + assert "[redacted]" in str(captured.value) diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 1ef5e81..ffb962c 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -102,7 +102,7 @@ def with_config(self, config): monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) - monkeypatch.setattr(agent_mod, "internal_source_tools", lambda: []) + monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) def fake_create_deep_agent(**kwargs): captured["tools"] = kwargs["tools"] diff --git a/agent/tests/test_internal_sources.py b/agent/tests/test_internal_sources.py index c43bdfe..b57811a 100644 --- a/agent/tests/test_internal_sources.py +++ b/agent/tests/test_internal_sources.py @@ -3,6 +3,7 @@ from datetime import datetime import internal_sources +import httpx import pytest from langchain_core.tools import StructuredTool from write_confirmation import WriteConfirmationInterceptor @@ -12,19 +13,20 @@ def clear_ambient_credentials(monkeypatch): monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_APP_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False) def test_mcp_servers_are_configured_in_one_place(): assert internal_sources.MCP_SERVERS == { "github": { - "token_env": "GITHUB_PERSONAL_ACCESS_TOKEN", - "fallback_token_env": "GITHUB_CODER_TOKEN", "url_env": "GITHUB_MCP_URL", "default_url": "https://api.githubcopilot.com/mcp/readonly", "headers": { "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests", + "X-MCP-Toolsets": "repos,pull_requests,actions", }, }, "posthog": { @@ -56,35 +58,31 @@ def test_internal_source_tools_empty_without_env(monkeypatch): def test_github_uses_hosted_read_only_search_mcp_with_pat(): - assert internal_sources._configured_connections( + github = internal_sources._configured_connections( {"GITHUB_PERSONAL_ACCESS_TOKEN": "github_pat_test"} - ) == { - "github": { - "transport": "streamable_http", - "url": "https://api.githubcopilot.com/mcp/readonly", - "headers": { - "Authorization": "Bearer github_pat_test", - "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests", - }, - } + )["github"] + + assert github["transport"] == "streamable_http" + assert github["url"] == "https://api.githubcopilot.com/mcp/readonly" + assert github["headers"] == { + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,pull_requests,actions", } + assert github["auth"].provider.token() == "github_pat_test" def test_github_url_can_be_overridden_without_disabling_read_only_mode(): - assert internal_sources._configured_connections( + github = internal_sources._configured_connections( { "GITHUB_PERSONAL_ACCESS_TOKEN": "github_pat_test", "GITHUB_MCP_URL": "https://github.example.test/mcp", } - )["github"] == { - "transport": "streamable_http", - "url": "https://github.example.test/mcp", - "headers": { - "Authorization": "Bearer github_pat_test", - "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests", - }, + )["github"] + + assert github["url"] == "https://github.example.test/mcp" + assert github["headers"] == { + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,pull_requests,actions", } @@ -94,10 +92,92 @@ def test_github_coder_token_can_power_read_only_mcp_when_pat_is_unset(): )["github"] assert github["headers"] == { - "Authorization": "Bearer github_pat_write", "X-MCP-Readonly": "true", - "X-MCP-Toolsets": "repos,issues,pull_requests", + "X-MCP-Toolsets": "repos,pull_requests,actions", } + assert github["auth"].provider.token() == "github_pat_write" + + +def test_github_mcp_accepts_the_selected_app_provider(): + class AppProvider: + def __init__(self): + self.current = "installation-one" + + def token(self): + return self.current + + provider = AppProvider() + github = internal_sources._configured_connections( + {}, github_provider=provider + )["github"] + + assert "Authorization" not in github["headers"] + request = next( + github["auth"].sync_auth_flow(httpx.Request("GET", "https://example.test")) + ) + assert request.headers["Authorization"] == "Bearer installation-one" + provider.current = "installation-two" + request = next( + github["auth"].sync_auth_flow(httpx.Request("GET", "https://example.test")) + ) + assert request.headers["Authorization"] == "Bearer installation-two" + + async def authorize(): + flow = github["auth"].async_auth_flow( + httpx.Request("GET", "https://example.test") + ) + return await anext(flow) + + request = asyncio.run(authorize()) + assert request.headers["Authorization"] == "Bearer installation-two" + + +def test_github_tools_require_exact_allowlist_and_read_only_hint(monkeypatch): + class FakeMCPClient: + def __init__(self, connections, *, tool_interceptors): + del connections, tool_interceptors + + async def get_tools(self): + return [ + StructuredTool.from_function( + func=lambda: "ok", + name="get_file_contents", + description="allowed read", + metadata={"readOnlyHint": True}, + ), + StructuredTool.from_function( + func=lambda: "missing", + name="get_commit", + description="missing hint", + ), + StructuredTool.from_function( + func=lambda: "write", + name="create_pull_request", + description="write", + metadata={"readOnlyHint": True}, + ), + StructuredTool.from_function( + func=lambda: "trigger", + name="rerun_workflow_run", + description="trigger", + metadata={"readOnlyHint": True}, + ), + ] + + monkeypatch.setattr(internal_sources, "MultiServerMCPClient", FakeMCPClient) + result = asyncio.run( + internal_sources._load_tools( + { + "github": { + "transport": "streamable_http", + "url": "https://example.test", + "headers": {}, + } + } + ) + ) + + assert [tool.name for tool in result["github"]] == ["get_file_contents"] def test_posthog_uses_hosted_read_only_mcp_with_personal_api_key(): @@ -184,6 +264,22 @@ async def get_tools(self): ] +def test_internal_source_toolsets_loads_from_a_running_event_loop(monkeypatch): + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + monkeypatch.setenv("LINEAR_MCP_URL", "https://linear.example.test/mcp") + + async def fake_load_tools(connections): + assert set(connections) == {"linear"} + return {"linear": []} + + monkeypatch.setattr(internal_sources, "_load_tools", fake_load_tools) + + async def load_from_async_context(): + return internal_sources.internal_source_toolsets() + + assert asyncio.run(load_from_async_context()) == {"linear": []} + + def test_one_unavailable_source_does_not_remove_the_other( monkeypatch, caplog, diff --git a/agent/tests/test_open_pull_request.py b/agent/tests/test_open_pull_request.py deleted file mode 100644 index 5a11097..0000000 --- a/agent/tests/test_open_pull_request.py +++ /dev/null @@ -1,214 +0,0 @@ -import shlex -from types import SimpleNamespace - -import pytest -import write_confirmation -from coding.open_pull_request import build_open_pull_request -from coding.sandbox import PerJobDaytonaBackend - - -class FakeBox: - def __init__(self): - self.commands = [] - self.writes = [] - - def execute(self, command, **_kwargs): - self.commands.append(command) - return SimpleNamespace( - output="https://github.com/org/repo/pull/9", - exit_code=0, - ) - - def write(self, file_path, content): - self.writes.append((file_path, content)) - - def delete(self): - pass - - -def _tool(monkeypatch, backend=None): - monkeypatch.setenv("DAYTONA_SNAPSHOT", "test-snap") - backend = backend or PerJobDaytonaBackend(create_sandbox=lambda **_k: FakeBox()) - monkeypatch.setattr("coding.sandbox.current_run_id", lambda: "run-a") - backend.execute("true") - return build_open_pull_request(backend), backend - - -def test_red_check_without_open_anyway_does_not_run_gh(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - tool, backend = _tool(monkeypatch) - with pytest.raises(RuntimeError, match="test_exit_code"): - tool.invoke( - { - "repo": "org/repo", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "green the unit job", - "test_command": "pnpm test", - "test_exit_code": 1, - "open_anyway": False, - } - ) - assert not any("gh pr create" in cmd for cmd in backend._boxes["run-a"].commands) - - -def test_allowlist_miss_does_not_run_gh(monkeypatch): - monkeypatch.setenv("GITHUB_ALLOWED_REPOS", "CopilotKit/*") - tool, backend = _tool(monkeypatch) - with pytest.raises(RuntimeError, match="GITHUB_ALLOWED_REPOS"): - tool.invoke( - { - "repo": "other/repo", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "nope", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert not any("gh pr create" in cmd for cmd in backend._boxes["run-a"].commands) - - -def test_rejected_confirm_does_not_run_gh(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - monkeypatch.setattr( - "coding.open_pull_request.require_write_confirmation", - lambda **_k: False, - ) - tool, backend = _tool(monkeypatch) - result = tool.invoke( - { - "repo": "org/repo", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "draft", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert "cancelled" in result.lower() - assert not any("gh pr create" in cmd for cmd in backend._boxes["run-a"].commands) - - -def test_approve_and_green_runs_draft_pr(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - monkeypatch.setattr( - "coding.open_pull_request.require_write_confirmation", - lambda **_k: True, - ) - tool, backend = _tool(monkeypatch) - result = tool.invoke( - { - "repo": "org/repo", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "draft", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert "https://github.com/org/repo/pull/9" in result - assert any( - "gh pr create" in cmd and "--draft" in cmd - for cmd in backend._boxes["run-a"].commands - ) - - -def test_shell_metacharacters_are_quoted_and_invalid_repo_rejected(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - monkeypatch.setattr( - "coding.open_pull_request.require_write_confirmation", - lambda **_k: True, - ) - tool, backend = _tool(monkeypatch) - box = backend._boxes["run-a"] - - with pytest.raises(RuntimeError, match="invalid repo"): - tool.invoke( - { - "repo": "org/repo;curl evil.com", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "nope", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert not any("gh pr create" in cmd for cmd in box.commands) - assert box.writes == [] - - evil_title = 'foo"; curl evil.com #' - result = tool.invoke( - { - "repo": "org/repo", - "base": "main", - "head": "fix/ci", - "title": evil_title, - "body": "body with $(curl evil.com) and `id`", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert "https://github.com/org/repo/pull/9" in result - assert box.writes == [("/tmp/opentag-pr-body.md", "body with $(curl evil.com) and `id`")] - create_cmds = [cmd for cmd in box.commands if "gh pr create" in cmd] - assert len(create_cmds) == 1 - cmd = create_cmds[0] - quoted_title = shlex.quote(evil_title) - assert quoted_title in cmd - assert f"--title {quoted_title}" in cmd - assert shlex.quote("org/repo") in cmd - assert shlex.quote("main") in cmd - assert shlex.quote("fix/ci") in cmd - assert shlex.quote("/tmp/opentag-pr-body.md") in cmd - assert "--body-file" in cmd - assert "<<" not in cmd - # Unquoted title would leave a raw double-quote before the semicolon payload. - assert '"; curl' not in cmd.replace(quoted_title, "") - - -def test_gh_failure_after_approval_emits_then_raises(monkeypatch): - monkeypatch.delenv("GITHUB_ALLOWED_REPOS", raising=False) - monkeypatch.setattr( - "coding.open_pull_request.require_write_confirmation", - lambda **_k: True, - ) - emitted = [] - - async def emit(_config, message): - emitted.append(message) - - monkeypatch.setattr(write_confirmation, "copilotkit_emit_message", emit) - monkeypatch.setattr(write_confirmation, "ensure_config", lambda: {}) - - class FailingGhBox(FakeBox): - def execute(self, command, **_kwargs): - self.commands.append(command) - if "gh pr create" in command: - return SimpleNamespace(output="HTTP 401", exit_code=1) - return SimpleNamespace(output="ok", exit_code=0) - - tool, _backend = _tool( - monkeypatch, - backend=PerJobDaytonaBackend(create_sandbox=lambda **_k: FailingGhBox()), - ) - with pytest.raises(RuntimeError, match="gh pr create failed"): - tool.invoke( - { - "repo": "org/repo", - "base": "main", - "head": "fix/ci", - "title": "Fix CI", - "body": "draft", - "test_command": "pnpm test", - "test_exit_code": 0, - } - ) - assert emitted - assert "failed" in emitted[0] - assert "HTTP 401" in emitted[0] diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index 39b7f21..5640c33 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -30,3 +30,5 @@ def test_coding_dependencies_are_declared(): deps = project["project"]["dependencies"] assert any(dep.startswith("daytona") for dep in deps) assert any(dep.startswith("langchain-daytona") for dep in deps) + assert any(dep.startswith("httpx") for dep in deps) + assert any(dep.startswith("pyjwt[crypto]") for dep in deps) diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index b4dd595..5c78bb1 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -41,7 +41,7 @@ def test_prompt_completes_useful_work_when_a_capability_is_unavailable(): def test_prompt_describes_read_only_github_search(): - assert "GitHub tools to search repositories" in BASE_SYSTEM_PROMPT + assert "GitHub tools to read repositories" in BASE_SYSTEM_PROMPT assert "GitHub integration is read-only" in BASE_SYSTEM_PROMPT diff --git a/agent/tests/test_repository_tools.py b/agent/tests/test_repository_tools.py new file mode 100644 index 0000000..8f391d9 --- /dev/null +++ b/agent/tests/test_repository_tools.py @@ -0,0 +1,401 @@ +from types import SimpleNamespace + +import pytest + +from coding.github_credentials import GitHubIdentity +from coding.repository_tools import build_repository_tools + + +class FakeProvider: + git_username = "x-access-token" + + def __init__(self, *, actor="octocat", pr=None, fail_pr_once=False): + self.actor = actor + self.pr = pr + self.fail_pr_once = fail_pr_once + self.created_pull = None + self.requests = [] + + def token(self): + return "operation-secret" + + def identity(self): + return GitHubIdentity(self.actor, 42, f"42+{self.actor}@users.noreply.github.com") + + def request_json(self, method, path, *, json=None): + self.requests.append((method, path, json)) + if method == "GET" and "/pulls/" in path: + if self.pr is None: + raise RuntimeError("forbidden") + return self.pr + if method == "GET" and "/pulls?" in path: + return [self.created_pull] if self.created_pull else [] + if method == "GET" and path == "/repos/org/repo": + return {"default_branch": "main"} + if method in {"POST", "PATCH"}: + if self.fail_pr_once: + self.fail_pr_once = False + raise RuntimeError("REST unavailable") + return { + "html_url": "https://github.com/org/repo/pull/9", + "number": 9, + } + raise AssertionError((method, path, json)) + + +class FakeBackend: + def __init__(self, *, fail_push=False): + self.state = {} + self.branch = "" + self.clone_calls = [] + self.pull_calls = [] + self.push_calls = [] + self.remotes = [] + self.identity = None + self.fail_push = fail_push + + def job_state(self): + return self.state + + def clone_repository(self, **kwargs): + self.clone_calls.append(kwargs) + self.branch = kwargs["branch"] + + def set_git_identity(self, **kwargs): + self.identity = kwargs + + def add_remote(self, **kwargs): + self.remotes.append(kwargs) + + def pull_repository(self, **kwargs): + self.pull_calls.append(kwargs) + + def push_repository(self, **kwargs): + self.push_calls.append(kwargs) + if self.fail_push: + raise RuntimeError("push denied operation-secret") + + def execute(self, command, **_kwargs): + if "switch -c" in command: + self.branch = command.rsplit(" ", 1)[-1].strip("'") + return SimpleNamespace(output="", exit_code=0) + if "config pull.rebase false" in command: + return SimpleNamespace(output="", exit_code=0) + if "branch --show-current" in command: + return SimpleNamespace(output=self.branch, exit_code=0) + if "status --porcelain" in command: + return SimpleNamespace(output="", exit_code=0) + if "rev-parse HEAD" in command: + return SimpleNamespace(output="abc123", exit_code=0) + raise AssertionError(command) + + +def _tools(backend=None, provider=None): + backend = backend or FakeBackend() + provider = provider or FakeProvider() + prepare, publish = build_repository_tools(backend, provider) + return prepare, publish, backend, provider + + +def _prepare(prepare, **overrides): + return prepare.invoke( + { + "repo": "org/repo", + "base_branch": "main", + "head_branch": "opentag/fix", + **overrides, + } + ) + + +def _publish(publish, **overrides): + return publish.invoke( + { + "repo": "org/repo", + "base_branch": "main", + "head_branch": "opentag/fix", + "title": "Fix tests", + "body": "Draft body", + "test_command": "pytest", + "test_exit_code": 0, + **overrides, + } + ) + + +def test_prepare_clones_with_operation_token_and_configures_local_identity(): + prepare, _publish_tool, backend, _provider = _tools() + + result = _prepare(prepare) + + assert "status: prepared" in result + assert backend.clone_calls[0] == { + "repo": "org/repo", + "path": "workspace/org-repo", + "branch": "main", + "username": "x-access-token", + "token": "operation-secret", + } + assert backend.identity["name"] == "octocat" + assert backend.branch == "opentag/fix" + + +def test_identical_prepare_replay_reuses_the_existing_repository(): + prepare, _publish_tool, backend, _provider = _tools() + + _prepare(prepare) + result = _prepare(prepare) + + assert "status: already_prepared" in result + assert len(backend.clone_calls) == 1 + + +def test_prepare_replay_cannot_change_the_target(): + prepare, _publish_tool, backend, _provider = _tools() + + _prepare(prepare) + + with pytest.raises(RuntimeError, match="may be called only once"): + _prepare(prepare, head_branch="opentag/other") + assert len(backend.clone_calls) == 1 + + +def test_prepare_existing_fork_pr_and_syncs_base_through_daytona(): + pr = { + "state": "open", + "base": {"ref": "main", "repo": {"full_name": "org/repo"}}, + "head": {"ref": "feature", "repo": {"full_name": "fork/repo"}}, + } + provider = FakeProvider(pr=pr) + prepare, _publish_tool, backend, _provider = _tools(provider=provider) + + result = prepare.invoke( + {"repo": "org/repo", "pr_number": 7, "sync_base": True} + ) + + assert "push_repository: fork/repo" in result + assert backend.clone_calls[0]["repo"] == "fork/repo" + assert backend.remotes == [ + {"path": "workspace/fork-repo", "name": "upstream", "repo": "org/repo"} + ] + assert backend.pull_calls[0]["remote"] == "upstream" + + +def test_unauthorized_prepare_fails_closed_before_clone(): + provider = FakeProvider(pr=None) + prepare, _publish_tool, backend, _provider = _tools(provider=provider) + + with pytest.raises(RuntimeError, match="forbidden"): + prepare.invoke({"repo": "org/repo", "pr_number": 7}) + assert backend.clone_calls == [] + + +def test_closed_pr_cannot_be_prepared(): + pr = { + "state": "closed", + "base": {"ref": "main", "repo": {"full_name": "org/repo"}}, + "head": {"ref": "feature", "repo": {"full_name": "org/repo"}}, + } + provider = FakeProvider(pr=pr) + prepare, _publish_tool, backend, _provider = _tools(provider=provider) + + with pytest.raises(RuntimeError, match="PR #7 is not open"): + prepare.invoke({"repo": "org/repo", "pr_number": 7}) + assert backend.clone_calls == [] + + +def test_rejection_performs_no_remote_write(monkeypatch): + prepare, publish, backend, provider = _tools() + _prepare(prepare) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: False + ) + + result = _publish(publish) + + assert "status: cancelled" in result + assert backend.push_calls == [] + assert not any(method in {"POST", "PATCH"} for method, _path, _json in provider.requests) + + +def test_one_confirmed_push_creates_a_draft_pr(monkeypatch): + prepare, publish, backend, provider = _tools() + _prepare(prepare) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: True + ) + + result = _publish(publish) + + assert "status: opened" in result + assert len(backend.push_calls) == 1 + method, path, payload = provider.requests[-1] + assert (method, path) == ("POST", "/repos/org/repo/pulls") + assert payload["draft"] is True + + +def test_existing_pr_is_updated_without_duplicate_creation(monkeypatch): + pr = { + "state": "open", + "base": {"ref": "main", "repo": {"full_name": "org/repo"}}, + "head": {"ref": "opentag/fix", "repo": {"full_name": "org/repo"}}, + } + provider = FakeProvider(pr=pr) + prepare, publish, backend, _provider = _tools(provider=provider) + prepare.invoke({"repo": "org/repo", "pr_number": 7}) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: True + ) + + result = _publish(publish, existing_pr_number=7) + + assert "status: updated" in result + writes = [item for item in provider.requests if item[0] in {"POST", "PATCH"}] + assert [(method, path) for method, path, _payload in writes] == [ + ("PATCH", "/repos/org/repo/pulls/7") + ] + assert len(backend.push_calls) == 1 + + +def test_failed_push_does_not_attempt_pr_write(monkeypatch): + backend = FakeBackend(fail_push=True) + prepare, publish, _backend, provider = _tools(backend=backend) + _prepare(prepare) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: True + ) + monkeypatch.setattr("coding.repository_tools.emit_write_failure", lambda *_a: None) + + with pytest.raises(RuntimeError, match="push failed"): + _publish(publish) + assert not any(method in {"POST", "PATCH"} for method, _path, _json in provider.requests) + + +def test_pr_failure_after_push_retries_only_pr_creation(monkeypatch): + provider = FakeProvider(fail_pr_once=True) + prepare, publish, backend, _provider = _tools(provider=provider) + _prepare(prepare) + confirmations = [] + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", + lambda **kwargs: confirmations.append(kwargs) or True, + ) + monkeypatch.setattr("coding.repository_tools.emit_write_failure", lambda *_a: None) + + first = _publish(publish) + second = _publish(publish) + + assert "status: branch_pushed" in first + assert "status: opened" in second + assert len(backend.push_calls) == 1 + assert len(confirmations) == 1 + + +def test_lost_create_response_recovers_the_created_pr(monkeypatch): + provider = FakeProvider(fail_pr_once=True) + provider.created_pull = { + "number": 9, + "html_url": "https://github.com/org/repo/pull/9", + "title": "Fix tests", + "body": "Draft body", + "draft": True, + "base": {"ref": "main", "repo": {"full_name": "org/repo"}}, + "head": { + "ref": "opentag/fix", + "sha": "abc123", + "repo": {"full_name": "org/repo"}, + }, + } + prepare, publish, backend, _provider = _tools(provider=provider) + _prepare(prepare) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: True + ) + + result = _publish(publish) + + assert "status: opened" in result + assert "https://github.com/org/repo/pull/9" in result + assert len(backend.push_calls) == 1 + + +def test_create_failure_does_not_adopt_a_different_pull_request(monkeypatch): + provider = FakeProvider(fail_pr_once=True) + provider.created_pull = { + "number": 9, + "html_url": "https://github.com/org/repo/pull/9", + "title": "Someone else's work", + "body": "Different body", + "draft": False, + "base": {"ref": "main", "repo": {"full_name": "org/repo"}}, + "head": { + "ref": "opentag/fix", + "sha": "abc123", + "repo": {"full_name": "org/repo"}, + }, + } + prepare, publish, _backend, _provider = _tools(provider=provider) + _prepare(prepare) + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", lambda **_k: True + ) + monkeypatch.setattr("coding.repository_tools.emit_write_failure", lambda *_a: None) + + result = _publish(publish) + + assert "status: branch_pushed" in result + assert "pr_url:" not in result + + +def test_successful_publish_replay_returns_the_prior_result(monkeypatch): + prepare, publish, backend, provider = _tools() + _prepare(prepare) + confirmations = [] + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", + lambda **kwargs: confirmations.append(kwargs) or True, + ) + + first = _publish(publish) + second = _publish(publish) + + assert second == first + assert len(backend.push_calls) == 1 + assert len(confirmations) == 1 + assert len([request for request in provider.requests if request[0] == "POST"]) == 1 + + +def test_successful_publish_replay_canonicalizes_the_pr_number(monkeypatch): + prepare, publish, backend, provider = _tools() + _prepare(prepare) + confirmations = [] + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", + lambda **kwargs: confirmations.append(kwargs) or True, + ) + + first = _publish(publish) + second = _publish(publish, existing_pr_number=9) + + assert second == first + assert len(backend.push_calls) == 1 + assert len(confirmations) == 1 + assert len([request for request in provider.requests if request[0] == "POST"]) == 1 + assert len([request for request in provider.requests if request[0] == "PATCH"]) == 0 + + +@pytest.mark.parametrize("actor", ["octocat", "open-tag[bot]"]) +def test_confirmation_displays_pat_or_app_actor(monkeypatch, actor): + provider = FakeProvider(actor=actor) + prepare, publish, _backend, _provider = _tools(provider=provider) + _prepare(prepare) + captured = [] + monkeypatch.setattr( + "coding.repository_tools.require_write_confirmation", + lambda **kwargs: captured.append(kwargs) or False, + ) + + _publish(publish) + + assert actor in str(captured[0]["fields"]) + assert "abc123" in str(captured[0]["fields"]) diff --git a/agent/tests/test_sandbox.py b/agent/tests/test_sandbox.py index 34e9f85..1c43dbf 100644 --- a/agent/tests/test_sandbox.py +++ b/agent/tests/test_sandbox.py @@ -66,6 +66,27 @@ def test_current_run_id_falls_back_to_thread_id(monkeypatch): assert current_run_id() == "thread-a" +def test_current_run_id_uses_the_parent_task_namespace_for_a_coder_job(monkeypatch): + namespace = {"value": "tools:job-a|tools:prepare"} + run_id = {"value": "run-a"} + monkeypatch.setattr( + "langgraph.config.get_config", + lambda: { + "run_id": run_id["value"], + "configurable": { + "thread_id": "thread-a", + "checkpoint_ns": namespace["value"], + } + }, + ) + + prepared_job = current_run_id() + namespace["value"] = "tools:job-a|tools:publish" + run_id["value"] = "run-b" + + assert current_run_id() == prepared_job == "thread-a:tools:job-a" + + def test_redact_secrets_strips_github_token_prefixes(): text = "auth ghp_ABCDEFG123 github_pat_ZZ gho_YY" redacted = redact_secrets(text) @@ -90,9 +111,11 @@ def test_redact_secrets_replaces_explicit_secret(): assert "[redacted]" in redacted -def test_redact_secrets_replaces_write_token(monkeypatch): - monkeypatch.setenv("GITHUB_CODER_TOKEN", "not-a-standard-prefix-token") - redacted = redact_secrets("see not-a-standard-prefix-token") +def test_redact_secrets_replaces_explicit_nonstandard_token(): + redacted = redact_secrets( + "see not-a-standard-prefix-token", + secret="not-a-standard-prefix-token", + ) assert "not-a-standard-prefix-token" not in redacted assert "[redacted]" in redacted @@ -127,6 +150,34 @@ def test_execute_redacts_token_shaped_output(monkeypatch): assert result.exit_code == 0 +def test_daytona_git_failure_redacts_the_operation_token(monkeypatch): + token = "arbitrary-installation-secret" + + class GitApi: + def clone(self, **kwargs): + raise RuntimeError(f"clone denied for {kwargs['password']}") + + box = FakeBox() + box.sandbox = SimpleNamespace(git=GitApi()) + monkeypatch.setenv("DAYTONA_SNAPSHOT", "test-snap") + monkeypatch.setattr("coding.sandbox.current_run_id", lambda: "run-a") + backend = PerJobDaytonaBackend(create_sandbox=lambda **_k: box) + + try: + backend.clone_repository( + repo="org/repo", + path="workspace/repo", + branch="main", + username="x-access-token", + token=token, + ) + except RuntimeError as error: + assert token not in str(error) + assert "[redacted]" in str(error) + else: + raise AssertionError("clone should fail") + + def test_execute_returns_execute_response_with_truncated_false(monkeypatch): monkeypatch.setenv("DAYTONA_SNAPSHOT", "test-snap") backend = PerJobDaytonaBackend(create_sandbox=lambda **_k: FakeBox()) @@ -443,6 +494,9 @@ def create_sandbox(**kwargs): backend.execute("git status") assert created[0]["snapshot"] is None + assert "GITHUB_TOKEN" not in created[0]["env"] + assert "GH_TOKEN" not in created[0]["env"] + assert "GIT_ASKPASS" not in created[0]["env"] commands = backend._boxes["run-a"].commands git_installs = [cmd for cmd in commands if "apt-get" in cmd] assert len(git_installs) == 1 @@ -450,19 +504,13 @@ def create_sandbox(**kwargs): assert commands[-1] == "git status" -def test_gh_command_installs_missing_gh_once(monkeypatch): +def test_gh_command_is_never_bootstrapped(monkeypatch): box = ToolBox(have_git=True, have_gh=False) backend = _backend(monkeypatch, lambda **_k: box) backend.execute("gh --version") backend.execute("gh --version") - gh_installs = [cmd for cmd in box.commands if "cli/cli/releases" in cmd] - assert len(gh_installs) == 1 - joined = " ".join(gh_installs) - assert "cli/cli/releases/download" in joined - assert "gh_2.74.1_linux_" in joined - assert ".local/bin/gh" in joined - assert "--max-time 60" in joined + assert not any("cli/cli/releases" in cmd for cmd in box.commands) assert box.commands[-1] == "gh --version" @@ -534,24 +582,23 @@ def test_snapshot_skips_install_on_git_and_gh(monkeypatch): assert box.commands == ["git status", "gh --version"] -def test_gh_install_script_installs_curl_if_missing(monkeypatch): +def test_gh_command_does_not_install_curl(monkeypatch): box = ToolBox(have_git=True, have_gh=False) backend = _backend(monkeypatch, lambda **_k: box) backend.execute("gh --version") - gh_install = next(cmd for cmd in box.commands if "cli/cli/releases" in cmd) - assert "command -v curl" in gh_install - assert "apt-get install -y curl" in gh_install + assert not any("cli/cli/releases" in cmd for cmd in box.commands) + assert not any("apt-get install -y curl" in cmd for cmd in box.commands) -def test_combined_git_and_gh_command_installs_git_before_gh(monkeypatch): +def test_combined_git_and_gh_command_installs_only_git(monkeypatch): box = ToolBox(have_git=False, have_gh=False) backend = _backend(monkeypatch, lambda **_k: box) backend.execute("git push && gh pr create") git_at = next(i for i, cmd in enumerate(box.commands) if "apt-get" in cmd) - gh_at = next(i for i, cmd in enumerate(box.commands) if "cli/cli/releases" in cmd) - assert git_at < gh_at + assert git_at >= 0 + assert not any("cli/cli/releases" in cmd for cmd in box.commands) def test_execute_log_redacts_token_in_command(monkeypatch, capsys): diff --git a/agent/uv.lock b/agent/uv.lock index 1bd5dfd..8252a97 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -1475,10 +1475,12 @@ dependencies = [ { name = "daytona" }, { name = "deepagents" }, { name = "fastapi" }, + { name = "httpx" }, { name = "langchain" }, { name = "langchain-daytona" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "tavily-python" }, { name = "uvicorn", extra = ["standard"] }, @@ -1486,7 +1488,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "pytest" }, ] @@ -1497,20 +1498,19 @@ requires-dist = [ { name = "daytona" }, { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.115.14" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain", specifier = ">=1.2.4" }, { name = "langchain-daytona" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.1.7" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "tavily-python", specifier = ">=0.3.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, ] [package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.27.0" }, - { name = "pytest", specifier = ">=8.0.0" }, -] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] [[package]] name = "opentelemetry-api" diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py index 8acde0e..b8a3fea 100644 --- a/agent/write_confirmation.py +++ b/agent/write_confirmation.py @@ -207,7 +207,7 @@ async def report_write_failure(action: str, error: str) -> None: def emit_write_failure(action: str, error: str) -> None: - """Sync entry for tools that cannot await, such as `open_pull_request`.""" + """Synchronous entry point for graph tools that cannot await.""" def _run() -> None: asyncio.run(report_write_failure(action, error)) diff --git a/app/railway.test.ts b/app/railway.test.ts index 1f86b98..02fb5f4 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -70,6 +70,10 @@ describe("Railway deployment graph", () => { OPENAI_API_KEY: { type: "preserve" }, TAVILY_API_KEY: { type: "preserve" }, GITHUB_PERSONAL_ACCESS_TOKEN: { type: "preserve" }, + GITHUB_CODER_TOKEN: { type: "preserve" }, + GITHUB_APP_ID: { type: "preserve" }, + GITHUB_APP_INSTALLATION_ID: { type: "preserve" }, + GITHUB_APP_PRIVATE_KEY_BASE64: { type: "preserve" }, GITHUB_MCP_URL: { type: "preserve" }, POSTHOG_PERSONAL_API_KEY: { type: "preserve" }, POSTHOG_MCP_URL: { type: "preserve" }, diff --git a/deployment/aws/README.md b/deployment/aws/README.md index fc63cf7..44ac2dd 100644 --- a/deployment/aws/README.md +++ b/deployment/aws/README.md @@ -83,6 +83,11 @@ task starts; use an empty string for an unused integration. Create a second Secrets Manager secret for Datadog. Its entire plaintext value must be the raw Datadog API key, not JSON. +For optional GitHub App coding, create a separate Secrets Manager secret whose +entire plaintext value is the base64-encoded private-key PEM. Pass its complete +ARN as `githubAppPrivateKeySecretArn`; do not add the private key to the JSON +application secret. Existing PAT-only deployments require no change. + Changing the OpenTag secret requires a new ECS task. Never put secret values in CDK context, command history, or source control. @@ -104,12 +109,16 @@ These CDK context values become container environment variables: | `corsAllowOrigins` | `CORS_ALLOW_ORIGINS` | `*` | | `daytonaSnapshot` | `DAYTONA_SNAPSHOT` | Unset | | `daytonaTtlMinutes` | `DAYTONA_TTL_MINUTES` | `60` | -| `githubAllowedRepos` | `GITHUB_ALLOWED_REPOS` | Unset | +| `githubAppId` | `GITHUB_APP_ID` | Unset | +| `githubAppInstallationId` | `GITHUB_APP_INSTALLATION_ID` | Unset | | `githubMcpUrl` | `GITHUB_MCP_URL` | Hosted read-only GitHub MCP | | `posthogMcpUrl` | `POSTHOG_MCP_URL` | Hosted read-only PostHog MCP | | `linearMcpUrl` | `LINEAR_MCP_URL` | Hosted Linear MCP | | `notionMcpUrl` | `NOTION_MCP_URL` | Unset | +`githubAppPrivateKeySecretArn` optionally maps a separate raw Secrets Manager +secret to `GITHUB_APP_PRIVATE_KEY_BASE64` on the agent container. + The AWS task fixes `AGENT_URL` to `http://127.0.0.1:8123/`, the runtime port to `3000`, and the agent port to `8123` because both containers share one task. Users running the images elsewhere can set `AGENT_URL`, `PORT`, `SERVER_HOST`, diff --git a/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index 2fa4748..56fc391 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -138,9 +138,15 @@ export class OpenTagStack extends cdk.Stack { "daytonaTtlMinutes", 60, ); - const githubAllowedRepos = contextString( + const githubAppId = contextString(this, "githubAppId", ""); + const githubAppInstallationId = contextString( this, - "githubAllowedRepos", + "githubAppInstallationId", + "", + ); + const githubAppPrivateKeySecretArn = contextString( + this, + "githubAppPrivateKeySecretArn", "", ); const enableDatadog = contextBoolean(this, "enableDatadog", true); @@ -190,6 +196,13 @@ export class OpenTagStack extends cdk.Stack { "OpenTagSecret", applicationSecretArn.valueAsString, ); + const githubAppPrivateKeySecret = githubAppPrivateKeySecretArn + ? secretsmanager.Secret.fromSecretCompleteArn( + this, + "GitHubAppPrivateKeySecret", + githubAppPrivateKeySecretArn, + ) + : undefined; const serviceCluster = cluster ?? this.createStandaloneCluster( appName, @@ -229,7 +242,11 @@ export class OpenTagStack extends cdk.Stack { ), ...optionalEnvironment("DAYTONA_SNAPSHOT", daytonaSnapshot), DAYTONA_TTL_MINUTES: String(daytonaTtlMinutes), - ...optionalEnvironment("GITHUB_ALLOWED_REPOS", githubAllowedRepos), + ...optionalEnvironment("GITHUB_APP_ID", githubAppId), + ...optionalEnvironment( + "GITHUB_APP_INSTALLATION_ID", + githubAppInstallationId, + ), GITHUB_MCP_URL: contextString( this, "githubMcpUrl", @@ -275,7 +292,15 @@ export class OpenTagStack extends cdk.Stack { streamPrefix: "agent", }), memoryReservationMiB: 1792, - secrets: secretFields(applicationSecret, AGENT_SECRET_KEYS), + secrets: { + ...secretFields(applicationSecret, AGENT_SECRET_KEYS), + ...(githubAppPrivateKeySecret + ? { + GITHUB_APP_PRIVATE_KEY_BASE64: + ecs.Secret.fromSecretsManager(githubAppPrivateKeySecret), + } + : {}), + }, }); agentContainer.addPortMappings({ appProtocol: ecs.AppProtocol.http, @@ -331,6 +356,7 @@ export class OpenTagStack extends cdk.Stack { const executionRole = task.obtainExecutionRole(); applicationSecret.grantRead(executionRole); + githubAppPrivateKeySecret?.grantRead(executionRole); if (secretsKmsKey) { secretsKmsKey.grantDecrypt(executionRole); } diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index f08c2b9..62f38ae 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -112,7 +112,8 @@ test("allows supported non-secret environment overrides through context", () => mermaidUrl: "https://cdn.example.test/mermaid.js", daytonaSnapshot: "snap-test", daytonaTtlMinutes: "45", - githubAllowedRepos: "CopilotKit/*", + githubAppId: "12345", + githubAppInstallationId: "67890", openAiModel: "gpt-test", openAiReasoningEffort: "high", openAiVerbosity: "medium", @@ -126,7 +127,8 @@ test("allows supported non-secret environment overrides through context", () => { Name: "AGENT_DISPLAY_NAME", Value: "Kite" }, { Name: "DAYTONA_SNAPSHOT", Value: "snap-test" }, { Name: "DAYTONA_TTL_MINUTES", Value: "45" }, - { Name: "GITHUB_ALLOWED_REPOS", Value: "CopilotKit/*" }, + { Name: "GITHUB_APP_ID", Value: "12345" }, + { Name: "GITHUB_APP_INSTALLATION_ID", Value: "67890" }, { Name: "OPENAI_MODEL", Value: "gpt-test" }, { Name: "OPENAI_REASONING_EFFORT", Value: "high" }, { Name: "OPENAI_VERBOSITY", Value: "medium" }, @@ -195,6 +197,23 @@ test("injects application secrets without plaintext values", () => { assert.doesNotMatch(json, /cpk-[A-Za-z0-9]/); }); +test("optionally injects a separate GitHub App private-key secret", () => { + const secretArn = + "arn:aws:secretsmanager:us-east-1:123456789012:secret:github-app-key-AbCdEf"; + const template = Template.fromStack( + stackWithContext({ + githubAppId: "12345", + githubAppInstallationId: "67890", + githubAppPrivateKeySecretArn: secretArn, + }), + ); + const json = JSON.stringify(template.toJSON()); + + assert.match(json, /GITHUB_APP_PRIVATE_KEY_BASE64/); + assert.match(json, /github-app-key-AbCdEf/); + assert.doesNotMatch(json, /BEGIN PRIVATE KEY/); +}); + test("can disable Datadog before account credentials are available", () => { const template = Template.fromStack( stackWithContext({ enableDatadog: false }), diff --git a/setup.md b/setup.md index f5502e1..7826442 100644 --- a/setup.md +++ b/setup.md @@ -82,13 +82,15 @@ or Channel slug. | `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | | `OPENAI_VERBOSITY` | No | Defaults to `low` | | `TAVILY_API_KEY` | No | Enables live web research | -| `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, issue, and PR search. If reused by the coder, the token itself also needs branch and pull-request write permission | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, PR, Actions-run, and job-log search. It remains the legacy coding fallback | | `GITHUB_MCP_URL` | No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers | | `DAYTONA_API_KEY` | No | Enables the coding subagent (Daytona sandbox) | -| `DAYTONA_SNAPSHOT` | No | Optional Daytona snapshot id. If unset, the first command probes the box. `git`, `gh`, and `pnpm` install only when a command needs that tool. The default snapshot already has Node. `pnpm` is enabled with Corepack in `$HOME/.local/bin` | +| `DAYTONA_SNAPSHOT` | No | Optional Daytona snapshot id. If unset, the first command probes the box. `git` and `pnpm` install only when needed. The default snapshot already has Node. `pnpm` is enabled with Corepack in `$HOME/.local/bin` | | `DAYTONA_TTL_MINUTES` | No | Daytona box TTL in minutes. Defaults to `60` | -| `GITHUB_CODER_TOKEN` | No | Preferred write token for `git` / `gh` in Daytona. It can also power the server-enforced read-only GitHub MCP when `GITHUB_PERSONAL_ACCESS_TOKEN` is unset | -| `GITHUB_ALLOWED_REPOS` | No | Comma list of `owner/repo` or `owner/*`. If unset, any repo the write token can write is allowed | +| `GITHUB_CODER_TOKEN` | No | Preferred PAT coding credential. Mutually exclusive with complete GitHub App credentials | +| `GITHUB_APP_ID` | No | GitHub App ID; all three App variables are required together | +| `GITHUB_APP_INSTALLATION_ID` | No | Single supported GitHub App installation ID | +| `GITHUB_APP_PRIVATE_KEY_BASE64` | No | Base64-encoded GitHub App private-key PEM | | `POSTHOG_PERSONAL_API_KEY` | No | Enables the hosted PostHog MCP in read-only CLI mode | | `POSTHOG_MCP_URL` | No | Overrides the hosted PostHog MCP URL | | `LINEAR_API_KEY` | No | Enables the hosted Linear MCP | @@ -100,14 +102,18 @@ or Channel slug. | `SERVER_PORT` | No | Local/container port; defaults to `8123` | | `AGENT_RELOAD` | No | Local development reload; disabled by default | -To check a live Daytona box (create, `echo`, `git`, `gh`, then delete): +To check a live Daytona box (create, `echo`, `git`, then delete): ```bash uv run --directory agent python scripts/probe_daytona.py ``` Only `OPENAI_API_KEY` is required. Coding stays off until `DAYTONA_API_KEY` and -a GitHub token are both set. GitHub MCP stays read-only even when coding is on. +a PAT or complete GitHub App configuration are set. If both explicit methods are +configured, or the App configuration is incomplete, coding stays off and startup +logs the configuration problem. `GITHUB_ALLOWED_REPOS` is no longer enforced; +if it remains configured, startup warns that GitHub permissions define access. +GitHub MCP stays read-only even when coding is on. Implementation jobs require a scoped brief with files, the exact change, and a test command; repair and merge jobs may inspect the checkout and CI logs to identify those details. Slack does not say "open the PR" unless the user named @@ -284,11 +290,28 @@ registered when the key is absent. Set `GITHUB_PERSONAL_ACCESS_TOKEN` to enable GitHub search. Use a fine-grained personal access token limited to the repositories and read permissions the agent -needs. OpenTag connects to GitHub's hosted MCP with only the repository, issue, -and pull-request toolsets and requests read-only mode. Set `GITHUB_MCP_URL` only +needs. OpenTag connects to GitHub's hosted MCP with an explicit allowlist of +read-only repository, pull-request, Actions-run, and job-log tools. Every loaded +tool must advertise `readOnlyHint`; triggers, reruns, cancels, deletes, and other +writes are excluded. Set `GITHUB_MCP_URL` only to override the hosted endpoint, then restart `pnpm agent` so it rediscovers the tools. +For coding, prefer a fine-grained `GITHUB_CODER_TOKEN`; classic PATs continue to +work. Alternatively, set all three GitHub App variables. A search PAT may coexist +with App coding. The required repository permissions are **Contents: read/write**, +**Pull requests: read/write**, and **Metadata: read-only**. Add **Actions: read** +for CI inspection and **Workflows: write** only when the agent must modify workflow +files. Installation-selected repositories are the App authorization boundary. +OpenTag does not request or configure branch-protection bypass. + +Credentials stay on the OpenTag host. Daytona receives the current token only on +clone, pull, and push API calls; the sandbox receives no GitHub environment +variable, credential helper, authenticated remote, App JWT, or private key. The +coder commits locally, then one `confirm_write` covers its push and draft-PR +create/update. If the push succeeds and the PR write fails, retrying performs only +the PR write. + ### PostHog Create a PostHog personal API key using the **MCP Server** preset, then set