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
32 changes: 28 additions & 4 deletions backend/druks/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from druks.prompts import render_prompt
from druks.sandbox import gate as sandbox_gate
from druks.sandbox.client import provisioning_key, sandbox_client
from druks.sandbox.models import SandboxIdentity
from druks.sandbox.models import SandboxIdentity, SecretRef
from druks.sandbox.templates import get_template_id
from druks.settings import load_settings
from druks.usage.models import UsageScrape
Expand All @@ -54,13 +54,14 @@ async def _runner(
workflow_id: str,
step: str,
config: AgentConfig,
refs: list[SecretRef],
) -> AsyncIterator["Workspace"]:
# The agent always runs in a Workspace. A warm run attaches the run's held VM; the
# rest get a fresh ephemeral VM. Either way workflow.get_workspace() turns the VM into
# the runner — fresh per call, so nothing (connection or credential) is held across steps.
if host_id:
vm = sandbox_client.attach(host_id=host_id)
elif (refs := [*config.secret_refs, *await workflow.get_secret_refs(session)]) and (
elif refs and (
identity := await SandboxIdentity.lookup(
session,
account_id=workflow.account_id,
Expand Down Expand Up @@ -329,14 +330,36 @@ async def _run(
)
async with gate:
await set_run_phase("provisioning_vm")
host_id = await workflow._lease_host(session, config)
# Resolved once: the box's entries and the harness config name the
# same servers.
subject = await workflow.subject
workspace_class = workflow.workspace_class
mcp_servers, mcp_refs = await workspace_class.get_all_mcp_servers(
session, subject, workflow.account_id
)
refs = [
*config.secret_refs,
*(
SecretRef(
name=secret.name,
secret_id=secret.secret_id,
resource=secret.resource,
host=secret.host,
)
for secret in await workspace_class.get_secrets(subject)
),
*mcp_refs,
]
host_id = await workflow._lease_host(session, config, refs)

# Record the call RUNNING once it has a host to run on (its id names
# the on-disk transcript dir) so the live step shows while the agent
# works, then finish it — or fail it if the run raised after
# starting. A provisioning failure happens before this and records
# no call.
async with _runner(session, workflow, host_id, workflow_id, self.id, config) as runner:
async with _runner(
session, workflow, host_id, workflow_id, self.id, config, refs
) as runner:
context = await runner.prepare_context(session, context, agent_call_id=call_id)
# Templates read the live workflow + the workspace the agent runs in,
# alongside whatever the workflow's get_prompt_context composes.
Expand Down Expand Up @@ -365,6 +388,7 @@ async def _run(
artifact_dir=artifact_dir,
call_id=call_id,
include_plugins=self.include_plugins,
mcp_servers=mcp_servers,
)
except BaseException as error:
await AgentCall.fail(engine, call_id=call_id, error=error)
Expand Down
15 changes: 7 additions & 8 deletions backend/druks/contrib/software_factory/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@
from druks.core.services import Github
from druks.db import db_session
from druks.mcp.inbound import get_druks_mcp_server
from druks.sandbox.datastructures import RequiredMcpServer
from druks.sandbox import SandboxMcpServer, SandboxSecret
from druks.sandbox.layout import get_related_root, get_work_root
from druks.sandbox.models import SecretRef
from druks.services.exceptions import ServiceNotConnectedError
from druks.settings import load_settings
from druks.skills.models import Skill
Expand Down Expand Up @@ -49,10 +48,10 @@ def workspace_root(self) -> str:
return get_work_root(self.host.ssh_username)

@classmethod
async def get_required_mcp_servers(cls, subject: Any) -> tuple[RequiredMcpServer, ...]:
async def get_mcp_servers(cls, subject: Any) -> tuple[SandboxMcpServer, ...]:
# GitHub MCP acts as the review actor. The clone acts as the operator.
actor = await get_review_actor()
github = RequiredMcpServer(
github = SandboxMcpServer(
name=GITHUB_MCP_NAME,
url=GITHUB_MCP_URL,
secret_id=(await actor.service.get()).id,
Expand Down Expand Up @@ -480,22 +479,22 @@ class ReviewWorkspace(RepoWorkspace):
# A checkout of the default branch, with room beside it for siblings. The reviewer
# checks out the PR itself. The add_dirs grant needs the directory to exist.
@classmethod
async def get_secret_refs(cls, subject: Any) -> list[SecretRef]:
async def get_secrets(cls, subject: Any) -> list[SandboxSecret]:
# The review is authored under the review actor's identity.
actor = await get_review_actor()
return [
SecretRef(
SandboxSecret(
name=Github.secret_name,
secret_id=(await actor.service.get()).id,
resource=cls.get_repo(subject),
)
]

@classmethod
async def get_required_mcp_servers(cls, subject: Any) -> tuple[RequiredMcpServer, ...]:
async def get_mcp_servers(cls, subject: Any) -> tuple[SandboxMcpServer, ...]:
actor = await get_review_actor()
return (
RequiredMcpServer(
SandboxMcpServer(
name=GITHUB_MCP_NAME,
url=GITHUB_MCP_URL,
secret_id=(await actor.service.get()).id,
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/harnesses/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ async def get_manifest(
capability set always hashes the same and an eval report can bucket
calls by it."""
# Declared = the enabled registry view; delivered = what actually
# reached this call (a workspace's required server owns its name — see
# Workspace.get_mcp_delivery). The delivered server is what
# reached this call (a workspace's server owns its name — see
# Workspace.get_all_mcp_servers). The delivered server is what
# this harness ran against, so record its url + env var; fall back to
# the declared values only for a declared-but-not-delivered entry.
# token_present reads the delivered shape: it names a bearer or secret
Expand Down
8 changes: 4 additions & 4 deletions backend/druks/mcp/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
from druks.mcp.constants import BEARER_HEADER, BEARER_PREFIX, DRUKS_SERVER_NAME
from druks.mcp.enums import AllowedTools, Toolkit
from druks.mcp.exceptions import MissingEndpointError
from druks.sandbox.datastructures import RequiredMcpServer
from druks.sandbox.datastructures import SandboxMcpServer
from druks.secrets.datastructures import Audience
from druks.secrets.enums import SecretKind
from druks.secrets.models import VaultSecret
from druks.settings import load_settings


def get_druks_mcp_server(*, allowed_tools: tuple[str, ...]) -> RequiredMcpServer:
"""Druks' own `/mcp` as a workspace requires it, at the address a box reaches."""
def get_druks_mcp_server(*, allowed_tools: tuple[str, ...]) -> SandboxMcpServer:
"""Druks' own `/mcp` as a workspace's server, at the address a box reaches."""
if endpoint := load_settings().urls.webhook_base:
return RequiredMcpServer(
return SandboxMcpServer(
name=DRUKS_SERVER_NAME, url=f"{endpoint}/mcp", allowed_tools=allowed_tools
)
raise MissingEndpointError(DRUKS_SERVER_NAME)
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .datastructures import Sandbox
from .datastructures import Sandbox, SandboxMcpServer, SandboxSecret

__all__ = ["Sandbox"]
__all__ = ["Sandbox", "SandboxMcpServer", "SandboxSecret"]
21 changes: 17 additions & 4 deletions backend/druks/sandbox/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,23 @@ class McpServer:


@dataclass(frozen=True)
class RequiredMcpServer:
"""An MCP server a workspace requires for its runs. ``secret_id`` names the
vault row the box's entry issues from and ``resource`` what its token is for;
no ``secret_id`` names this appliance, whose token Druks mints for the run's
class SandboxSecret:
"""A secret a workspace's box holds as a placeholder. ``secret_id`` names the
vault row the issuer answers from and ``resource`` what its token is for. A
``host`` makes it a custom entry: the proxy swaps the placeholder in the
request header at that host, and the box reads it from ``name.upper()``."""

name: str
secret_id: str
resource: str = ""
host: str = ""


@dataclass(frozen=True)
class SandboxMcpServer:
"""An MCP server a workspace's box reaches. ``secret_id`` names the vault row
the box's entry issues from and ``resource`` what its token is for; no
``secret_id`` names this appliance, whose token Druks mints for the run's
account, limited to ``allowed_tools``. It owns its name: a same-named
registry entry is not delivered."""

Expand Down
12 changes: 3 additions & 9 deletions backend/druks/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,19 +919,13 @@ async def get_workspace(self, host: "Host") -> Workspace:
# Built per agent call, so nothing is held across steps.
return self.workspace_class(**await self.get_workspace_kwargs(host))

async def get_secret_refs(self, session: AsyncSession) -> list[SecretRef]:
# The secrets a box of this run fetches beyond its config's: the
# workspace's and its MCP servers', read before the box exists.
subject = await self.subject
_, mcp = await self.workspace_class.get_mcp_delivery(session, subject, self.account_id)
return [*await self.workspace_class.get_secret_refs(subject), *mcp]

async def _lease_host(self, session: AsyncSession, config: "AgentConfig") -> str | None:
async def _lease_host(
self, session: AsyncSession, config: "AgentConfig", refs: list[SecretRef]
) -> str | None:
# The warm VM, provisioned once per segment; state is carried in git, so
# only the host-id matters across steps — held-across-steps never fights replay.
if not self.steps_reuse_sandbox:
return
refs = [*config.secret_refs, *await self.get_secret_refs(session)]
# A crashed process left its box behind. Its identity finds it again.
if not self._host and refs:
identity = await SandboxIdentity.lookup(
Expand Down
67 changes: 28 additions & 39 deletions backend/druks/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from druks.mcp.helpers import get_bearer_token_env_var, get_grant_account
from druks.mcp.inbound import get_druks_account_token
from druks.sandbox import repo as checkout
from druks.sandbox.datastructures import AgentResult, McpServer, RequiredMcpServer
from druks.sandbox.datastructures import AgentResult, McpServer, SandboxMcpServer, SandboxSecret
from druks.sandbox.exceptions import ExecFailed
from druks.sandbox.layout import get_repo_root, get_work_root
from druks.sandbox.models import SecretRef
Expand All @@ -51,13 +51,13 @@ def get_agent_run_kwargs(self, **kwargs: Any) -> dict[str, Any]:
return kwargs

@classmethod
async def get_required_mcp_servers(cls, subject: Any) -> tuple[RequiredMcpServer, ...]:
# Override to declare the servers this workspace requires and the vault
# row each one issues through. Read before the box exists. Base: none.
async def get_mcp_servers(cls, subject: Any) -> tuple[SandboxMcpServer, ...]:
# Override to declare this workspace's servers and the vault row each
# one issues through. Read before the box exists. Base: none.
return ()

@classmethod
async def get_secret_refs(cls, subject: Any) -> list[SecretRef]:
async def get_secrets(cls, subject: Any) -> list[SandboxSecret]:
# The secrets a box of this workspace fetches, beyond its profile's.
# Read before the box exists, so from the subject alone. Base: none.
return []
Expand Down Expand Up @@ -154,45 +154,34 @@ async def _upload_input_file(
return remote

async def run_agent(self, *, account_id: str | None, **kwargs: Any) -> AgentResult:
run_kwargs = await self.with_mcp_servers(
db_session(), account_id, **self.get_agent_run_kwargs(**kwargs)
)
# with_mcp_servers is the run's last DB read; commit so the step's
# connection isn't held idle through the minutes the agent runs.
run_kwargs = self.get_agent_run_kwargs(**kwargs)
# Commit so the step's connection isn't held idle through the minutes
# the agent runs.
await db_session().commit()
return await self.host.run_agent(db_session(), **run_kwargs)

async def with_mcp_servers(
self, session: AsyncSession, account_id: str | None, **kwargs: Any
) -> dict[str, Any]:
# The harness names each server's url, variables, and plain headers.
# Every credential is a box entry, so nothing rides ``extra_env``.
wire, _ = await self.get_mcp_delivery(session, self.subject, account_id)
if wire:
kwargs["mcp_servers"] = wire
return kwargs

@classmethod
async def get_mcp_delivery(
async def get_all_mcp_servers(
cls, session: AsyncSession, subject: Any, account_id: str | None
) -> tuple[tuple[McpServer, ...], list[SecretRef]]:
"""The MCP servers a box of this workspace reaches: the wire shapes for
the harness and the secret refs for the box's entries, one per bearer
and per secret header. The workspace's required servers come first
and own their names: a same-named registry entry is neither resolved
nor delivered. A server that cannot authenticate fails here, before
the box."""
required = await cls.get_required_mcp_servers(subject)
required_names = {server.name for server in required}
if len(required_names) != len(required):
"""The MCP servers a box of this workspace reaches, as the harness names
them, and the secret refs for the box's entries, one per bearer and per
secret header. The workspace's servers come first and own their names:
a same-named registry entry is neither resolved nor delivered. A server
that cannot authenticate fails here, before the box."""
workspace_servers = await cls.get_mcp_servers(subject)
workspace_names = {server.name for server in workspace_servers}
if len(workspace_names) != len(workspace_servers):
# One config key per name in the emitted harness config — a dupe
# would break the VM's config parse mid-run.
raise ValueError(f"duplicate required MCP server names: {sorted(required_names)}")
wire = []
raise ValueError(f"duplicate workspace MCP server names: {sorted(workspace_names)}")
servers = []
refs = []
for server in required:
for server in workspace_servers:
variable = get_bearer_token_env_var(server.name)
wire.append(McpServer(name=server.name, url=server.url, bearer_token_env_var=variable))
servers.append(
McpServer(name=server.name, url=server.url, bearer_token_env_var=variable)
)
if server.secret_id:
secret_id = server.secret_id
else:
Expand All @@ -213,7 +202,7 @@ async def get_mcp_delivery(
run_account = owner.id if owner else None
for server in await mcp_models.McpServer.list_enabled(session):
name = server["name"]
if name in required_names:
if name in workspace_names:
continue
host = urlsplit(server["url"]).hostname
# An OAuth server mints its bearer from the stored grant, loud when
Expand All @@ -236,7 +225,7 @@ async def get_mcp_delivery(
variable = f"{TOKEN_ENV_PREFIX}{name.upper()}_HEADER_{index}"
env_headers[header] = variable
refs.append(SecretRef(name=variable.lower(), secret_id=secret.id, host=host))
wire.append(
servers.append(
McpServer(
name=name,
url=server["url"],
Expand All @@ -245,7 +234,7 @@ async def get_mcp_delivery(
env_headers=env_headers,
)
)
return tuple(wire), refs
return tuple(servers), refs


@dataclass(frozen=True)
Expand All @@ -265,11 +254,11 @@ def get_repo(cls, subject: Any) -> str:
return subject.repo

@classmethod
async def get_secret_refs(cls, subject: Any) -> list[SecretRef]:
async def get_secrets(cls, subject: Any) -> list[SandboxSecret]:
# The identity's vault row and the repo: the whole selection the
# issuer reads. A service that is not connected fails here, before the box.
return [
SecretRef(
SandboxSecret(
name=cls.github.secret_name,
secret_id=(await cls.github.get()).id,
resource=cls.get_repo(subject),
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/druks-field_notes/tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest import mock

from conftest import connect_service
from druks.sandbox import SandboxSecret
from druks.sandbox.layout import get_repo_root
from druks.testing import run_workflow
from druks_field_notes.app import FieldNotes
Expand Down Expand Up @@ -46,5 +47,5 @@ async def test_survey_workspace_clones_the_subject_repo(druks_db):
row = await connect_service(
"github", identity={"app_id": "1", "slug": "druks-operator"}, secrets={"private_key": "pem"}
)
[secret] = await workflow.get_secret_refs(druks_db)
assert secret.key == ("github", row.id, "acme/widgets", "")
[secret] = await workflow.workspace_class.get_secrets(await workflow.subject)
assert secret == SandboxSecret(name="github", secret_id=row.id, resource="acme/widgets")
Loading
Loading