Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion backend/druks/contrib/software_factory/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Literal

import httpx
from pydantic import Field

from druks.agents import Agent
Expand All @@ -23,6 +24,7 @@
from druks.db import StoredSubject
from druks.doctor import CheckResult
from druks.services import ServiceNotConnectedError
from druks.settings import load_settings
from druks.workflows import SubjectActivity

# Only what the timeline can't already show. A running agent has an agent call
Expand Down Expand Up @@ -66,6 +68,38 @@ async def check_review_identity() -> CheckResult:
)


async def check_issues_mcp() -> CheckResult:
"""Whether this appliance's /mcp answers, so an issues build can fetch
and comment. Linear and Jira do not need it."""
if (await SoftwareFactory.settings()).tracker != "issues":
return CheckResult(name="issues_mcp", ok=True, detail="not required")
endpoint = load_settings().urls.endpoint.rstrip("/")
if not endpoint:
return CheckResult(
name="issues_mcp",
ok=False,
pending=True,
detail="urls.endpoint is unset — the sandbox needs it to reach /mcp.",
)
url = f"{endpoint}/mcp"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
except httpx.RequestError as error:
return CheckResult(
name="issues_mcp",
ok=False,
detail=f"{url} is unreachable: {error}. The issues tracker tools need it.",
)
if response.status_code >= 500:
return CheckResult(
name="issues_mcp",
ok=False,
detail=f"{url} returned {response.status_code}. The issues tracker tools need it.",
)
return CheckResult(name="issues_mcp", ok=True, detail=url)


class SoftwareFactory(App):
name = "software_factory"
# These tables (projects, work_items, ...) are already unprefixed in core's
Expand Down Expand Up @@ -155,7 +189,7 @@ def clean(self) -> dict[str, str]:
problems["review_app_id"] = "Required once the review App private key is set."
return problems

checks = [check_tracker_identity, check_review_identity]
checks = [check_tracker_identity, check_review_identity, check_issues_mcp]

@classmethod
async def get_tracker(cls, source: str | None = None) -> Tracker | None:
Expand Down
3 changes: 3 additions & 0 deletions backend/druks/contrib/software_factory/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
# act as (druks.contrib.software_factory.github).
GITHUB_MCP_NAME = "github"
GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"
# The appliance /mcp, required when the tracker is issues. Same doors the
# dashboard uses; the sandbox reaches them here, not through Linear.
APPLIANCE_MCP_NAME = "druks"
64 changes: 60 additions & 4 deletions backend/druks/contrib/software_factory/workflows.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit, urlunsplit

from pydantic import BaseModel, Field

from druks.accounts.models import Account
from druks.accounts.models import Account, PersonalAccessToken
from druks.contrib.software_factory.contracts import ImplementationOutput, ReviewWork
from druks.contrib.software_factory.enums import (
EvaluationVerdict,
Expand All @@ -23,7 +24,7 @@
from druks.workspaces import RepoWorkspace

from .app import SoftwareFactory
from .constants import GITHUB_MCP_NAME, GITHUB_MCP_URL
from .constants import APPLIANCE_MCP_NAME, GITHUB_MCP_NAME, GITHUB_MCP_URL
from .datastructures import PullRequest
from .github import get_review_actor
from .journal import BuildJournal
Expand All @@ -35,20 +36,55 @@

logger = logging.getLogger(__name__)

_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})


def appliance_mcp_url() -> str:
"""The appliance /mcp as a sandbox reaches this process. Loopback is this
host, not the VM, so it becomes the Docker host gateway."""
endpoint = load_settings().urls.endpoint.rstrip("/")
if not endpoint:
raise FatalError(
"urls.endpoint is unset; the issues tracker tools need /mcp reachable from the sandbox."
)
parts = urlsplit(endpoint)
host = parts.hostname or ""
if host in _LOOPBACK_HOSTS:
port = f":{parts.port}" if parts.port else ""
endpoint = urlunsplit(
(parts.scheme, f"host.docker.internal{port}", parts.path, "", "")
).rstrip("/")
return f"{endpoint}/mcp"


@dataclass(frozen=True, kw_only=True)
class BuildWorkspace(RepoWorkspace):
skills: tuple[str, ...]
# Installation token for build's github MCP server, minted per repo from
# the identity reviews act as. Required — there is no build without github.
mcp_token: str
# Appliance /mcp, set only when the tracker is issues. Empty otherwise —
# Linear and Jira do not take this server.
appliance_mcp_url: str = ""
appliance_mcp_token: str = ""

@property
def workspace_root(self) -> str:
return get_work_root(self.host.ssh_username)

