From 0daf695ec693d02fa90d0e9ebd63d31ce01bd149 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 27 Sep 2026 20:45:38 +0200 Subject: [PATCH] Name the workspace's sandbox secrets and MCP servers on the author surface A workspace's custom secret hook returned SecretRef, an internal ORM row, so apps imported druks.sandbox.models. The MCP hook's type lived in the internal druks.sandbox.datastructures module. - Add SandboxSecret and rename RequiredMcpServer to SandboxMcpServer. Export both from druks.sandbox. - Rename the workspace hooks to get_secrets() and get_mcp_servers(). - Rename get_mcp_delivery() to get_all_mcp_servers(). The agent call now resolves it once and passes the servers to the workspace. Before, the box's secret refs and the harness config each resolved every server again. - Document get_secrets() in the author guide and list both types as stable imports. --- backend/druks/agents.py | 32 ++++- .../contrib/software_factory/workflows.py | 15 +- backend/druks/harnesses/base.py | 4 +- backend/druks/mcp/inbound.py | 8 +- backend/druks/sandbox/__init__.py | 4 +- backend/druks/sandbox/datastructures.py | 21 ++- backend/druks/workflows.py | 12 +- backend/druks/workspaces.py | 67 ++++----- .../druks-field_notes/tests/test_workflows.py | 5 +- .../software_factory/test_build_workspace.py | 15 +- backend/tests/test_agents.py | 4 +- backend/tests/test_author_surface.py | 2 +- backend/tests/test_declared_sandboxes.py | 5 +- backend/tests/test_manifest.py | 2 +- backend/tests/test_mcp_oauth.py | 14 +- backend/tests/test_mcp_servers.py | 131 ++++++++---------- backend/tests/test_sandbox_identities.py | 2 +- backend/tests/test_warm_host_rotation.py | 26 ++-- backend/tests/test_workspaces.py | 9 +- docs/configuration.md | 2 +- docs/writing-an-app.md | 55 ++++++-- 21 files changed, 239 insertions(+), 196 deletions(-) diff --git a/backend/druks/agents.py b/backend/druks/agents.py index 84ad1011..8b85875b 100644 --- a/backend/druks/agents.py +++ b/backend/druks/agents.py @@ -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 @@ -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, @@ -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. @@ -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) diff --git a/backend/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index 1867c158..fc4477dc 100644 --- a/backend/druks/contrib/software_factory/workflows.py +++ b/backend/druks/contrib/software_factory/workflows.py @@ -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 @@ -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, @@ -480,11 +479,11 @@ 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), @@ -492,10 +491,10 @@ async def get_secret_refs(cls, subject: Any) -> list[SecretRef]: ] @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, diff --git a/backend/druks/harnesses/base.py b/backend/druks/harnesses/base.py index d786dc06..d4e71235 100644 --- a/backend/druks/harnesses/base.py +++ b/backend/druks/harnesses/base.py @@ -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 diff --git a/backend/druks/mcp/inbound.py b/backend/druks/mcp/inbound.py index 9d2d75f1..ce91f921 100644 --- a/backend/druks/mcp/inbound.py +++ b/backend/druks/mcp/inbound.py @@ -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) diff --git a/backend/druks/sandbox/__init__.py b/backend/druks/sandbox/__init__.py index 4124fe49..4dc7328d 100644 --- a/backend/druks/sandbox/__init__.py +++ b/backend/druks/sandbox/__init__.py @@ -1,3 +1,3 @@ -from .datastructures import Sandbox +from .datastructures import Sandbox, SandboxMcpServer, SandboxSecret -__all__ = ["Sandbox"] +__all__ = ["Sandbox", "SandboxMcpServer", "SandboxSecret"] diff --git a/backend/druks/sandbox/datastructures.py b/backend/druks/sandbox/datastructures.py index 47c4cbb2..52b8ebf6 100644 --- a/backend/druks/sandbox/datastructures.py +++ b/backend/druks/sandbox/datastructures.py @@ -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.""" diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index e3eb30a8..d6022f6c 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -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( diff --git a/backend/druks/workspaces.py b/backend/druks/workspaces.py index 88c9b5a5..2a914a69 100644 --- a/backend/druks/workspaces.py +++ b/backend/druks/workspaces.py @@ -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 @@ -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 [] @@ -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: @@ -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 @@ -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"], @@ -245,7 +234,7 @@ async def get_mcp_delivery( env_headers=env_headers, ) ) - return tuple(wire), refs + return tuple(servers), refs @dataclass(frozen=True) @@ -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), diff --git a/backend/tests/druks-field_notes/tests/test_workflows.py b/backend/tests/druks-field_notes/tests/test_workflows.py index f12f2c2a..191719fe 100644 --- a/backend/tests/druks-field_notes/tests/test_workflows.py +++ b/backend/tests/druks-field_notes/tests/test_workflows.py @@ -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 @@ -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") diff --git a/backend/tests/software_factory/test_build_workspace.py b/backend/tests/software_factory/test_build_workspace.py index e98764a4..e6b31202 100644 --- a/backend/tests/software_factory/test_build_workspace.py +++ b/backend/tests/software_factory/test_build_workspace.py @@ -16,6 +16,7 @@ from druks.contrib.software_factory.workflows import Build, BuildWorkspace, ReviewWorkspace from druks.core.services import Github from druks.mcp.helpers import get_bearer_token_env_var +from druks.sandbox import SandboxSecret from druks.sandbox.host import Host from druks.sandbox.layout import get_related_root, get_repo_root from druks.settings import Urls @@ -78,13 +79,13 @@ async def test_build_workspace_declares_its_github_mcp_as_the_review_actor(druks ) subject = SimpleNamespace(repo="o/main") - [github], [ref] = await BuildWorkspace.get_mcp_delivery(druks_db, subject, None) + [github], [ref] = await BuildWorkspace.get_all_mcp_servers(druks_db, subject, None) assert github.url == GITHUB_MCP_URL assert github.bearer_token_env_var == get_bearer_token_env_var(GITHUB_MCP_NAME) assert ref.key == ("mcp_github_token", reviewer.id, "o/main", "api.githubcopilot.com") - [clone] = await BuildWorkspace.get_secret_refs(subject) - assert clone.key == ("github", operator.id, "o/main", "") + [clone] = await BuildWorkspace.get_secrets(subject) + assert clone == SandboxSecret(name="github", secret_id=operator.id, resource="o/main") async def test_get_workspace_kwargs_carries_the_build_fields(): @@ -114,7 +115,7 @@ async def _required_servers(monkeypatch: pytest.MonkeyPatch, tracker: str): ) settings = SoftwareFactory.Settings(tracker=tracker) monkeypatch.setattr(SoftwareFactory, "settings", AsyncMock(return_value=settings)) - return await BuildWorkspace.get_required_mcp_servers(SimpleNamespace(repo="o/main")) + return await BuildWorkspace.get_mcp_servers(SimpleNamespace(repo="o/main")) async def test_a_board_build_requires_the_appliance_mcp(druks_db, monkeypatch): @@ -150,13 +151,13 @@ async def test_review_mcp_and_gh_use_the_review_actor(druks_db): ) subject = SimpleNamespace(repo="o/app") - [github], [ref] = await ReviewWorkspace.get_mcp_delivery(druks_db, subject, None) + [github], [ref] = await ReviewWorkspace.get_all_mcp_servers(druks_db, subject, None) assert github.url == GITHUB_MCP_URL assert github.bearer_token_env_var == get_bearer_token_env_var(GITHUB_MCP_NAME) assert ref.key == ("mcp_github_token", reviewer.id, "o/app", "api.githubcopilot.com") - [clone] = await ReviewWorkspace.get_secret_refs(subject) - assert clone.key == ("github", reviewer.id, "o/app", "") + [clone] = await ReviewWorkspace.get_secrets(subject) + assert clone == SandboxSecret(name="github", secret_id=reviewer.id, resource="o/app") class _IdentitySandbox: diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 66054b66..7d4071ed 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -827,8 +827,8 @@ async def fake_provision(self, *, idempotency_key=None, **_kwargs): config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") with pytest.raises(HarnessSandboxProvisioningError): - await current_run._lease_host(db_session(), config) - host_id = await current_run._lease_host(db_session(), config) + await current_run._lease_host(db_session(), config, []) + host_id = await current_run._lease_host(db_session(), config, []) assert host_id == "warm-host" assert keys == ["wf-9:workflow", "wf-9:workflow"] diff --git a/backend/tests/test_author_surface.py b/backend/tests/test_author_surface.py index e59b335e..2258f2f3 100644 --- a/backend/tests/test_author_surface.py +++ b/backend/tests/test_author_surface.py @@ -15,7 +15,7 @@ "ServiceConnectError", "ServiceNotConnectedError", }, - "druks.sandbox": {"Sandbox"}, + "druks.sandbox": {"Sandbox", "SandboxMcpServer", "SandboxSecret"}, "druks.agents": {"Agent", "AgentOutput", "Bot", "BotUser"}, "druks.workflows": { "AgentCall", diff --git a/backend/tests/test_declared_sandboxes.py b/backend/tests/test_declared_sandboxes.py index 02693101..8b617ec3 100644 --- a/backend/tests/test_declared_sandboxes.py +++ b/backend/tests/test_declared_sandboxes.py @@ -240,7 +240,7 @@ async def test_warm_lease_uses_workflow_template(monkeypatch): assert ( await workflow._lease_host( - db_session(), SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") + db_session(), SimpleNamespace(secrets={}, secret_refs=[], secrets_id=""), [] ) == "host-1" ) @@ -266,7 +266,6 @@ async def ephemeral(**kwargs): workflow = SimpleNamespace( sandbox=sandbox, get_workspace=AsyncMock(return_value="workspace"), - get_secret_refs=AsyncMock(return_value=[]), ) resolve = AsyncMock(return_value="template-1") monkeypatch.setattr( @@ -278,7 +277,7 @@ async def ephemeral(**kwargs): config = SimpleNamespace(secrets={}, secret_refs=[], secrets_id="") async with agent_module._runner( - db_session(), workflow, None, "run-1", "summarize", config + db_session(), workflow, None, "run-1", "summarize", config, [] ) as runner: assert runner == "workspace" diff --git a/backend/tests/test_manifest.py b/backend/tests/test_manifest.py index 5627a459..28db182f 100644 --- a/backend/tests/test_manifest.py +++ b/backend/tests/test_manifest.py @@ -64,7 +64,7 @@ async def test_manifest_records_the_delivered_capability_set(druks_db): bearer_token_env_var=get_bearer_token_env_var("github"), ) # Both servers delivered with a bearer entry — github is SoftwareFactory's own - # requirement (get_required_mcp_servers), so it reads delivered but not declared. + # server (get_mcp_servers), so it reads delivered but not declared. manifest = await _build(mcp_servers=(linear, github), skills=("alpha",)) assert manifest["schema_version"] == 2 diff --git a/backend/tests/test_mcp_oauth.py b/backend/tests/test_mcp_oauth.py index a5eb3e7c..3260a4d7 100644 --- a/backend/tests/test_mcp_oauth.py +++ b/backend/tests/test_mcp_oauth.py @@ -693,7 +693,7 @@ async def test_get_cache_and_refresh_lock_are_per_account(auth_server, druks_db) async def _ref(account_id: str | None = None) -> SecretRef: - _, refs = await Workspace.get_mcp_delivery(db_session(), None, account_id) + _, refs = await Workspace.get_all_mcp_servers(db_session(), None, account_id) return next(ref for ref in refs if ref.name == get_bearer_token_env_var(_NAME).lower()) @@ -703,10 +703,10 @@ async def test_delivery_binds_the_grant_and_the_row_issues_the_token( _register_oauth_server() grant = await _store_grant() - wire, refs = await Workspace.get_mcp_delivery(db_session(), None, None) + servers, refs = await Workspace.get_all_mcp_servers(db_session(), None, None) var = get_bearer_token_env_var(_NAME) - entry = next(s for s in wire if s.name == _NAME) + entry = next(s for s in servers if s.name == _NAME) assert entry.url == _SERVER_URL assert entry.bearer_token_env_var == var [ref] = refs @@ -714,7 +714,7 @@ async def test_delivery_binds_the_grant_and_the_row_issues_the_token( token, expires_at = await grant.issue_token("") assert token == "at-1" assert expires_at > datetime.now(UTC) - assert "at-1" not in repr(wire) + repr(refs) + assert "at-1" not in repr(servers) + repr(refs) async def test_delivery_fails_loudly_for_an_unconnected_enabled_oauth_server( @@ -725,7 +725,7 @@ async def test_delivery_fails_loudly_for_an_unconnected_enabled_oauth_server( server.identity_mode = IdentityMode.SHARED with pytest.raises(MissingGrantError, match=_NAME): - await Workspace.get_mcp_delivery(db_session(), None, None) + await Workspace.get_all_mcp_servers(db_session(), None, None) async def test_delivery_names_the_account_missing_its_per_user_grant(druks_db): @@ -734,7 +734,7 @@ async def test_delivery_names_the_account_missing_its_per_user_grant(druks_db): server.identity_mode = IdentityMode.PER_USER with pytest.raises(MissingGrantError) as error: - await Workspace.get_mcp_delivery(db_session(), None, account.id) + await Workspace.get_all_mcp_servers(db_session(), None, account.id) assert error.value.name == _NAME assert error.value.account_id == account.id @@ -759,7 +759,7 @@ async def test_delivery_with_a_named_account_does_not_use_the_default_account( await _store_grant(account_id=default_account.id, identity_mode=IdentityMode.PER_USER) with pytest.raises(MissingGrantError) as error: - await Workspace.get_mcp_delivery(db_session(), None, named.id) + await Workspace.get_all_mcp_servers(db_session(), None, named.id) assert error.value.account_id == named.id diff --git a/backend/tests/test_mcp_servers.py b/backend/tests/test_mcp_servers.py index de6a4e14..fdbf6fab 100644 --- a/backend/tests/test_mcp_servers.py +++ b/backend/tests/test_mcp_servers.py @@ -22,7 +22,7 @@ from druks.mcp.helpers import get_bearer_token_env_var from druks.mcp.inbound import get_druks_mcp_server from druks.mcp.models import McpServer -from druks.sandbox.datastructures import RequiredMcpServer +from druks.sandbox import SandboxMcpServer from druks.sandbox.models import SecretRef from druks.secrets.datastructures import Audience from druks.secrets.enums import SecretKind @@ -36,10 +36,6 @@ _BEARER = {"Authorization": f"Bearer {_TOKEN}"} -class _FakeSandbox: - ssh_username = "exedev" - - def _sandbox_config() -> SandboxSettings: return SandboxSettings( service_url="https://sb.test", @@ -50,15 +46,16 @@ def _sandbox_config() -> SandboxSettings: ) -async def _delivery() -> dict: - # Delivery at the workspace seam: the enabled servers become wire shapes on - # ``mcp_servers``; their credentials are box entries, never env. - return await Workspace(host=_FakeSandbox()).with_mcp_servers(db_session(), None) # type: ignore[arg-type] +async def _servers() -> tuple: + # The enabled servers as the harness names them; their credentials are box + # entries, never env. + servers, _ = await Workspace.get_all_mcp_servers(db_session(), None, None) + return servers async def _refs() -> dict[str, SecretRef]: # The secret refs a box of a plain workspace binds, one per entry, by name. - _, refs = await Workspace.get_mcp_delivery(db_session(), None, None) + _, refs = await Workspace.get_all_mcp_servers(db_session(), None, None) return {ref.name: ref for ref in refs} @@ -74,15 +71,15 @@ async def _github_row() -> VaultSecret: ) -def _requiring(*servers: RequiredMcpServer) -> type[Workspace]: - # A workspace declaring the servers it requires and the vault row each - # one issues through, as SoftwareFactory does. - class _Requiring(Workspace): +def _declaring(*servers: SandboxMcpServer) -> type[Workspace]: + # A workspace declaring its servers and the vault row each one issues + # through, as SoftwareFactory does. + class _Declaring(Workspace): @classmethod - async def get_required_mcp_servers(cls, subject) -> tuple[RequiredMcpServer, ...]: + async def get_mcp_servers(cls, subject) -> tuple[SandboxMcpServer, ...]: return servers - return _Requiring + return _Declaring # --- custom servers: CRUD + enable/disable ------------------------------- @@ -155,30 +152,29 @@ async def test_delivery_names_the_variable_and_binds_the_header_row(druks_db): await McpServer.create(druks_db, name="linear", url=_LINEAR_URL, secret_headers=_BEARER) row = await _bearer_row("linear") - kwargs = await _delivery() + servers = await _servers() refs = await _refs() - # The wire shape names only the var. The value is a box entry the issuer + # The harness shape names only the var. The value is a box entry the issuer # answers from the bound row; nothing rides the run env. A pasted bearer # is the Authorization header spelled out, delivered like any other. - linear = next(s for s in kwargs["mcp_servers"] if s.name == "linear") + linear = next(s for s in servers if s.name == "linear") assert linear.bearer_token_env_var == "" assert linear.env_headers == {"Authorization": "MCP_LINEAR_HEADER_0"} - assert "extra_env" not in kwargs [ref] = refs.values() assert ref.key == ("mcp_linear_header_0", row.id, "", "mcp.linear.app") assert _TOKEN not in repr(linear) + repr(refs) assert await row.issue_token("") == (f"Bearer {_TOKEN}", None) -async def test_required_server_delivers_beside_the_registry(druks_db): +async def test_workspace_server_delivers_beside_the_registry(druks_db): # A workspace declares a server with its own vault row and resource - # (SoftwareFactory's review identity and repo): wire shape + entry ride + # (SoftwareFactory's review identity and repo): harness shape + entry ride # the same seam as every registry server. await McpServer.create(druks_db, name="linear", url=_LINEAR_URL, secret_headers=_BEARER) row = await _github_row() - workspace = _requiring( - RequiredMcpServer( + workspace = _declaring( + SandboxMcpServer( name="github", url="https://api.githubcopilot.com/mcp/", secret_id=row.id, @@ -186,9 +182,9 @@ async def test_required_server_delivers_beside_the_registry(druks_db): ) ) - wire, refs = await workspace.get_mcp_delivery(db_session(), None, None) + servers, refs = await workspace.get_all_mcp_servers(db_session(), None, None) - github = next(s for s in wire if s.name == "github") + github = next(s for s in servers if s.name == "github") assert github.url == "https://api.githubcopilot.com/mcp/" assert github.bearer_token_env_var == "MCP_GITHUB_TOKEN" by_name = {ref.name: ref for ref in refs} @@ -198,25 +194,25 @@ async def test_required_server_delivers_beside_the_registry(druks_db): "acme/widgets", "api.githubcopilot.com", ) - assert "linear" in {s.name for s in wire} + assert "linear" in {s.name for s in servers} assert "mcp_linear_header_0" in by_name -async def test_required_server_owns_its_name_against_a_registry_twin(druks_db): - # Exactly one wire entry per name — the workspace's — and the registry twin +async def test_workspace_server_owns_its_name_against_a_registry_twin(druks_db): + # Exactly one harness entry per name — the workspace's — and the registry twin # is skipped whole: it is neither bound to an entry nor resolved at all (a # tokenless twin would otherwise raise). await McpServer.create(druks_db, name="linear", url=_LINEAR_URL, secret_headers=_BEARER) await McpServer.create(druks_db, name="notion", url="https://mcp.notion.com/sse") row = await _github_row() - workspace = _requiring( - RequiredMcpServer(name="linear", url="https://required.internal/linear", secret_id=row.id), - RequiredMcpServer(name="notion", url="https://required.internal/notion", secret_id=row.id), + workspace = _declaring( + SandboxMcpServer(name="linear", url="https://required.internal/linear", secret_id=row.id), + SandboxMcpServer(name="notion", url="https://required.internal/notion", secret_id=row.id), ) - wire, refs = await workspace.get_mcp_delivery(db_session(), None, None) + servers, refs = await workspace.get_all_mcp_servers(db_session(), None, None) - delivered = [s for s in wire if s.name == "linear"] + delivered = [s for s in servers if s.name == "linear"] assert len(delivered) == 1 assert delivered[0].url == "https://required.internal/linear" by_name = {ref.name: ref for ref in refs} @@ -224,16 +220,16 @@ async def test_required_server_owns_its_name_against_a_registry_twin(druks_db): assert by_name["mcp_notion_token"].secret_id == row.id -async def test_duplicate_required_names_are_refused(druks_db): +async def test_duplicate_workspace_names_are_refused(druks_db): # Two servers under one name would collide in the emitted harness config # (one TOML table / JSON key per name) — refused loudly at delivery. - workspace = _requiring( - RequiredMcpServer(name="github", url="https://a/", secret_id="one"), - RequiredMcpServer(name="github", url="https://b/", secret_id="two"), + workspace = _declaring( + SandboxMcpServer(name="github", url="https://a/", secret_id="one"), + SandboxMcpServer(name="github", url="https://b/", secret_id="two"), ) - with pytest.raises(ValueError, match="duplicate required"): - await workspace.get_mcp_delivery(db_session(), None, None) + with pytest.raises(ValueError, match="duplicate workspace"): + await workspace.get_all_mcp_servers(db_session(), None, None) async def test_enabled_server_without_secrets_raises_loudly(druks_db): @@ -243,13 +239,12 @@ async def test_enabled_server_without_secrets_raises_loudly(druks_db): await McpServer.create(druks_db, name="notion", url="https://mcp.notion.com/sse") with pytest.raises(MissingTokenError, match="notion"): - await _delivery() + await _servers() async def test_enabled_server_reaches_both_harness_configs_without_token(druks_db): await McpServer.create(druks_db, name="linear", url=_LINEAR_URL, secret_headers=_BEARER) - kwargs = await _delivery() - servers = kwargs["mcp_servers"] + servers = await _servers() claude_config = " ".join( ClaudeHarness( @@ -271,7 +266,6 @@ async def test_enabled_server_reaches_both_harness_configs_without_token(druks_d assert _LINEAR_URL in codex_config assert "MCP_LINEAR_HEADER_0" in codex_config assert _TOKEN not in codex_config - assert "extra_env" not in kwargs # --- declared headers: N per server, secret values via env refs ----------- @@ -293,17 +287,16 @@ async def _grafana_shaped_server() -> None: async def test_declared_headers_deliver_inline_and_secret_values_are_entries(druks_db): await _grafana_shaped_server() - kwargs = await _delivery() + servers = await _servers() refs = await _refs() - grafana = next(s for s in kwargs["mcp_servers"] if s.name == "grafana") - # The wire shape names the env var behind each secret header; the value is + grafana = next(s for s in servers if s.name == "grafana") + # The harness shape names the env var behind each secret header; the value is # a box entry for that header on the server's host, never inline. assert grafana.headers == {"X-Grafana-URL": "https://acme.grafana.net"} assert grafana.env_headers == {"X-Api-Key": "MCP_GRAFANA_HEADER_0"} assert "grafana-api-secret" not in repr(grafana) + repr(refs) - assert "extra_env" not in kwargs - # No bearer: neither the wire shape nor the box carries an Authorization entry. + # No bearer: neither the harness shape nor the box carries an Authorization entry. assert grafana.bearer_token_env_var == "" [ref] = refs.values() row = await druks_db.get(VaultSecret, ref.secret_id) @@ -323,10 +316,10 @@ async def test_three_secret_headers_bind_three_entries(druks_db): secret_headers={**_BEARER, "X-Api-Key": "key-secret", "X-Org": "org-secret"}, ) - kwargs = await _delivery() + servers = await _servers() refs = await _refs() - acme = next(s for s in kwargs["mcp_servers"] if s.name == "acme") + acme = next(s for s in servers if s.name == "acme") assert acme.env_headers == { "Authorization": "MCP_ACME_HEADER_0", "X-Api-Key": "MCP_ACME_HEADER_1", @@ -342,8 +335,7 @@ async def test_three_secret_headers_bind_three_entries(druks_db): async def test_two_header_server_emits_both_headers_in_each_harness_config(druks_db): await _grafana_shaped_server() - kwargs = await _delivery() - servers = kwargs["mcp_servers"] + servers = await _servers() header_env_var = servers[0].env_headers["X-Api-Key"] claude_flags = ClaudeHarness( @@ -381,8 +373,7 @@ async def test_secret_and_declared_headers_combine_on_one_server(druks_db): headers={"X-Region": "eu"}, ) - kwargs = await _delivery() - servers = kwargs["mcp_servers"] + servers = await _servers() claude_flags = ClaudeHarness( model="claude-x", fast_mode=False, effort=None, sandbox=_sandbox_config() @@ -392,7 +383,6 @@ async def test_secret_and_declared_headers_combine_on_one_server(druks_db): "Authorization": "${MCP_ACME_HEADER_0}", "X-Region": "eu", } - assert "extra_env" not in kwargs async def test_bearerless_server_merges_with_its_headers(druks_db): @@ -559,14 +549,13 @@ def _static_entry(url): async def test_packaged_catalog_is_empty_and_delivers_nothing(registry_state, druks_db): # The packaged default is an explicit empty ``mcpServers`` map: a fresh # install registers no built-ins and delivers no MCP servers. SoftwareFactory's github - # MCP is SoftwareFactory's own requirement (get_required_mcp_servers), never a catalog + # MCP is SoftwareFactory's own requirement (get_mcp_servers), never a catalog # entry. load_mcp_catalog(PACKAGED_MCP_CATALOG) assert not [s for s in (await McpServer._merged(druks_db)).values() if s["builtin"]] - kwargs = await _delivery() - assert "mcp_servers" not in kwargs + assert await _servers() == () def test_load_catalog_tolerates_wrapper_and_is_idempotent(tmp_path, registry_state): @@ -668,12 +657,12 @@ async def test_catalog_enabled_false_ships_the_entry_dark(tmp_path, registry_sta # --- druks' own server: each account's token, on first use ----------------- -def _requiring_druks(monkeypatch, allowed_tools=()) -> type[Workspace]: +def _declaring_druks(monkeypatch, allowed_tools=()) -> type[Workspace]: monkeypatch.setattr( "druks.mcp.inbound.load_settings", lambda: SimpleNamespace(urls=Urls(endpoint="https://druks.test/", webhook_host="")), ) - return _requiring(get_druks_mcp_server(allowed_tools=allowed_tools)) + return _declaring(get_druks_mcp_server(allowed_tools=allowed_tools)) async def _druks_row(account_id: str) -> VaultSecret: @@ -699,15 +688,15 @@ def test_druks_needs_an_address_a_box_reaches(monkeypatch): async def test_delivery_mints_the_run_account_its_own_token(druks_db, monkeypatch): allowed_tools = ("software_factory_get_ticket",) - workspace = _requiring_druks(monkeypatch, allowed_tools) + workspace = _declaring_druks(monkeypatch, allowed_tools) account = await Account.get_or_create(druks_db, "op@example.com") - wire, refs = await workspace.get_mcp_delivery(db_session(), None, account.id) + servers, refs = await workspace.get_all_mcp_servers(db_session(), None, account.id) row = await _druks_row(account.id) minted = await PersonalAccessToken.authenticate(druks_db, await _druks_pat(account.id)) assert (minted.account_id, minted.allowed_tools) == (account.id, list(allowed_tools)) - server = next(one for one in wire if one.name == DRUKS_SERVER_NAME) + server = next(one for one in servers if one.name == DRUKS_SERVER_NAME) assert server.url == "https://druks.test/mcp" assert server.bearer_token_env_var == "MCP_DRUKS_TOKEN" assert {ref.name: ref.secret_id for ref in refs}["mcp_druks_token"] == row.id @@ -716,32 +705,32 @@ async def test_delivery_mints_the_run_account_its_own_token(druks_db, monkeypatc async def test_a_later_run_reuses_the_token_and_a_retired_one_is_replaced(druks_db, monkeypatch): - workspace = _requiring_druks(monkeypatch) + workspace = _declaring_druks(monkeypatch) account = await Account.get_or_create(druks_db, "op@example.com") - await workspace.get_mcp_delivery(db_session(), None, account.id) + await workspace.get_all_mcp_servers(db_session(), None, account.id) first = await _druks_pat(account.id) # No tools: the token carries the account's whole API. assert (await PersonalAccessToken.authenticate(druks_db, first)).allowed_tools is None - await workspace.get_mcp_delivery(db_session(), None, account.id) + await workspace.get_all_mcp_servers(db_session(), None, account.id) assert await _druks_pat(account.id) == first assert len(await PersonalAccessToken.list_for_account(druks_db, account.id)) == 1 await (await PersonalAccessToken.authenticate(druks_db, first)).revoke() - await workspace.get_mcp_delivery(db_session(), None, account.id) + await workspace.get_all_mcp_servers(db_session(), None, account.id) assert await _druks_pat(account.id) != first assert len(await PersonalAccessToken.list_for_account(druks_db, account.id)) == 2 async def test_two_accounts_hold_their_own_tokens(druks_db, monkeypatch): - workspace = _requiring_druks(monkeypatch) + workspace = _declaring_druks(monkeypatch) first = await Account.get_or_create(druks_db, "first@example.com") second = await Account.get_or_create(druks_db, "second@example.com") - await workspace.get_mcp_delivery(db_session(), None, first.id) - await workspace.get_mcp_delivery(db_session(), None, second.id) + await workspace.get_all_mcp_servers(db_session(), None, first.id) + await workspace.get_all_mcp_servers(db_session(), None, second.id) holders = [ (await PersonalAccessToken.authenticate(druks_db, await _druks_pat(account_id))).account_id diff --git a/backend/tests/test_sandbox_identities.py b/backend/tests/test_sandbox_identities.py index b8063d0d..12df629d 100644 --- a/backend/tests/test_sandbox_identities.py +++ b/backend/tests/test_sandbox_identities.py @@ -405,7 +405,7 @@ async def issue_token(resource: str) -> tuple[str, datetime]: async def _mcp_identity() -> tuple[SandboxIdentity, str, dict]: # The one ref a box of a plain workspace binds for the enabled servers. await seed_run(db_session(), kind=Summarize.kind, run_id="run-1") - [ref] = (await Workspace.get_mcp_delivery(db_session(), None, None))[1] + [ref] = (await Workspace.get_all_mcp_servers(db_session(), None, None))[1] identity, entries = await SandboxIdentity.create( db_session(), account_id=(await Account.get_for_run(db_session(), None)).id, diff --git a/backend/tests/test_warm_host_rotation.py b/backend/tests/test_warm_host_rotation.py index e7cb16c6..b69b769c 100644 --- a/backend/tests/test_warm_host_rotation.py +++ b/backend/tests/test_warm_host_rotation.py @@ -82,8 +82,8 @@ async def test_warm_host_reused_while_lease_covers_another_call(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) flow = _warm_workflow() - first = await flow._lease_host(db_session(), _NONE) - second = await flow._lease_host(db_session(), _NONE) + first = await flow._lease_host(db_session(), _NONE, []) + second = await flow._lease_host(db_session(), _NONE, []) assert first == second == "host-1" assert fake.provisions == ["wf-1:workflow"] @@ -98,8 +98,8 @@ async def test_warm_host_rotates_when_lease_cannot_cover_a_call(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) flow = _warm_workflow() - first = await flow._lease_host(db_session(), _NONE) - second = await flow._lease_host(db_session(), _NONE) + first = await flow._lease_host(db_session(), _NONE, []) + second = await flow._lease_host(db_session(), _NONE, []) assert first == "host-1" assert second == "host-2" @@ -114,9 +114,9 @@ async def test_warm_host_keeps_its_entries_across_calls(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) flow = _warm_workflow() - first = await flow._lease_host(db_session(), _ANTHROPIC) + first = await flow._lease_host(db_session(), _ANTHROPIC, []) second = await flow._lease_host( - db_session(), _config({"anthropic": _ENTRY}, _ANTHROPIC.secrets_id) + db_session(), _config({"anthropic": _ENTRY}, _ANTHROPIC.secrets_id), [] ) assert first == second == "host-1" @@ -133,8 +133,8 @@ async def test_warm_host_rotates_when_a_call_needs_other_entries(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) flow = _warm_workflow() - first = await flow._lease_host(db_session(), _ANTHROPIC) - second = await flow._lease_host(db_session(), _NONE) + first = await flow._lease_host(db_session(), _ANTHROPIC, []) + second = await flow._lease_host(db_session(), _NONE, []) assert first == "host-1" assert second == "host-2" @@ -151,9 +151,9 @@ async def test_provisioning_key_names_the_pasted_key(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) replaced = _config({"anthropic": _ENTRY}, "anthropic.20260907T120000") - await _warm_workflow()._lease_host(db_session(), _ANTHROPIC) - await _warm_workflow()._lease_host(db_session(), _ANTHROPIC) - await _warm_workflow()._lease_host(db_session(), replaced) + await _warm_workflow()._lease_host(db_session(), _ANTHROPIC, []) + await _warm_workflow()._lease_host(db_session(), _ANTHROPIC, []) + await _warm_workflow()._lease_host(db_session(), replaced, []) assert fake.provisions == [ "wf-1:workflow:anthropic.20260907T110000", @@ -170,7 +170,7 @@ async def test_no_warm_host_when_reuse_disabled(monkeypatch): monkeypatch.setattr(sdk, "sandbox_client", fake) flow = _warm_workflow(reuse=False) - assert await flow._lease_host(db_session(), _NONE) is None + assert await flow._lease_host(db_session(), _NONE, []) is None assert fake.provisions == [] @@ -202,7 +202,7 @@ async def test_a_replay_finds_the_warm_box_through_its_identity( config = SimpleNamespace(secrets={}, secret_refs=secrets, secrets_id=subscription.id) flow.account_id = identity.account_id - assert await flow._lease_host(db_session(), config) == "host-crashed" + assert await flow._lease_host(db_session(), config, secrets) == "host-crashed" assert client.reattached == ["host-crashed"] assert client.provisions == [] diff --git a/backend/tests/test_workspaces.py b/backend/tests/test_workspaces.py index 032ed480..db9087ac 100644 --- a/backend/tests/test_workspaces.py +++ b/backend/tests/test_workspaces.py @@ -8,6 +8,7 @@ from druks.contrib.software_factory.services import GithubReviewer from druks.core.apis.github import GitHubClient from druks.core.services import Github +from druks.sandbox import SandboxSecret from druks.sandbox.layout import get_repo_root from druks.secrets.models import VaultSecret from druks.workspaces import RepoWorkspace, Workspace @@ -60,10 +61,10 @@ async def test_repo_workspace_names_its_github_secret_and_repo_before_the_box_ex row = await _connect() subject = SimpleNamespace(repo="acme/widgets") - [secret] = await RepoWorkspace.get_secret_refs(subject) + [secret] = await RepoWorkspace.get_secrets(subject) - assert secret.key == ("github", row.id, "acme/widgets", "") - assert await Workspace.get_secret_refs(subject) == [] + assert secret == SandboxSecret(name="github", secret_id=row.id, resource="acme/widgets") + assert await Workspace.get_secrets(subject) == [] async def test_a_workspace_selects_its_github_identity_by_service(druks_db): @@ -72,7 +73,7 @@ async def test_a_workspace_selects_its_github_identity_by_service(druks_db): class Reviewing(RepoWorkspace): github = GithubReviewer - [secret] = await Reviewing.get_secret_refs(SimpleNamespace(repo="o/r")) + [secret] = await Reviewing.get_secrets(SimpleNamespace(repo="o/r")) assert (secret.secret_id, secret.resource) == (row.id, "o/r") diff --git a/docs/configuration.md b/docs/configuration.md index c2f96d72..f3d31198 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -714,7 +714,7 @@ Druks gives OAuth discovery and client registration 30 seconds in total. A timeout names the stage that was pending. Retry the connection. Druks delivers enabled servers through the selected harness unless an app -workspace owns a required server with the same name. Each OAuth bearer and +workspace declares a server with the same name. Each OAuth bearer and each secret header is a Drukbox entry behind a vault row. The sandbox holds a placeholder under a derived variable, and the harness configuration names that variable. The secrets proxy swaps the placeholder only for the server's host. diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index 6eac5d24..8dd768e3 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -668,20 +668,52 @@ those two, so a request cannot select another repo or identity. Override `Workflow.get_workspace_kwargs()` to pass `branch` or the fields a subclass adds. Extend `RepoWorkspace` by adding fields, not by cloning again. -Override `run_agent()` to prepare the VM before the call, `get_agent_run_kwargs()` -to grant directories or skills, and `get_required_mcp_servers(subject)` to -require an MCP server with its own vault row: +Override `run_agent()` to prepare the VM before the call, and +`get_agent_run_kwargs()` to grant directories or skills. + +Override `get_secrets(subject)` to give the sandbox a secret of its own: + +```python +from druks.sandbox import SandboxSecret +from druks.workspaces import Workspace + +from .services import BillingApi + + +class InvoiceWorkspace(Workspace): + @classmethod + async def get_secrets(cls, subject) -> list[SandboxSecret]: + return [ + SandboxSecret( + name="billing_token", + secret_id=(await BillingApi.get()).id, + host="api.billing.example", + ) + ] +``` + +The secret names the vault row the issuer answers from. With a `host`, it is a +custom entry. The sandbox holds a placeholder in `BILLING_TOKEN`, the name in +upper case. The secrets proxy puts the value in a request header only for that +host. A header row supplies its own header. Any other row goes out as +`Authorization: Bearer `. Without a `host`, the name is a Drukbox +catalog entry such as `github`, and Drukbox sets its variable and hosts. +`resource` tells the issuer what the token is for, such as a repo. Druks reads +the secrets before the sandbox exists, so read them from the subject alone. + +Override `get_mcp_servers(subject)` to give the sandbox an MCP server with its +own vault row: ```python -from druks.sandbox.datastructures import RequiredMcpServer +from druks.sandbox import SandboxMcpServer class BuildWorkspace(RepoWorkspace): @classmethod - async def get_required_mcp_servers(cls, subject) -> tuple[RequiredMcpServer, ...]: + async def get_mcp_servers(cls, subject) -> tuple[SandboxMcpServer, ...]: actor = await get_review_actor() return ( - RequiredMcpServer( + SandboxMcpServer( name="github", url="https://api.githubcopilot.com/mcp/", secret_id=(await actor.service.get()).id, @@ -694,10 +726,11 @@ The server names the vault row the issuer answers from and what the token is for: here a connected GitHub service and its repo. Druks binds the server's host and the variable `MCP_GITHUB_TOKEN` to the entry when it creates the sandbox. The harness configuration names the variable, and the sandbox never -holds the token. A required server owns its name, so a same-named registry -server is not delivered. `Workspace.get_mcp_delivery(subject, account_id)` -returns the wire shapes and the secret refs for every MCP server of a sandbox. -Override it to deliver none. +holds the token. A workspace server owns its name, so a same-named registry +server is not delivered. `Workspace.get_all_mcp_servers(subject, account_id)` +returns the harness shapes and the secret refs for every MCP server of a +sandbox: the workspace's servers and the enabled registry servers. Override it +to give the sandbox none. Keep durable state outside the VM. A workflow can set `steps_reuse_sandbox = True` to retain one host across a segment. Druks releases @@ -1847,7 +1880,7 @@ Import from concern namespaces, not from `druks.durable` or internal modules: | `druks.services` | `Service`, `ServiceConnectError`, `ServiceNotConnectedError`, `OauthClient`, `OauthExchangeError`, `OauthRefreshError` | | `druks.agents` | `Agent`, `AgentOutput`, `Bot`, `BotUser` | | `druks.workflows` | `Workflow`, `Gate`, `step`, run/agent response types, lifecycle enums and workflow errors | -| `druks.sandbox` | `Sandbox` | +| `druks.sandbox` | `Sandbox`, `SandboxMcpServer`, `SandboxSecret` | | `druks.workspaces` | `Workspace`, `RepoWorkspace` | | `druks.db` | `Model`, `StoredSubject`, `db_session` | | `druks.db.fields` | `EncryptedJsonField`, `EncryptedTextField`, `Secret`, `SecretsMapping` |