def get_required_mcp_servers(self) -> tuple[RequiredMcpServer, ...]:
return (RequiredMcpServer(name=GITHUB_MCP_NAME, url=GITHUB_MCP_URL, token=self.mcp_token),)
servers = (
RequiredMcpServer(name=GITHUB_MCP_NAME, url=GITHUB_MCP_URL, token=self.mcp_token),
)
if self.appliance_mcp_url:
servers += (
RequiredMcpServer(
name=APPLIANCE_MCP_NAME,
url=self.appliance_mcp_url,
token=self.appliance_mcp_token,
),
)
return servers

async def run_agent(self, *, account_id: str | None, **kwargs: Any):
# Agents clone related repos on demand; Claude's --add-dir target must exist first.
Expand Down Expand Up @@ -193,13 +229,33 @@ async def get_workspace_kwargs(self, host: "Host") -> dict[str, Any]:
f"Could not mint the GitHub token for {repo}; build requires it "
"for its github MCP server."
) from error
return {
kwargs = {
**kwargs,
# None until the first implement provisions the PR branch.
"branch": self.branch,
"mcp_token": mcp_token,
"skills": tuple(self._profile.get("recommended_skills", [])),
}
if (await SoftwareFactory.settings()).tracker == "issues":
kwargs["appliance_mcp_url"] = appliance_mcp_url()
account_id = self.account_id
if account_id:
account = await Account.get(account_id)
if not account:
raise FatalError(
f"issues tracker tools need account {account_id} to mint the /mcp PAT."
)
else:
account = await Account.get_default()
if not account:
raise FatalError(
"issues tracker tools need a run account or a default "
"account to mint the /mcp PAT."
)
_, kwargs["appliance_mcp_token"] = await PersonalAccessToken.create(
account_id=account.id, name="issues sandbox"
)
return kwargs

async def get_prompt_context(self, **context: Any) -> dict[str, Any]:
work_item = await self.subject
Expand Down
146 changes: 144 additions & 2 deletions backend/tests/software_factory/test_build_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

import pytest
from druks import workspaces as workspace_mod
from druks.contrib.software_factory.constants import GITHUB_MCP_NAME, GITHUB_MCP_URL
from druks.accounts.models import Account
from druks.contrib.software_factory.app import SoftwareFactory
from druks.contrib.software_factory.constants import (
APPLIANCE_MCP_NAME,
GITHUB_MCP_NAME,
GITHUB_MCP_URL,
)
from druks.contrib.software_factory.workflows import Build, BuildWorkspace
from druks.mcp.helpers import get_bearer_token_env_var
from druks.sandbox import host as host_mod
Expand Down Expand Up @@ -83,6 +89,25 @@ async def test_build_workspace_declares_its_github_mcp(druks_db):
github = next(s for s in kwargs["mcp_servers"] if s.name == GITHUB_MCP_NAME)
assert github.url == GITHUB_MCP_URL
assert "ghs_review" not in repr(github)
assert APPLIANCE_MCP_NAME not in {s.name for s in kwargs["mcp_servers"]}


async def test_issues_tracker_requires_appliance_mcp():
workspace = BuildWorkspace(
host=_FakeSandbox(), # type: ignore[arg-type]
subject=SimpleNamespace(repo="o/main"),
branch="b",
mcp_token="ghs_review",
skills=("python-house-rules",),
appliance_mcp_url="http://host.docker.internal:8001/mcp",
appliance_mcp_token="druks_pat_test",
)
kwargs = await workspace.with_mcp_servers(None, **workspace.get_agent_run_kwargs())

appliance = next(s for s in kwargs["mcp_servers"] if s.name == APPLIANCE_MCP_NAME)
assert appliance.url == "http://host.docker.internal:8001/mcp"
assert kwargs["extra_env"][get_bearer_token_env_var(APPLIANCE_MCP_NAME)] == "druks_pat_test"
assert "druks_pat_test" not in repr(appliance)


def _review_actor_stub(monkeypatch: pytest.MonkeyPatch, *, review_actor) -> None:
Expand All @@ -93,7 +118,9 @@ async def _review_actor():


@pytest.mark.asyncio
async def test_get_workspace_kwargs_carries_the_build_fields(monkeypatch: pytest.MonkeyPatch):
async def test_get_workspace_kwargs_carries_the_build_fields(
druks_db, monkeypatch: pytest.MonkeyPatch
):
async def _review_token(_repo: str) -> str:
return "ghs_review"

Expand Down Expand Up @@ -146,6 +173,121 @@ async def _no_token(_repo: str) -> str:
await workflow.get_workspace_kwargs(sandbox)


def _pin_tracker(monkeypatch: pytest.MonkeyPatch, tracker: str) -> None:
settings = SoftwareFactory.Settings(tracker=tracker)

async def _settings(cls):
return settings

monkeypatch.setattr(SoftwareFactory, "settings", classmethod(_settings))


def _issues_workspace(monkeypatch: pytest.MonkeyPatch) -> tuple[Build, Any]:
async def _review_token(_repo: str) -> str:
return "ghs_review"

_pin_tracker(monkeypatch, "issues")
monkeypatch.setattr(
"druks.contrib.software_factory.workflows.load_settings",
lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="http://127.0.0.1:8001")),
)
_review_actor_stub(
monkeypatch,
review_actor=lambda: SimpleNamespace(
client=SimpleNamespace(token_for_repo=_review_token),
mode="approve",
),
)
sandbox = host_mod.Host(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type]
workflow = Build()
workflow.input = Build._run_input_model()
workflow.subject = SimpleNamespace(repo="o/app")
workflow._profile = {"recommended_skills": ["python-house-rules"]}
workflow.account_id = None
return workflow, sandbox


async def test_get_workspace_kwargs_mints_a_pat_for_the_run_account(druks_db, monkeypatch):
account = await Account.get_or_create("op@example.com")
await Account.get_or_create("other@example.com")
workflow, sandbox = _issues_workspace(monkeypatch)
workflow.account_id = account.id

kwargs = await workflow.get_workspace_kwargs(sandbox)

assert kwargs["appliance_mcp_url"] == "http://host.docker.internal:8001/mcp"
assert kwargs["appliance_mcp_token"].startswith("druks_pat_")
workspace = BuildWorkspace(**kwargs)
assert APPLIANCE_MCP_NAME in {server.name for server in workspace.get_required_mcp_servers()}
assert "issues" not in {server.name for server in workspace.get_required_mcp_servers()}


async def test_get_workspace_kwargs_uses_the_sole_operator_when_unassigned(druks_db, monkeypatch):
await Account.get_or_create("op@example.com")
workflow, sandbox = _issues_workspace(monkeypatch)

kwargs = await workflow.get_workspace_kwargs(sandbox)

assert kwargs["appliance_mcp_token"].startswith("druks_pat_")


async def test_get_workspace_kwargs_keeps_a_public_mcp_endpoint(druks_db, monkeypatch):
await Account.get_or_create("op@example.com")
workflow, sandbox = _issues_workspace(monkeypatch)
monkeypatch.setattr(
"druks.contrib.software_factory.workflows.load_settings",
lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="https://druks.example.com")),
)

kwargs = await workflow.get_workspace_kwargs(sandbox)

assert kwargs["appliance_mcp_url"] == "https://druks.example.com/mcp"


async def test_get_workspace_kwargs_fails_without_an_operator_account(druks_db, monkeypatch):
workflow, sandbox = _issues_workspace(monkeypatch)

with pytest.raises(FatalError, match="/mcp PAT"):
await workflow.get_workspace_kwargs(sandbox)


async def test_get_workspace_kwargs_fails_when_endpoint_is_unset(druks_db, monkeypatch):
await Account.get_or_create("op@example.com")
workflow, sandbox = _issues_workspace(monkeypatch)
monkeypatch.setattr(
"druks.contrib.software_factory.workflows.load_settings",
lambda: SimpleNamespace(urls=SimpleNamespace(endpoint="")),
)

with pytest.raises(FatalError, match="/mcp"):
await workflow.get_workspace_kwargs(sandbox)


async def test_linear_tracker_does_not_require_appliance_mcp(druks_db, monkeypatch):
async def _review_token(_repo: str) -> str:
return "ghs_review"

_review_actor_stub(
monkeypatch,
review_actor=lambda: SimpleNamespace(
client=SimpleNamespace(token_for_repo=_review_token),
mode="approve",
),
)
sandbox = host_mod.Host(record=SimpleNamespace(id="h1", ssh_username="exedev")) # type: ignore[arg-type]
workflow = Build()
workflow.input = Build._run_input_model()
workflow.subject = SimpleNamespace(repo="o/app")
workflow._profile = {"recommended_skills": ["python-house-rules"]}

kwargs = await workflow.get_workspace_kwargs(sandbox)
workspace = BuildWorkspace(**kwargs)

servers = workspace.get_required_mcp_servers()
assert "appliance_mcp_url" not in kwargs
assert APPLIANCE_MCP_NAME not in {server.name for server in servers}


class _IdentitySandbox:
ssh_username = "exedev"

Expand Down
Loading